From 48c88cb93af4520615b46f40a545564c6024e6d9 Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Tue, 26 Nov 2024 01:36:59 +0400 Subject: [PATCH] feat: additional expenses --- main.py | 2 +- models/__init__.py | 1 + models/expense.py | 24 ++++++ routers/__init__.py | 3 +- routers/expense.py | 49 +++++++++++ routers/statistics.py | 6 +- schemas/base.py | 4 +- schemas/expense.py | 52 ++++++++++++ services/expenses.py | 85 +++++++++++++++++++ services/payroll.py | 5 +- services/statistics/__init__.py | 1 + services/statistics/common.py | 19 +++++ services/statistics/expenses_statistics.py | 60 +++++++++++++ .../profit_statistics.py} | 82 ++++-------------- test.py | 5 +- 15 files changed, 318 insertions(+), 80 deletions(-) create mode 100644 models/expense.py create mode 100644 routers/expense.py create mode 100644 schemas/expense.py create mode 100644 services/expenses.py create mode 100644 services/statistics/__init__.py create mode 100644 services/statistics/common.py create mode 100644 services/statistics/expenses_statistics.py rename services/{statistics.py => statistics/profit_statistics.py} (86%) diff --git a/main.py b/main.py index b400761..e3de9b6 100644 --- a/main.py +++ b/main.py @@ -47,7 +47,7 @@ routers_list = [ routers.task_router, routers.statistics_router, routers.work_shifts_router, - + routers.expense_router, ] for router in routers_list: app.include_router(router) diff --git a/models/__init__.py b/models/__init__.py index 7e11751..9572120 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -13,5 +13,6 @@ from .payroll import * from .billing import * from .marketplace_products import * from .deal_group import * +from .expense import * configure_mappers() diff --git a/models/expense.py b/models/expense.py new file mode 100644 index 0000000..d06cee5 --- /dev/null +++ b/models/expense.py @@ -0,0 +1,24 @@ +from datetime import datetime, date +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from models import BaseModel + +if TYPE_CHECKING: + from models import User + + +class Expense(BaseModel): + __tablename__ = 'expenses' + + id: Mapped[int] = mapped_column(primary_key=True) + created_at: Mapped[datetime] = mapped_column(nullable=False) + spent_date: Mapped[date] = mapped_column(nullable=False) + name: Mapped[str] = mapped_column() + comment: Mapped[str] = mapped_column() + amount: Mapped[float] = mapped_column() + + created_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + created_by_user: Mapped["User"] = relationship(foreign_keys=[created_by_user_id]) diff --git a/routers/__init__.py b/routers/__init__.py index 47be862..79c9806 100644 --- a/routers/__init__.py +++ b/routers/__init__.py @@ -14,4 +14,5 @@ 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 \ No newline at end of file +from .statistics import statistics_router +from .expense import expense_router \ No newline at end of file diff --git a/routers/expense.py b/routers/expense.py new file mode 100644 index 0000000..5e685b1 --- /dev/null +++ b/routers/expense.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends + +from backend.dependecies import SessionDependency, CurrentUserDependency, PaginationDependency +from schemas.expense import GetAllExpensesResponse, UpdateExpenseResponse, UpdateExpenseRequest, DeleteExpenseResponse +from services.auth import authorized_user +from services.expenses import ExpensesService + +expense_router = APIRouter( + prefix="/expense", + tags=["expense"], + dependencies=[Depends(authorized_user)] +) + + +@expense_router.get( + '/get-all', + operation_id='get_all_expenses', + response_model=GetAllExpensesResponse, +) +async def get_all( + session: SessionDependency, + pagination: PaginationDependency, +): + return await ExpensesService(session).get_all(pagination) + + +@expense_router.post( + '/update', + operation_id='update_expense', + response_model=UpdateExpenseResponse, +) +async def update_expense( + session: SessionDependency, + request: UpdateExpenseRequest, + user: CurrentUserDependency, +): + return await ExpensesService(session).update_expense(user, request) + + +@expense_router.delete( + '/delete/{expense_id}', + operation_id='delete_expense', + response_model=DeleteExpenseResponse, +) +async def delete_expense( + session: SessionDependency, + expense_id: int, +): + return await ExpensesService(session).delete_expense(expense_id) diff --git a/routers/statistics.py b/routers/statistics.py index a2637ac..f6c14de 100644 --- a/routers/statistics.py +++ b/routers/statistics.py @@ -7,7 +7,7 @@ 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 +from services.statistics import ProfitStatisticsService statistics_router = APIRouter( prefix="/statistics", @@ -25,7 +25,7 @@ async def get_profit_chart_data( session: Annotated[AsyncSession, Depends(get_session)], request: GetProfitChartDataRequest ): - return await StatisticsService(session).get_profit_chart_data(request) + return await ProfitStatisticsService(session).get_profit_chart_data(request) @statistics_router.post( @@ -37,4 +37,4 @@ async def get_profit_table_data( session: Annotated[AsyncSession, Depends(get_session)], request: GetProfitTableDataRequest ): - return await StatisticsService(session).get_profit_table_data(request) + return await ProfitStatisticsService(session).get_profit_table_data(request) diff --git a/schemas/base.py b/schemas/base.py index 96525a5..2c1e51b 100644 --- a/schemas/base.py +++ b/schemas/base.py @@ -47,8 +47,8 @@ class PaginationSchema(BaseSchema): class PaginationInfoSchema(BaseSchema): - total_pages: int - total_items: int + total_pages: int = 0 + total_items: int = 0 class BaseEnumSchema(BaseSchema): diff --git a/schemas/expense.py b/schemas/expense.py new file mode 100644 index 0000000..6e99cd8 --- /dev/null +++ b/schemas/expense.py @@ -0,0 +1,52 @@ +import datetime +from typing import Optional + +from schemas.base import OkMessageSchema, BaseSchema, PaginationInfoSchema +from schemas.user import UserSchema + + +# region Entities + +class ExpenseSchemaBase(BaseSchema): + id: int + name: str + comment: str + amount: float + created_by_user: UserSchema + spent_date: datetime.date + + +class UpdateExpenseSchema(BaseSchema): + id: Optional[int] = None + name: str + comment: Optional[str] = "" + amount: float + spent_date: datetime.date + + +# endregion + +# region Requests + + +class UpdateExpenseRequest(BaseSchema): + expense: UpdateExpenseSchema + + +# endregion + +# region Responses + +class GetAllExpensesResponse(BaseSchema): + expenses: list[ExpenseSchemaBase] + pagination_info: PaginationInfoSchema + + +class UpdateExpenseResponse(OkMessageSchema): + pass + + +class DeleteExpenseResponse(OkMessageSchema): + pass + +# endregion diff --git a/services/expenses.py b/services/expenses.py new file mode 100644 index 0000000..8e601bb --- /dev/null +++ b/services/expenses.py @@ -0,0 +1,85 @@ +from datetime import datetime +from typing import Optional + +import math +from fastapi import HTTPException +from sqlalchemy import delete, select, func +from fastapi import status + +from models import User +from models.expense import Expense +from schemas.base import PaginationSchema, PaginationInfoSchema +from schemas.expense import UpdateExpenseResponse, UpdateExpenseRequest, DeleteExpenseResponse, GetAllExpensesResponse +from services.base import BaseService +from utils.dependecies import is_valid_pagination + + +class ExpensesService(BaseService): + async def get_all(self, pagination: PaginationSchema) -> GetAllExpensesResponse: + if not is_valid_pagination(pagination): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid pagination') + page = max(0, pagination.page - 1) + + stmt = ( + select(Expense) + .order_by(Expense.spent_date.desc()) + .offset(page * pagination.items_per_page) + .limit(pagination.items_per_page) + ) + + total_records = await self.session.scalar(select(func.count()).select_from(Expense)) + if not total_records: + return GetAllExpensesResponse( + expenses=[], + pagination_info=PaginationInfoSchema() + ) + total_items = total_records + total_pages = math.ceil(total_records / pagination.items_per_page) + + expenses = await self.session.execute(stmt) + expenses = expenses.scalars().all() + response = GetAllExpensesResponse( + expenses=expenses, + pagination_info=PaginationInfoSchema( + total_items=total_items, + total_pages=total_pages + ) + ) + return response + + async def get_by_id(self, expense_id) -> Optional[Expense]: + expense = await self.session.get(Expense, expense_id) + return expense + + async def update_expense(self, user: User, request: UpdateExpenseRequest) -> UpdateExpenseResponse: + expense = await self.get_by_id(request.expense.id) + + if not expense: + expense = Expense( + created_at=datetime.now(), + name=request.expense.name, + comment=request.expense.comment, + amount=request.expense.amount, + spent_date=request.expense.spent_date, + created_by_user_id=user.id, + ) + self.session.add(expense) + await self.session.commit() + return UpdateExpenseResponse(ok=True, message='Запись о расходах успешно создана') + + expense.name = request.expense.name + expense.amount = request.expense.amount + expense.comment = request.expense.comment + expense.spent_date = request.expense.spent_date + self.session.add(expense) + await self.session.commit() + return UpdateExpenseResponse(ok=True, message='Запись о расходах успешно изменена') + + async def delete_expense(self, expense_id) -> DeleteExpenseResponse: + stmt = ( + delete(Expense) + .where(Expense.id == expense_id) + ) + await self.session.execute(stmt) + await self.session.commit() + return DeleteExpenseResponse(ok=True, message='Запись о расходах успешно удалена') diff --git a/services/payroll.py b/services/payroll.py index b52097e..d6b5580 100644 --- a/services/payroll.py +++ b/services/payroll.py @@ -149,10 +149,7 @@ class PayrollService(BaseService): if not total_records: return GetPaymentRecordsResponse( payment_records=[], - pagination_info=PaginationInfoSchema( - total_pages=0, - total_items=0 - ) + pagination_info=PaginationInfoSchema() ) total_items = total_records total_pages = math.ceil(total_records / pagination.items_per_page) diff --git a/services/statistics/__init__.py b/services/statistics/__init__.py new file mode 100644 index 0000000..488448b --- /dev/null +++ b/services/statistics/__init__.py @@ -0,0 +1 @@ +from .profit_statistics import ProfitStatisticsService \ No newline at end of file diff --git a/services/statistics/common.py b/services/statistics/common.py new file mode 100644 index 0000000..ec2680c --- /dev/null +++ b/services/statistics/common.py @@ -0,0 +1,19 @@ +from datetime import date + +from sqlalchemy import select, func, literal, text, cast, CTE +from sqlalchemy.dialects.postgresql import TIMESTAMP + + +def generate_date_range(date_from: date, date_to: date, additional_columns: list[str]) -> CTE: + cols = [literal(0).label(col_label) for col_label in additional_columns] + return select( + cast( + func.generate_series( + date_from, + date_to, + text("'1 day'") + ), + TIMESTAMP(timezone=False) + ).label("date"), + *cols, + ).cte() diff --git a/services/statistics/expenses_statistics.py b/services/statistics/expenses_statistics.py new file mode 100644 index 0000000..00cb3bf --- /dev/null +++ b/services/statistics/expenses_statistics.py @@ -0,0 +1,60 @@ +from datetime import date +from sqlalchemy import select, func, Subquery, cast +from sqlalchemy.dialects.postgresql import TIMESTAMP +from models import PaymentRecord, Expense +from services.base import BaseService +from services.statistics.common import generate_date_range + + +class ExpensesStatisticsService(BaseService): + date_from: date + date_to: date + + def _get_expenses_sub(self, model, date_column, amount_column) -> Subquery: + all_dates = generate_date_range(self.date_from, self.date_to, ["expenses"]) + + expenses = ( + select( + func.sum(getattr(model, amount_column)).label("expenses"), + cast(getattr(model, date_column), TIMESTAMP(timezone=False)).label("date"), + ) + .group_by("date") + .subquery() + ) + + expenses_with_gaps_filled = ( + select( + all_dates.c.date, + (all_dates.c.expenses + func.coalesce(expenses.c.expenses, 0)).label("expenses"), + ) + .join(expenses, all_dates.c.date == expenses.c.date, isouter=True) + .order_by(all_dates.c.date) + .subquery() + ) + return expenses_with_gaps_filled + + def _apply_expenses(self, deals_by_dates: Subquery, expenses_subquery: Subquery): + return ( + select( + deals_by_dates.c.date, + deals_by_dates.c.deals_count, + deals_by_dates.c.revenue, + (func.coalesce(deals_by_dates.c.profit, 0) - func.coalesce(expenses_subquery.c.expenses, 0)).label( + "profit"), + (deals_by_dates.c.expenses + expenses_subquery.c.expenses).label("expenses"), + ) + .join(expenses_subquery, expenses_subquery.c.date == deals_by_dates.c.date) + ) + + def apply_expenses(self, date_from: date, date_to: date, deals_by_dates: Subquery): + self.date_from, self.date_to = date_from, date_to + + # Apply salary expenses + salary_expenses = self._get_expenses_sub(PaymentRecord, "start_date", "amount") + deals_by_dates = self._apply_expenses(deals_by_dates, salary_expenses) + + # Apply additional expenses + additional_expenses = self._get_expenses_sub(Expense, "spent_date", "amount") + deals_by_dates = self._apply_expenses(deals_by_dates, additional_expenses) + + return deals_by_dates diff --git a/services/statistics.py b/services/statistics/profit_statistics.py similarity index 86% rename from services/statistics.py rename to services/statistics/profit_statistics.py index 3ab8e0c..dfded38 100644 --- a/services/statistics.py +++ b/services/statistics/profit_statistics.py @@ -1,17 +1,19 @@ -from datetime import datetime, timedelta +from datetime import datetime from fastapi import HTTPException -from sqlalchemy import select, and_, union_all, func, Subquery, literal, text +from sqlalchemy import select, and_, union_all, func, Subquery, literal from enums.profit_table_group_by import ProfitTableGroupBy from models import DealService, Deal, DealStatusHistory, DealProductService, DealProduct, Service, Client, \ - ShippingWarehouse, BaseMarketplace, User, PaymentRecord + ShippingWarehouse, BaseMarketplace, User from schemas.statistics import GetProfitChartDataResponse, GetProfitChartDataRequest, ProfitChartDataItem, \ GetProfitTableDataResponse, GetProfitTableDataRequest, ProfitTableDataItem, CommonProfitFilters from services.base import BaseService +from services.statistics.common import generate_date_range +from services.statistics.expenses_statistics import ExpensesStatisticsService -class StatisticsService(BaseService): +class ProfitStatisticsService(BaseService): @staticmethod def _get_sub_deals_created_at(date_from: datetime.date, date_to: datetime.date): return ( @@ -46,7 +48,7 @@ class StatisticsService(BaseService): @staticmethod def _get_filtered_sub_status_history(date_from: datetime.date, date_to: datetime.date): - sub_status_history = StatisticsService._get_sub_status_history() + sub_status_history = ProfitStatisticsService._get_sub_status_history() return ( select(sub_status_history) .where(sub_status_history.c.date.between(date_from, date_to)) @@ -55,8 +57,8 @@ class StatisticsService(BaseService): def _get_deals_dates(self, deal_status_id: int): if deal_status_id == -1: - return StatisticsService._get_sub_deals_created_at(self.date_from, self.date_to) - return StatisticsService._get_filtered_sub_status_history(self.date_from, self.date_to) + return ProfitStatisticsService._get_sub_deals_created_at(self.date_from, self.date_to) + return ProfitStatisticsService._get_filtered_sub_status_history(self.date_from, self.date_to) @staticmethod def _to_schema(rows, is_chart: bool = True) -> ( @@ -161,7 +163,7 @@ class StatisticsService(BaseService): ) def _group_by_date(self, stmt): - all_dates = self._generate_date_range(["deals_count", "revenue", "profit", "expenses"]) + all_dates = generate_date_range(self.date_from, self.date_to, ["deals_count", "revenue", "profit", "expenses"]) deals = ( select( stmt.c.date, @@ -276,60 +278,6 @@ class StatisticsService(BaseService): .group_by(managers.c.id, "grouped_value") ) - def _generate_date_range(self, additional_columns: list[str]): - cols = [literal(0).label(col_label) for col_label in additional_columns] - date_from = self.date_from + timedelta(days=1) - return select( - func.date_trunc( - "day", - func.generate_series( - date_from, - self.date_to, - text("'1 day'") - ), - ).label("date"), - *cols, - ).cte() - - def _get_expenses_sub(self) -> Subquery: - all_dates = self._generate_date_range(["expenses"]) - payment_records = ( - select( - func.sum(PaymentRecord.amount).label("expenses"), - func.date_trunc( - "day", - PaymentRecord.start_date, - ).label("date"), - ) - .where(PaymentRecord.start_date.between(self.date_from, self.date_to)) - .group_by("date") - .subquery() - ) - payments_with_filled_gaps = ( - select( - all_dates.c.date, - (all_dates.c.expenses + func.coalesce(payment_records.c.expenses, 0)).label("expenses"), - ) - .join(payment_records, all_dates.c.date == payment_records.c.date, isouter=True) - .order_by(all_dates.c.date) - .subquery() - ) - return payments_with_filled_gaps - - def _apply_expenses(self, deals_by_dates: Subquery): - expenses_by_dates = self._get_expenses_sub() - - return ( - select( - deals_by_dates.c.date, - deals_by_dates.c.deals_count, - deals_by_dates.c.revenue, - (func.coalesce(deals_by_dates.c.profit, 0) - func.coalesce(expenses_by_dates.c.expenses, 0)).label("profit"), - expenses_by_dates.c.expenses, - ) - .join(expenses_by_dates, expenses_by_dates.c.date == deals_by_dates.c.date) - ) - async def _get_data_rows_grouped_by_date( self, stmt_deal_services, @@ -344,7 +292,12 @@ class StatisticsService(BaseService): sub_grouped_by_deals = self._group_by_deals(sub_union) sub_deals_grouped_by_date = self._group_by_date(sub_grouped_by_deals).subquery() - stmt_deals_applied_expenses = self._apply_expenses(sub_deals_grouped_by_date) + expenses_statistics_service = ExpensesStatisticsService(self.session) + stmt_deals_applied_expenses = expenses_statistics_service.apply_expenses( + self.date_from, + self.date_to, + sub_deals_grouped_by_date + ) result = await self.session.execute(stmt_deals_applied_expenses) rows = result.all() @@ -352,7 +305,6 @@ class StatisticsService(BaseService): async def _get_data_grouped_by_date(self, request: CommonProfitFilters, is_chart: bool = True): self.date_from, self.date_to = request.date_range - self.date_to += timedelta(days=1) sub_deals_dates = self._get_deals_dates(request.deal_status_id) @@ -393,7 +345,6 @@ class StatisticsService(BaseService): def _get_common_table_grouped(self, request: GetProfitTableDataRequest): self.date_from, self.date_to = request.date_range - self.date_to += timedelta(days=1) sub_deals_dates = self._get_deals_dates(request.deal_status_id) @@ -433,7 +384,6 @@ class StatisticsService(BaseService): 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) diff --git a/test.py b/test.py index 52fa082..d09eb7a 100644 --- a/test.py +++ b/test.py @@ -5,15 +5,14 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.session import session_maker -from schemas.statistics import GetProfitChartDataRequest -from services.statistics import StatisticsService +from services.statistics.profit_statistics import ProfitStatisticsService async def main(): session: AsyncSession = session_maker() try: - service = StatisticsService(session) + service = ProfitStatisticsService(session) service.date_from = datetime.date(2020, 1, 20) service.date_to = datetime.date(2020, 2, 10) stmt = service._generate_date_range(["expenses"])