Merge remote-tracking branch 'origin/statistics'

# Conflicts:
#	main.py
#	routers/__init__.py
#	schemas/deal.py
This commit is contained in:
2024-11-21 01:36:26 +03:00
14 changed files with 567 additions and 10 deletions

View File

@@ -0,0 +1,9 @@
from enum import IntEnum
class ProfitTableGroupBy(IntEnum):
BY_DATES = 0
BY_CLIENTS = 1
BY_STATUSES = 2
BY_WAREHOUSES = 3
BY_MARKETPLACES = 4

View File

@@ -45,7 +45,9 @@ routers_list = [
routers.time_tracking_router,
routers.billing_router,
routers.task_router,
routers.statistics_router,
routers.work_shifts_router,
]
for router in routers_list:
app.include_router(router)

View File

@@ -9,6 +9,7 @@ from models.work_shifts import WorkShift
if TYPE_CHECKING:
from models.payroll import PayRate, PaymentRecord
from models import Deal
role_permissions = Table(
'role_permissions',
@@ -99,6 +100,11 @@ class User(BaseModel):
foreign_keys="WorkShift.user_id"
)
managed_deals: Mapped[list["Deal"]] = relationship(
back_populates="manager",
uselist=True,
)
class Position(BaseModel):
__tablename__ = 'positions'

View File

@@ -10,9 +10,12 @@ from .marketplace import BaseMarketplace
from .shipping_warehouse import ShippingWarehouse
if TYPE_CHECKING:
from . import (DealBillRequest, ServicePriceCategory,
DealGroup
)
from . import (
DealBillRequest,
ServicePriceCategory,
DealGroup,
User,
)
# @unique
@@ -98,6 +101,9 @@ class Deal(BaseModel):
back_populates='deals'
)
manager_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=True)
manager: Mapped[Optional["User"]] = relationship(back_populates='managed_deals', lazy='joined')
class DealStatusHistory(BaseModel):
__tablename__ = 'deals_status_history'

View File

@@ -14,3 +14,4 @@ from .time_tracking import time_tracking_router
from .billing import billing_router
from .task import task_router
from .work_shifts import work_shifts_router
from .statistics import statistics_router

40
routers/statistics.py Normal file
View File

@@ -0,0 +1,40 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from backend.session import get_session
from schemas.statistics import GetProfitChartDataRequest, GetProfitChartDataResponse, GetProfitTableDataResponse, \
GetProfitTableDataRequest
from services.auth import authorized_user
from services.statistics import StatisticsService
statistics_router = APIRouter(
prefix="/statistics",
tags=["statistics"],
dependencies=[Depends(authorized_user)]
)
@statistics_router.post(
'/get-profit-chart-data',
response_model=GetProfitChartDataResponse,
operation_id='get_profit_chart_data',
)
async def get_profit_chart_data(
session: Annotated[AsyncSession, Depends(get_session)],
request: GetProfitChartDataRequest
):
return await StatisticsService(session).get_profit_chart_data(request)
@statistics_router.post(
'/get-profit-table-data',
response_model=GetProfitTableDataResponse,
operation_id='get_profit_table_data',
)
async def get_profit_table_data(
session: Annotated[AsyncSession, Depends(get_session)],
request: GetProfitTableDataRequest
):
return await StatisticsService(session).get_profit_table_data(request)

View File

@@ -45,3 +45,14 @@ async def create(
request: CreateUserRequest
):
return await UserService(session).create(request)
@user_router.get(
'/get-managers',
response_model=GetManagersResponse,
operation_id='get_managers',
)
async def get_managers(
session: SessionDependency,
):
return await UserService(session).get_managers()

View File

@@ -6,7 +6,7 @@ from pydantic import constr, field_validator
from models import ServiceCategoryPrice, ServicePriceCategory, Deal, Product, DealProduct, DealStatusHistory
from schemas.base import BaseSchema, OkMessageSchema
from schemas.billing import DealBillRequestSchema, GroupBillRequestSchema
from schemas.billing import DealBillRequestSchema,GroupBillRequestSchema
from schemas.client import ClientSchema
from schemas.marketplace import BaseMarketplaceSchema
from schemas.product import ProductSchema
@@ -101,6 +101,7 @@ class DealSchema(BaseSchema):
bill_request: Optional[DealBillRequestSchema] = None
category: Optional[ServicePriceCategorySchema] = None
group: Optional[DealGroupSchema] = None
manager: Optional[UserSchema] = None
delivery_date: Optional[datetime.datetime] = None
receiving_slot_date: Optional[datetime.datetime] = None
@@ -114,6 +115,7 @@ class DealGeneralInfoSchema(BaseSchema):
shipping_warehouse: Optional[str] = None
delivery_date: Optional[datetime.datetime] = None
receiving_slot_date: Optional[datetime.datetime] = None
manager: Optional[UserSchema] = None
# endregion Entities

48
schemas/statistics.py Normal file
View File

@@ -0,0 +1,48 @@
import datetime
from typing import List, Tuple
from enums.profit_table_group_by import ProfitTableGroupBy
from schemas.base import BaseSchema
# region Entities
class ProfitChartDataItem(BaseSchema):
date: datetime.date
revenue: float
profit: float
deals_count: int
class ProfitTableDataItem(BaseSchema):
grouped_value: datetime.date | str | int
revenue: float
profit: float
deals_count: int
# endregion
# region Requests
class GetProfitChartDataRequest(BaseSchema):
date_range: Tuple[datetime.date, datetime.date]
client_id: int
base_marketplace_key: str
deal_status_id: int
class GetProfitTableDataRequest(BaseSchema):
date_range: Tuple[datetime.date, datetime.date]
group_table_by: ProfitTableGroupBy
# endregion
# region Responses
class GetProfitChartDataResponse(BaseSchema):
data: List[ProfitChartDataItem]
class GetProfitTableDataResponse(BaseSchema):
data: List[ProfitTableDataItem]
# endregion

View File

@@ -63,4 +63,9 @@ class UpdateUserResponse(OkMessageSchema):
class CreateUserResponse(OkMessageSchema):
pass
class GetManagersResponse(BaseSchema):
managers: List[UserSchema]
# endregion

View File

@@ -18,6 +18,7 @@ from services.client import ClientService
from services.deal_group import DealGroupService
from services.service import ServiceService
from services.shipping_warehouse import ShippingWarehouseService
from services.user import UserService
class DealService(BaseService):
@@ -360,6 +361,14 @@ class DealService(BaseService):
shipping_warehouse = await shipping_warehouse_service.create_by_name(request.data.shipping_warehouse)
deal.shipping_warehouse = shipping_warehouse
# Updating manager
if request.data.manager:
user_service = UserService(self.session)
user = await user_service.get_by_id(request.data.manager.id)
deal.manager = user
else:
deal.manager = None
await self.session.commit()
return DealUpdateGeneralInfoResponse(ok=True, message='Данные о сделке успешно обновлены')
except Exception as e:

399
services/statistics.py Normal file
View File

@@ -0,0 +1,399 @@
from collections import defaultdict
from datetime import datetime, timedelta
from fastapi import HTTPException
from sqlalchemy import select, and_, union_all, func, Subquery
from enums.profit_table_group_by import ProfitTableGroupBy
from models import DealService, Deal, DealStatusHistory, DealProductService, DealProduct, Service, Client, \
ShippingWarehouse, BaseMarketplace
from schemas.statistics import GetProfitChartDataResponse, GetProfitChartDataRequest, ProfitChartDataItem, \
GetProfitTableDataResponse, GetProfitTableDataRequest, ProfitTableDataItem
from services.base import BaseService
class StatisticsService(BaseService):
@staticmethod
def _get_sub_deals_created_at(date_from: datetime.date, date_to: datetime.date):
return (
select(
Deal.id.label('deal_id'),
Deal.created_at.label('deal_date'),
Deal.current_status,
)
.where(Deal.created_at.between(date_from, date_to))
.subquery()
)
@staticmethod
def _get_sub_status_history():
last_statuses = (
select(
DealStatusHistory.deal_id,
func.max(DealStatusHistory.changed_at).label('changed_at')
)
.group_by(DealStatusHistory.deal_id)
.subquery()
)
return (
select(
Deal.id.label('deal_id'),
last_statuses.c.changed_at.label('deal_date'),
Deal.current_status,
)
.join(last_statuses, last_statuses.c.deal_id == Deal.id)
.subquery()
)
@staticmethod
def _get_filtered_sub_status_history(date_from: datetime.date, date_to: datetime.date):
sub_status_history = StatisticsService._get_sub_status_history()
return (
select(sub_status_history)
.where(sub_status_history.c.deal_date.between(date_from, date_to))
.subquery()
)
@staticmethod
def _get_deals_dates(deal_status_id: int, date_from: datetime.date, date_to: datetime.date):
if deal_status_id == -1:
return StatisticsService._get_sub_deals_created_at(date_from, date_to)
return StatisticsService._get_filtered_sub_status_history(date_from, date_to)
@staticmethod
def _fill_dates_gaps(rows, date_from: datetime.date, date_to: datetime.date, is_chart: bool = True) -> (
list[ProfitChartDataItem] | list[ProfitTableDataItem]
):
dates = defaultdict(lambda: {"deals_count": 0, "profit": 0, "revenue": 0})
for row in rows:
dates[row.deal_date.date()] = {
"deals_count": row.deals_count,
"profit": row.profit,
"revenue": row.revenue
}
data = []
while date_from < date_to:
if is_chart:
data_item = ProfitChartDataItem(
date=date_from,
deals_count=dates[date_from]["deals_count"],
profit=dates[date_from]["profit"],
revenue=dates[date_from]["revenue"],
)
else:
data_item = ProfitTableDataItem(
grouped_value=date_from,
deals_count=dates[date_from]["deals_count"],
profit=dates[date_from]["profit"],
revenue=dates[date_from]["revenue"],
)
data.append(data_item)
date_from += timedelta(days=1)
return data
@staticmethod
def _get_stmt_deal_services(sub_filtered_status_history: Subquery):
return (
select(
Deal.id.label("deal_id"),
func.date_trunc(
"day",
sub_filtered_status_history.c.deal_date,
).label("deal_date"),
func.sum(DealService.price * DealService.quantity).label("revenue"),
func.sum((DealService.price - Service.cost) * DealService.quantity).label("profit"),
)
.join(DealService, Deal.id == DealService.deal_id)
.join(Service, DealService.service_id == Service.id)
.join(sub_filtered_status_history, Deal.id == sub_filtered_status_history.c.deal_id)
.where(Deal.is_deleted == False)
.group_by(Deal.id, "deal_date")
)
@staticmethod
def _apply_filters(request: GetProfitChartDataRequest, stmt_deal_services, stmt_deal_product_services):
if request.client_id != -1:
stmt_deal_services = stmt_deal_services.where(Deal.client_id == request.client_id)
stmt_deal_product_services = stmt_deal_product_services.where(Deal.client_id == request.client_id)
if request.base_marketplace_key != "all":
stmt_deal_services = stmt_deal_services.where(Deal.base_marketplace_key == request.base_marketplace_key)
stmt_deal_product_services = stmt_deal_product_services.where(
Deal.base_marketplace_key == request.base_marketplace_key)
if request.deal_status_id != -1:
stmt_deal_services = stmt_deal_services.where(Deal.current_status == request.deal_status_id)
stmt_deal_product_services = stmt_deal_product_services.where(Deal.current_status == request.deal_status_id)
return stmt_deal_services, stmt_deal_product_services
@staticmethod
def _get_stmt_product_services():
return (
select(
Deal.id.label("deal_id"),
func.sum(DealProductService.price * DealProduct.quantity).label("revenue"),
func.sum((DealProductService.price - Service.cost) * DealProduct.quantity).label("profit"),
)
.join(DealProduct, Deal.id == DealProduct.deal_id)
.join(
DealProductService,
and_(
DealProductService.deal_id == Deal.id,
DealProductService.product_id == DealProduct.product_id,
)
)
.join(Service, DealProductService.service_id == Service.id)
.where(Deal.is_deleted == False)
.group_by(Deal.id)
)
@staticmethod
def _get_joined_deals_and_statuses(sub_deal_product_services: Subquery, sub_deals_dates: Subquery):
return (
select(
sub_deal_product_services.c.deal_id,
func.date_trunc(
"day",
sub_deals_dates.c.deal_date
).label("deal_date"),
sub_deal_product_services.c.revenue.label("revenue"),
sub_deal_product_services.c.profit.label("profit"),
)
.join(sub_deals_dates, sub_deal_product_services.c.deal_id == sub_deals_dates.c.deal_id)
)
@staticmethod
def _group_by_deals(stmt_union: Subquery):
return (
select(
stmt_union.c.deal_id,
stmt_union.c.deal_date,
func.sum(stmt_union.c.profit).label("profit"),
func.sum(stmt_union.c.revenue).label("revenue"),
)
.group_by(stmt_union.c.deal_id, stmt_union.c.deal_date)
)
@staticmethod
def _group_by_date(stmt):
return (
select(
stmt.c.deal_date,
func.count(stmt.c.deal_id).label("deals_count"),
func.sum(stmt.c.revenue).label("revenue"),
func.sum(stmt.c.profit).label("profit"),
)
.group_by(stmt.c.deal_date)
.order_by(stmt.c.deal_date.asc())
)
@staticmethod
def _join_and_group_by_clients(stmt):
return (
select(
Client.id,
Client.name.label("grouped_value"),
func.count(stmt.c.deal_id).label("deals_count"),
func.sum(stmt.c.revenue).label("revenue"),
func.sum(stmt.c.profit).label("profit"),
)
.join(Deal, Deal.id == stmt.c.deal_id)
.join(Client, Client.id == Deal.client_id)
.group_by(Client.id, Client.name)
)
@staticmethod
def _join_and_group_by_statuses(stmt):
return (
select(
Deal.current_status.label("grouped_value"),
func.count(stmt.c.deal_id).label("deals_count"),
func.sum(stmt.c.revenue).label("revenue"),
func.sum(stmt.c.profit).label("profit"),
)
.join(Deal, Deal.id == stmt.c.deal_id)
.group_by(Deal.current_status)
)
@staticmethod
def _join_and_group_by_warehouses(stmt):
return (
select(
ShippingWarehouse.id,
ShippingWarehouse.name.label("grouped_value"),
ShippingWarehouse.is_deleted,
func.count(stmt.c.deal_id).label("deals_count"),
func.sum(stmt.c.revenue).label("revenue"),
func.sum(stmt.c.profit).label("profit"),
)
.join(Deal, Deal.id == stmt.c.deal_id)
.join(ShippingWarehouse, Deal.shipping_warehouse_id == ShippingWarehouse.id)
.where(ShippingWarehouse.is_deleted == False)
.group_by(ShippingWarehouse.is_deleted, ShippingWarehouse.id, ShippingWarehouse.name)
)
@staticmethod
def _join_and_group_by_marketplaces(stmt):
return (
select(
BaseMarketplace.name.label("grouped_value"),
func.count(stmt.c.deal_id).label("deals_count"),
func.sum(stmt.c.revenue).label("revenue"),
func.sum(stmt.c.profit).label("profit"),
)
.join(Deal, Deal.id == stmt.c.deal_id)
.join(BaseMarketplace, Deal.base_marketplace_key == BaseMarketplace.key)
.group_by(BaseMarketplace.name)
)
async def _get_data_rows_grouped_by_date(
self,
stmt_deal_services,
stmt_deal_product_services,
sub_deals_dates: Subquery,
):
sub_deal_product_services = stmt_deal_product_services.subquery()
stmt_join_deals_statuses = self._get_joined_deals_and_statuses(sub_deal_product_services, sub_deals_dates)
sub_union = union_all(stmt_deal_services, stmt_join_deals_statuses).subquery()
sub_grouped_by_deals = self._group_by_deals(sub_union)
stmt_grouped_by_date = self._group_by_date(sub_grouped_by_deals)
result = await self.session.execute(stmt_grouped_by_date)
rows = result.all()
return rows
async def get_profit_chart_data(self, request: GetProfitChartDataRequest) -> GetProfitChartDataResponse:
date_from, date_to = request.date_range
date_to += timedelta(days=1)
sub_deals_dates = self._get_deals_dates(request.deal_status_id, date_from, date_to)
stmt_deal_services = self._get_stmt_deal_services(sub_deals_dates)
stmt_deal_product_services = self._get_stmt_product_services()
stmt_deal_services, stmt_deal_product_services = self._apply_filters(
request,
stmt_deal_services,
stmt_deal_product_services
)
rows = await self._get_data_rows_grouped_by_date(
stmt_deal_services, stmt_deal_product_services, sub_deals_dates
)
data = self._fill_dates_gaps(rows, date_from, date_to)
return GetProfitChartDataResponse(data=data)
async def _get_table_grouped_by_dates(self, request: GetProfitTableDataRequest) -> GetProfitTableDataResponse:
date_from, date_to = request.date_range
date_to += timedelta(days=1)
sub_deals_dates = self._get_sub_deals_created_at(date_from, date_to)
stmt_deal_services = self._get_stmt_deal_services(sub_deals_dates)
stmt_deal_product_services = self._get_stmt_product_services()
rows = await self._get_data_rows_grouped_by_date(
stmt_deal_services, stmt_deal_product_services, sub_deals_dates
)
data = self._fill_dates_gaps(rows, date_from, date_to, False)
return GetProfitTableDataResponse(data=data)
async def _table_data_from_stmt(self, stmt) -> GetProfitTableDataResponse:
result = await self.session.execute(stmt)
rows = result.all()
data = []
for row in rows:
data.append(ProfitTableDataItem(
grouped_value=row.grouped_value,
revenue=row.revenue,
profit=row.profit,
deals_count=row.deals_count,
))
return GetProfitTableDataResponse(data=data)
def _get_common_table_grouped(self, request: GetProfitTableDataRequest):
date_from, date_to = request.date_range
date_to += timedelta(days=1)
sub_deals_dates = self._get_sub_deals_created_at(date_from, date_to)
stmt_deal_services = self._get_stmt_deal_services(sub_deals_dates)
stmt_deal_product_services = self._get_stmt_product_services()
stmt_deal_product_services = (
select(
stmt_deal_product_services.c.deal_id,
func.date_trunc(
"day",
Deal.created_at
).label("deal_date"),
stmt_deal_product_services.c.revenue.label("revenue"),
stmt_deal_product_services.c.profit.label("profit"),
)
.join(Deal, Deal.id == stmt_deal_product_services.c.deal_id)
)
sub_union = union_all(stmt_deal_services, stmt_deal_product_services).subquery()
sub_grouped_by_deals = self._group_by_deals(sub_union)
return sub_grouped_by_deals
async def _get_table_grouped_by_clients(self, request: GetProfitTableDataRequest) -> GetProfitTableDataResponse:
sub_grouped_by_deals = self._get_common_table_grouped(request)
stmt_grouped_by_clients = self._join_and_group_by_clients(sub_grouped_by_deals)
return await self._table_data_from_stmt(stmt_grouped_by_clients)
async def _get_table_grouped_by_statuses(self, request: GetProfitTableDataRequest) -> GetProfitTableDataResponse:
date_from, date_to = request.date_range
date_to += timedelta(days=1)
sub_deals_dates = self._get_filtered_sub_status_history(date_from, date_to)
stmt_deal_services = self._get_stmt_deal_services(sub_deals_dates)
sub_deal_product_services = self._get_stmt_product_services().subquery()
stmt_join_deals_statuses = self._get_joined_deals_and_statuses(sub_deal_product_services, sub_deals_dates)
sub_union = union_all(stmt_deal_services, stmt_join_deals_statuses).subquery()
sub_grouped_by_deals = self._group_by_deals(sub_union)
stmt_grouped_by_date = self._join_and_group_by_statuses(sub_grouped_by_deals)
return await self._table_data_from_stmt(stmt_grouped_by_date)
async def _get_table_grouped_by_warehouses(self, request: GetProfitTableDataRequest) -> GetProfitTableDataResponse:
sub_grouped_by_deals = self._get_common_table_grouped(request)
stmt_grouped_by_clients = self._join_and_group_by_warehouses(sub_grouped_by_deals)
return await self._table_data_from_stmt(stmt_grouped_by_clients)
async def _get_table_grouped_by_marketplace(self, request: GetProfitTableDataRequest) -> GetProfitTableDataResponse:
sub_grouped_by_deals = self._get_common_table_grouped(request)
stmt_grouped_by_clients = self._join_and_group_by_marketplaces(sub_grouped_by_deals)
return await self._table_data_from_stmt(stmt_grouped_by_clients)
async def get_profit_table_data(self, request: GetProfitTableDataRequest) -> GetProfitTableDataResponse:
match request.group_table_by:
case ProfitTableGroupBy.BY_DATES:
return await self._get_table_grouped_by_dates(request)
case ProfitTableGroupBy.BY_CLIENTS:
return await self._get_table_grouped_by_clients(request)
case ProfitTableGroupBy.BY_STATUSES:
return await self._get_table_grouped_by_statuses(request)
case ProfitTableGroupBy.BY_WAREHOUSES:
return await self._get_table_grouped_by_warehouses(request)
case ProfitTableGroupBy.BY_MARKETPLACES:
return await self._get_table_grouped_by_marketplace(request)
raise HTTPException(status_code=400, detail='Указана некорректная группировка')

View File

@@ -1,4 +1,4 @@
from sqlalchemy import select, update, delete, insert
from sqlalchemy import select, update, delete, insert, and_
from models import User, user_position, user_pay_rate
from services.base import BaseService
@@ -16,6 +16,16 @@ class UserService(BaseService):
users_schemas = [UserSchema.model_validate(user) for user in users]
return GetAllUsersResponse(users=users_schemas)
async def get_managers(self) -> GetManagersResponse:
stmt = (
select(User)
.order_by(User.id.desc())
.where(and_(User.is_deleted == False, User.role_key == "employee"))
)
users = (await self.session.scalars(stmt)).all()
users_schemas = [UserSchema.model_validate(user) for user in users]
return GetManagersResponse(managers=users_schemas)
async def get_by_id(self, user_id: int) -> Optional[User]:
return await self.session.scalar(select(User).where(User.id == user_id))

19
test.py
View File

@@ -1,21 +1,30 @@
import asyncio
import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from backend.session import session_maker
from services.billing import BillingService
from schemas.statistics import GetProfitChartDataRequest
from services.statistics import StatisticsService
async def main():
session: AsyncSession = session_maker()
request = GetProfitChartDataRequest(
date_range=(
datetime.date(2020, 1, 1),
datetime.date(2020, 1, 31),
),
)
try:
service = BillingService(session)
service = StatisticsService(session)
pdf_file = await service.create_billing_document_pdf(121)
result = await service.get_profit_chart_data(request)
with open("report.pdf", "wb") as f:
f.write(pdf_file.getvalue())
# for res in result:
# print(res)
finally:
await session.close()