From e5712224e144d5e826d12aa05a12c4b6966e5e5f Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Thu, 12 Dec 2024 20:23:19 +0400 Subject: [PATCH] feat: income --- main.py | 2 +- models/__init__.py | 2 +- models/expense.py | 51 ----- models/transaction.py | 54 +++++ routers/__init__.py | 2 +- routers/expense.py | 103 ---------- routers/transaction.py | 118 +++++++++++ schemas/expense.py | 86 -------- schemas/statistics.py | 3 +- schemas/transaction.py | 97 +++++++++ services/expenses.py | 170 ---------------- services/statistics/expenses_statistics.py | 54 +---- services/statistics/profit_statistics.py | 14 +- .../statistics/transactions_statistics.py | 93 +++++++++ services/transaction.py | 186 ++++++++++++++++++ test.py | 2 +- 16 files changed, 574 insertions(+), 463 deletions(-) delete mode 100644 models/expense.py create mode 100644 models/transaction.py delete mode 100644 routers/expense.py create mode 100644 routers/transaction.py delete mode 100644 schemas/expense.py create mode 100644 schemas/transaction.py delete mode 100644 services/expenses.py create mode 100644 services/statistics/transactions_statistics.py create mode 100644 services/transaction.py diff --git a/main.py b/main.py index 3dbd91f..4af0e18 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, + routers.transaction_router, routers.shipping_router, ] for router in routers_list: diff --git a/models/__init__.py b/models/__init__.py index 9572120..c916de0 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -13,6 +13,6 @@ from .payroll import * from .billing import * from .marketplace_products import * from .deal_group import * -from .expense import * +from .transaction import * configure_mappers() diff --git a/models/expense.py b/models/expense.py deleted file mode 100644 index e2a70ab..0000000 --- a/models/expense.py +++ /dev/null @@ -1,51 +0,0 @@ -from datetime import datetime, date -from typing import TYPE_CHECKING - -from sqlalchemy import ForeignKey, Table, Column -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from models import BaseModel - -if TYPE_CHECKING: - from models import User - - -expenses_expense_tags = Table( - 'expenses_expense_tags', - BaseModel.metadata, - Column('expense_id', ForeignKey('expenses.id', ondelete='CASCADE'), primary_key=True), - Column('expense_tag_id', ForeignKey('expense_tags.id'), primary_key=True), -) - - -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], lazy="selectin") - - tags: Mapped[list["ExpenseTag"]] = relationship( - secondary=expenses_expense_tags, - lazy='selectin', - back_populates='expenses', - cascade='all, delete', - ) - - -class ExpenseTag(BaseModel): - __tablename__ = 'expense_tags' - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(unique=True) - - expenses: Mapped[list["Expense"]] = relationship( - secondary=expenses_expense_tags, - lazy='selectin', - back_populates='tags', - ) diff --git a/models/transaction.py b/models/transaction.py new file mode 100644 index 0000000..33066ac --- /dev/null +++ b/models/transaction.py @@ -0,0 +1,54 @@ +from datetime import datetime, date +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, Table, Column, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from models import BaseModel + +if TYPE_CHECKING: + from models import User + + +transactions_transaction_tags = Table( + 'transactions_transaction_tags', + BaseModel.metadata, + Column('transaction_id', ForeignKey('transactions.id', ondelete='CASCADE'), primary_key=True), + Column('transaction_tag_id', ForeignKey('transaction_tags.id'), primary_key=True), +) + + +class Transaction(BaseModel): + __tablename__ = 'transactions' + + 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() + is_income: Mapped[bool] = mapped_column(server_default=text("false"), nullable=False) + + 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], lazy="selectin") + + tags: Mapped[list["TransactionTag"]] = relationship( + secondary=transactions_transaction_tags, + lazy='selectin', + back_populates='transactions', + cascade='all, delete', + ) + + +class TransactionTag(BaseModel): + __tablename__ = 'transaction_tags' + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(index=True) + is_income: Mapped[bool] = mapped_column(server_default=text("false"), nullable=False) + + transactions: Mapped[list["Transaction"]] = relationship( + secondary=transactions_transaction_tags, + lazy='selectin', + back_populates='tags', + ) diff --git a/routers/__init__.py b/routers/__init__.py index 4461d7e..b3684b7 100644 --- a/routers/__init__.py +++ b/routers/__init__.py @@ -15,5 +15,5 @@ from .billing import billing_router from .task import task_router from .work_shifts import work_shifts_router from .statistics import statistics_router -from .expense import expense_router +from .transaction import transaction_router from .shipping import shipping_router diff --git a/routers/expense.py b/routers/expense.py deleted file mode 100644 index 8ae9df7..0000000 --- a/routers/expense.py +++ /dev/null @@ -1,103 +0,0 @@ -from fastapi import APIRouter, Depends - -from backend.dependecies import SessionDependency, CurrentUserDependency, PaginationDependency -from schemas.expense import * -from services.auth import authorized_user -from services.expenses import ExpensesService - -expense_router = APIRouter( - prefix="/expense", - tags=["expense"], - dependencies=[Depends(authorized_user)] -) - - -# region Expenses - -@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) - -# endregion - -# region Expense tags - -@expense_router.get( - '/get-all-tags', - operation_id='get_all_expense_tags', - response_model=GetAllExpenseTagsResponse, -) -async def get_all( - session: SessionDependency, -): - return await ExpensesService(session).get_all_tags() - - -@expense_router.post( - '/create-tag', - operation_id='create_expense_tag', - response_model=UpdateExpenseTagResponse, -) -async def update_expense( - session: SessionDependency, - request: CreateExpenseTagRequest, -): - return await ExpensesService(session).create_tag(request) - - -@expense_router.post( - '/update-tag', - operation_id='update_expense_tag', - response_model=UpdateExpenseTagResponse, -) -async def update_expense( - session: SessionDependency, - request: UpdateExpenseTagRequest, -): - return await ExpensesService(session).update_tag(request) - - -@expense_router.delete( - '/delete-tag/{tag_id}', - operation_id='delete_expense_tag', - response_model=DeleteExpenseTagResponse, -) -async def update_expense( - session: SessionDependency, - tag_id: int, -): - return await ExpensesService(session).delete_tag(tag_id) - -# endregion \ No newline at end of file diff --git a/routers/transaction.py b/routers/transaction.py new file mode 100644 index 0000000..a928a77 --- /dev/null +++ b/routers/transaction.py @@ -0,0 +1,118 @@ +from fastapi import APIRouter, Depends + +from backend.dependecies import SessionDependency, CurrentUserDependency, PaginationDependency +from schemas.transaction import * +from services.auth import authorized_user +from services.transaction import TransactionsService + +transaction_router = APIRouter( + prefix="/transaction", + tags=["transaction"], + dependencies=[Depends(authorized_user)] +) + + +# region Expenses + +@transaction_router.post( + '/get-all', + operation_id='get_all_transactions', + response_model=GetAllTransactionsResponse, +) +async def get_all( + session: SessionDependency, + pagination: PaginationDependency, + request: GetAllTransactionsRequest, +): + return await TransactionsService(session).get_all(pagination, request) + + +@transaction_router.post( + '/update', + operation_id='update_transaction', + response_model=UpdateTransactionResponse, +) +async def update_transaction( + session: SessionDependency, + request: UpdateTransactionRequest, + user: CurrentUserDependency, +): + return await TransactionsService(session).update_transaction(user, request) + + +@transaction_router.delete( + '/delete/{transaction_id}', + operation_id='delete_transaction', + response_model=DeleteTransactionResponse, +) +async def delete_transaction( + session: SessionDependency, + transaction_id: int, +): + return await TransactionsService(session).delete_transaction(transaction_id) + + +# endregion + +# region Expense tags + + +@transaction_router.get( + '/get-all-tags', + operation_id='get_all_transaction_tags', + response_model=GetAllTransactionTagsResponse, +) +async def get_all_transaction_tags( + session: SessionDependency, +): + return await TransactionsService(session).get_all_tags() + + +@transaction_router.get( + '/get-tags/{is_income}', + operation_id='get_transaction_tags', + response_model=GetTransactionTagsResponse, +) +async def get_transaction_tags( + session: SessionDependency, + is_income: bool, +): + return await TransactionsService(session).get_tags(is_income) + + +@transaction_router.post( + '/create-tag', + operation_id='create_transaction_tag', + response_model=UpdateTransactionTagResponse, +) +async def create_transaction_tag( + session: SessionDependency, + request: CreateTransactionTagRequest, +): + return await TransactionsService(session).create_tag(request) + + +@transaction_router.post( + '/update-tag', + operation_id='update_transaction_tag', + response_model=UpdateTransactionTagResponse, +) +async def update_transaction_tag( + session: SessionDependency, + request: UpdateTransactionTagRequest, +): + return await TransactionsService(session).update_tag(request) + + +@transaction_router.delete( + '/delete-tag/{tag_id}', + operation_id='delete_transaction_tag', + response_model=DeleteTransactionTagResponse, +) +async def update_transaction_tag( + session: SessionDependency, + tag_id: int, +): + return await TransactionsService(session).delete_tag(tag_id) + +# endregion diff --git a/schemas/expense.py b/schemas/expense.py deleted file mode 100644 index bf62d13..0000000 --- a/schemas/expense.py +++ /dev/null @@ -1,86 +0,0 @@ -import datetime -from typing import Optional - -from schemas.base import OkMessageSchema, BaseSchema, PaginationInfoSchema -from schemas.user import UserSchema - - -# region Entities - -class BaseExpenseTagSchema(BaseSchema): - name: str - - -class ExpenseTagSchema(BaseExpenseTagSchema): - id: int - - -class ExpenseSchemaBase(BaseSchema): - id: int - name: str - comment: str - amount: float - created_by_user: UserSchema - spent_date: datetime.date - tags: list[ExpenseTagSchema] - - -class UpdateExpenseSchema(BaseSchema): - id: Optional[int] = None - name: str - comment: Optional[str] = "" - amount: float - spent_date: datetime.date - tags: list[str] = [] - - -# endregion - -# region Requests - - -class UpdateExpenseRequest(BaseSchema): - expense: UpdateExpenseSchema - - -class CreateExpenseTagRequest(BaseSchema): - tag: BaseExpenseTagSchema - - -class UpdateExpenseTagRequest(BaseSchema): - tag: ExpenseTagSchema - - -# endregion - -# region Responses - -class GetAllExpensesResponse(BaseSchema): - expenses: list[ExpenseSchemaBase] - pagination_info: PaginationInfoSchema - - -class UpdateExpenseResponse(OkMessageSchema): - pass - - -class DeleteExpenseResponse(OkMessageSchema): - pass - - -class GetAllExpenseTagsResponse(BaseSchema): - tags: list[ExpenseTagSchema] - - -class CreateExpenseTagResponse(OkMessageSchema): - pass - - -class UpdateExpenseTagResponse(OkMessageSchema): - pass - - -class DeleteExpenseTagResponse(OkMessageSchema): - pass - -# endregion diff --git a/schemas/statistics.py b/schemas/statistics.py index 0c5fd86..316530b 100644 --- a/schemas/statistics.py +++ b/schemas/statistics.py @@ -31,7 +31,8 @@ class CommonProfitFilters(BaseSchema): base_marketplace_key: str deal_status_id: int manager_id: int - tag_id: int + expense_tag_id: int + income_tag_id: int class GetProfitChartDataRequest(CommonProfitFilters): diff --git a/schemas/transaction.py b/schemas/transaction.py new file mode 100644 index 0000000..632c3f7 --- /dev/null +++ b/schemas/transaction.py @@ -0,0 +1,97 @@ +import datetime +from typing import Optional + +from schemas.base import OkMessageSchema, BaseSchema, PaginationInfoSchema +from schemas.user import UserSchema + + +# region Entities + +class BaseTransactionTagSchema(BaseSchema): + name: str + is_income: bool + + +class TransactionTagSchema(BaseTransactionTagSchema): + id: int + + +class TransactionSchemaBase(BaseSchema): + id: int + name: str + comment: str + amount: float + created_by_user: UserSchema + spent_date: datetime.date + is_income: bool + tags: list[TransactionTagSchema] + + +class UpdateTransactionSchema(BaseSchema): + id: Optional[int] = None + name: str + comment: Optional[str] = "" + amount: float + spent_date: datetime.date + is_income: bool + tags: list[str] = [] + + +# endregion + +# region Requests + + +class GetAllTransactionsRequest(BaseSchema): + is_income: bool + + +class UpdateTransactionRequest(BaseSchema): + transaction: UpdateTransactionSchema + + +class CreateTransactionTagRequest(BaseSchema): + tag: BaseTransactionTagSchema + + +class UpdateTransactionTagRequest(BaseSchema): + tag: TransactionTagSchema + + +# endregion + +# region Responses + +class GetAllTransactionsResponse(BaseSchema): + transactions: list[TransactionSchemaBase] + pagination_info: PaginationInfoSchema + + +class UpdateTransactionResponse(OkMessageSchema): + pass + + +class DeleteTransactionResponse(OkMessageSchema): + pass + + +class GetTransactionTagsResponse(BaseSchema): + tags: list[TransactionTagSchema] + + +class GetAllTransactionTagsResponse(BaseSchema): + tags: list[TransactionTagSchema] + + +class CreateTransactionTagResponse(OkMessageSchema): + pass + + +class UpdateTransactionTagResponse(OkMessageSchema): + pass + + +class DeleteTransactionTagResponse(OkMessageSchema): + pass + +# endregion diff --git a/services/expenses.py b/services/expenses.py deleted file mode 100644 index 14afca9..0000000 --- a/services/expenses.py +++ /dev/null @@ -1,170 +0,0 @@ -from datetime import datetime - -import math -from fastapi import HTTPException -from fastapi import status -from sqlalchemy import delete, select, func, update, insert - -from models import User -from models.expense import Expense, ExpenseTag -from schemas.base import PaginationSchema -from schemas.expense import * -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_pages=0, - total_items=0 - ) - ) - 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: int) -> Optional[Expense]: - expense = await self.session.get(Expense, expense_id) - return expense - - async def add_tags(self, expense: Expense, tag_names: list[str]): - tags = [] - for tag_name in tag_names: - existing_tag = await self.get_tag_by_name(tag_name) - if existing_tag: - tags.append(existing_tag) - else: - tag = ExpenseTag(name=tag_name) - self.session.add(tag) - tags.append(tag) - - expense.tags = tags - - async def update_expense(self, user: User, request: UpdateExpenseRequest) -> UpdateExpenseResponse: - expense = await self.get_by_id(request.expense.id or -1) - - if not expense: - new_expense = Expense( - created_at=datetime.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(new_expense) - await self.add_tags(new_expense, request.expense.tags) - 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.add_tags(expense, request.expense.tags) - 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='Запись о расходах успешно удалена') - - async def get_all_tags(self) -> GetAllExpenseTagsResponse: - stmt = ( - select(ExpenseTag) - .order_by(ExpenseTag.id) - ) - tags = await self.session.execute(stmt) - return GetAllExpenseTagsResponse(tags=tags.scalars().all()) - - async def get_tag_by_id(self, expense_tag_id: int) -> Optional[ExpenseTag]: - return await self.session.get(ExpenseTag, expense_tag_id) - - async def get_tag_by_name(self, expense_tag_name: str) -> Optional[ExpenseTag]: - stmt = ( - select(ExpenseTag) - .where(ExpenseTag.name == expense_tag_name) - ) - tag = await self.session.scalar(stmt) - return tag - - async def create_tag(self, request: CreateExpenseTagRequest) -> CreateExpenseTagResponse: - tag = await self.get_tag_by_name(request.tag.name) - if tag: - return CreateExpenseTagResponse(ok=False, message='Такой тег уже есть.') - - tag_dict = request.tag.model_dump() - stmt = ( - insert(ExpenseTag) - .values(**tag_dict) - ) - await self.session.execute(stmt) - await self.session.commit() - return CreateExpenseTagResponse(ok=True, message='Тег успешно создан.') - - async def update_tag(self, request: UpdateExpenseTagRequest) -> UpdateExpenseTagResponse: - tag = await self.get_tag_by_name(request.tag.name) - if tag: - return UpdateExpenseTagResponse(ok=False, message='Тег с таким названием уже есть.') - - tag = await self.get_tag_by_id(request.tag.id) - if not tag: - return UpdateExpenseTagResponse(ok=False, message='Тег не найден.') - - tag_dict = request.tag.model_dump() - del tag_dict['id'] - stmt = ( - update(ExpenseTag) - .where(ExpenseTag.id == request.tag.id) - .values(**tag_dict) - ) - await self.session.execute(stmt) - await self.session.commit() - return UpdateExpenseTagResponse(ok=True, message='Тег успешно изменен.') - - async def delete_tag(self, tag_id: int) -> DeleteExpenseTagResponse: - tag = await self.get_tag_by_id(tag_id) - if not tag: - return DeleteExpenseTagResponse(ok=False, message='Тег не найден.') - if len(tag.expenses) > 0: - return DeleteExpenseTagResponse(ok=False, message='Тег прикреплен к записи о расходах.') - - stmt = ( - delete(ExpenseTag) - .where(ExpenseTag.id == tag_id) - ) - await self.session.execute(stmt) - await self.session.commit() - return DeleteExpenseTagResponse(ok=True, message='Тег удален') diff --git a/services/statistics/expenses_statistics.py b/services/statistics/expenses_statistics.py index 40304fe..6d4f161 100644 --- a/services/statistics/expenses_statistics.py +++ b/services/statistics/expenses_statistics.py @@ -3,24 +3,24 @@ from datetime import date from sqlalchemy import select, func, Subquery, cast, CTE from sqlalchemy.dialects.postgresql import TIMESTAMP -from models import PaymentRecord, Expense, expenses_expense_tags +from models import PaymentRecord from schemas.statistics import CommonProfitFilters from services.base import BaseService from services.statistics.common import generate_date_range -class ExpensesStatisticsService(BaseService): +class PaymentStatisticsService(BaseService): date_from: date date_to: date @staticmethod - def _fill_date_gaps(expenses: Subquery, all_dates: CTE) -> Subquery: + def _fill_date_gaps(payments: Subquery, all_dates: CTE) -> Subquery: return ( select( all_dates.c.date, - (all_dates.c.expenses + func.coalesce(expenses.c.expenses, 0)).label("expenses"), + (all_dates.c.expenses + func.coalesce(payments.c.expenses, 0)).label("expenses"), ) - .join(expenses, all_dates.c.date == expenses.c.date, isouter=True) + .join(payments, all_dates.c.date == payments.c.date, isouter=True) .order_by(all_dates.c.date) .subquery() ) @@ -40,58 +40,24 @@ class ExpensesStatisticsService(BaseService): expenses_with_filled_gaps = self._fill_date_gaps(expenses, all_dates) return expenses_with_filled_gaps - def _get_additional_expenses_sub(self, tag_id: int) -> Subquery: - all_dates = generate_date_range(self.date_from, self.date_to, ["expenses"]) - - expenses = ( - select(Expense) - ) - - if tag_id != -1: - expenses = ( - expenses - .join(expenses_expense_tags) - .where(expenses_expense_tags.c.expense_tag_id == tag_id) - ) - - expenses = expenses.subquery() - - expenses = ( - select( - func.sum(expenses.c.amount).label("expenses"), - cast(expenses.c.spent_date, TIMESTAMP(timezone=False)).label("date"), - ) - .where(expenses.c.spent_date.between(self.date_from, self.date_to)) - .group_by("date") - .subquery() - ) - - expenses_with_filled_gaps = self._fill_date_gaps(expenses, all_dates) - return expenses_with_filled_gaps - @staticmethod - def _apply_expenses(deals_by_dates: Subquery, expenses_subquery: Subquery): + def _apply_payments(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"), + (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, filters: CommonProfitFilters, deals_by_dates: Subquery): + def apply_payments(self, filters: CommonProfitFilters, deals_by_dates: Subquery): self.date_from, self.date_to = filters.date_range - # Apply salary expenses salary_expenses = self._get_payment_records_sub() - deals_by_dates = self._apply_expenses(deals_by_dates, salary_expenses) - - # Apply additional expenses - additional_expenses = self._get_additional_expenses_sub(filters.tag_id) - deals_by_dates = self._apply_expenses(deals_by_dates, additional_expenses) + deals_by_dates = self._apply_payments(deals_by_dates, salary_expenses) return deals_by_dates diff --git a/services/statistics/profit_statistics.py b/services/statistics/profit_statistics.py index 69a5ef8..4ded474 100644 --- a/services/statistics/profit_statistics.py +++ b/services/statistics/profit_statistics.py @@ -10,7 +10,8 @@ from schemas.statistics import GetProfitChartDataResponse, GetProfitChartDataReq GetProfitTableDataResponse, GetProfitTableDataRequest, ProfitTableDataItem, CommonProfitFilters from services.base import BaseService from services.statistics.common import generate_date_range -from services.statistics.expenses_statistics import ExpensesStatisticsService +from services.statistics.expenses_statistics import PaymentStatisticsService +from services.statistics.transactions_statistics import TransactionsStatisticsService class ProfitStatisticsService(BaseService): @@ -302,13 +303,18 @@ class ProfitStatisticsService(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() - expenses_statistics_service = ExpensesStatisticsService(self.session) - stmt_deals_applied_expenses = expenses_statistics_service.apply_expenses( + expenses_statistics_service = PaymentStatisticsService(self.session) + stmt_deals_applied_expenses = expenses_statistics_service.apply_payments( self.filters, sub_deals_grouped_by_date ) + transactions_statistics_service = TransactionsStatisticsService(self.session) + stmt_deals_applied_transactions = transactions_statistics_service.apply_transactions( + self.filters, + stmt_deals_applied_expenses.subquery() + ) - result = await self.session.execute(stmt_deals_applied_expenses) + result = await self.session.execute(stmt_deals_applied_transactions) rows = result.all() return rows diff --git a/services/statistics/transactions_statistics.py b/services/statistics/transactions_statistics.py new file mode 100644 index 0000000..cfbd539 --- /dev/null +++ b/services/statistics/transactions_statistics.py @@ -0,0 +1,93 @@ +from datetime import date + +from sqlalchemy import select, func, Subquery, cast, CTE, case, or_, and_ +from sqlalchemy.dialects.postgresql import TIMESTAMP + +from models import Transaction, transactions_transaction_tags, TransactionTag +from schemas.statistics import CommonProfitFilters +from services.base import BaseService +from services.statistics.common import generate_date_range + + +class TransactionsStatisticsService(BaseService): + date_from: date + date_to: date + + @staticmethod + def _fill_date_gaps(expenses: Subquery, all_dates: CTE) -> Subquery: + return ( + select( + all_dates.c.date, + (all_dates.c.expenses + func.coalesce(expenses.c.expenses, 0)).label("expenses"), + (all_dates.c.revenue + func.coalesce(expenses.c.revenue, 0)).label("revenue"), + ) + .join(expenses, all_dates.c.date == expenses.c.date, isouter=True) + .order_by(all_dates.c.date) + .subquery() + ) + + def _get_additional_transactions_sub(self, income_tag_id: int, expense_tag_id: int) -> Subquery: + all_dates = generate_date_range(self.date_from, self.date_to, ["expenses", "revenue"]) + + filtered_tags_sub = ( + select(TransactionTag) + .where( + or_( + and_(TransactionTag.is_income == True, + or_(income_tag_id == -1, TransactionTag.id == income_tag_id)), + and_(TransactionTag.is_income == False, + or_(expense_tag_id == -1, TransactionTag.id == expense_tag_id)), + ) + ) + .subquery() + ) + + transaction_ids = ( + select(transactions_transaction_tags.c.transaction_id) + .join(filtered_tags_sub, filtered_tags_sub.c.id == transactions_transaction_tags.c.transaction_tag_id) + .group_by(transactions_transaction_tags.c.transaction_id) + .subquery() + ) + + transactions = ( + select(Transaction) + .join(transaction_ids, transaction_ids.c.transaction_id == Transaction.id) + .subquery() + ) + + transactions = ( + select( + func.sum(case((transactions.c.is_income, transactions.c.amount), else_=0)).label("revenue"), + func.sum(case((~transactions.c.is_income, transactions.c.amount), else_=0)).label("expenses"), + cast(transactions.c.spent_date, TIMESTAMP(timezone=False)).label("date"), + ) + .where(transactions.c.spent_date.between(self.date_from, self.date_to)) + .group_by("date") + .subquery() + ) + + expenses_with_filled_gaps = self._fill_date_gaps(transactions, all_dates) + return expenses_with_filled_gaps + + @staticmethod + def _apply_transactions(deals_by_dates: Subquery, transactions: Subquery): + return ( + select( + deals_by_dates.c.date, + deals_by_dates.c.deals_count, + (deals_by_dates.c.revenue + transactions.c.revenue).label("revenue"), + (func.coalesce(deals_by_dates.c.profit, 0) - func.coalesce(transactions.c.expenses, 0) + func.coalesce( + transactions.c.revenue, 0)) + .label("profit"), + (deals_by_dates.c.expenses + transactions.c.expenses).label("expenses"), + ) + .join(transactions, transactions.c.date == deals_by_dates.c.date) + ) + + def apply_transactions(self, filters: CommonProfitFilters, deals_by_dates: Subquery): + self.date_from, self.date_to = filters.date_range + + additional_expenses = self._get_additional_transactions_sub(filters.income_tag_id, filters.expense_tag_id) + deals_by_dates = self._apply_transactions(deals_by_dates, additional_expenses) + + return deals_by_dates diff --git a/services/transaction.py b/services/transaction.py new file mode 100644 index 0000000..08c88da --- /dev/null +++ b/services/transaction.py @@ -0,0 +1,186 @@ +from datetime import datetime + +import math +from fastapi import HTTPException +from fastapi import status +from sqlalchemy import delete, select, func, update, insert, and_ + +from models import User +from models.transaction import Transaction, TransactionTag +from schemas.base import PaginationSchema +from schemas.transaction import * +from services.base import BaseService +from utils.dependecies import is_valid_pagination + + +class TransactionsService(BaseService): + async def get_all(self, pagination: PaginationSchema, request: GetAllTransactionsRequest) -> GetAllTransactionsResponse: + if not is_valid_pagination(pagination): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid pagination') + page = max(0, pagination.page - 1) + is_income = request.is_income + + stmt = ( + select(Transaction) + .where(Transaction.is_income == is_income) + .order_by(Transaction.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(Transaction) + .where(Transaction.is_income == is_income) + ) + if not total_records: + return GetAllTransactionsResponse( + transactions=[], + pagination_info=PaginationInfoSchema( + total_pages=0, + total_items=0 + ) + ) + total_items = total_records + total_pages = math.ceil(total_records / pagination.items_per_page) + + transactions = await self.session.execute(stmt) + transactions = transactions.scalars().all() + response = GetAllTransactionsResponse( + transactions=transactions, + pagination_info=PaginationInfoSchema( + total_items=total_items, + total_pages=total_pages + ) + ) + return response + + async def get_by_id(self, transaction_id: int) -> Optional[Transaction]: + transaction = await self.session.get(Transaction, transaction_id) + return transaction + + async def add_tags(self, transaction: Transaction, tag_names: list[str]): + tags = [] + for tag_name in tag_names: + existing_tag = await self.get_tag_by_name(tag_name, transaction.is_income) + if existing_tag: + tags.append(existing_tag) + else: + tag = TransactionTag(name=tag_name, is_income=transaction.is_income) + self.session.add(tag) + tags.append(tag) + + transaction.tags = tags + + async def update_transaction(self, user: User, request: UpdateTransactionRequest) -> UpdateTransactionResponse: + transaction = await self.get_by_id(request.transaction.id or -1) + + if not transaction: + new_transaction = Transaction( + created_at=datetime.datetime.now(), + name=request.transaction.name, + comment=request.transaction.comment, + amount=request.transaction.amount, + spent_date=request.transaction.spent_date, + created_by_user_id=user.id, + is_income=request.transaction.is_income, + ) + self.session.add(new_transaction) + await self.add_tags(new_transaction, request.transaction.tags) + await self.session.commit() + return UpdateTransactionResponse(ok=True, message='Запись успешно создана') + + transaction.name = request.transaction.name + transaction.amount = request.transaction.amount + transaction.comment = request.transaction.comment + transaction.spent_date = request.transaction.spent_date + self.session.add(transaction) + await self.add_tags(transaction, request.transaction.tags) + await self.session.commit() + return UpdateTransactionResponse(ok=True, message='Запись успешно изменена') + + async def delete_transaction(self, expense_id) -> DeleteTransactionResponse: + stmt = ( + delete(Transaction) + .where(Transaction.id == expense_id) + ) + await self.session.execute(stmt) + await self.session.commit() + return DeleteTransactionResponse(ok=True, message='Запись успешно удалена') + + async def get_all_tags(self) -> GetAllTransactionTagsResponse: + stmt = ( + select(TransactionTag) + .order_by(TransactionTag.id) + ) + tags = await self.session.execute(stmt) + return GetAllTransactionTagsResponse(tags=tags.scalars().all()) + + async def get_tags(self, is_income: bool) -> GetTransactionTagsResponse: + stmt = ( + select(TransactionTag) + .where(TransactionTag.is_income == is_income) + .order_by(TransactionTag.id) + ) + tags = await self.session.execute(stmt) + return GetTransactionTagsResponse(tags=tags.scalars().all()) + + async def get_tag_by_id(self, tag_id: int) -> Optional[TransactionTag]: + return await self.session.get(TransactionTag, tag_id) + + async def get_tag_by_name(self, tag_name: str, is_income: bool) -> Optional[TransactionTag]: + stmt = ( + select(TransactionTag) + .where(and_(TransactionTag.name == tag_name, TransactionTag.is_income == is_income)) + ) + tag = await self.session.scalar(stmt) + return tag + + async def create_tag(self, request: CreateTransactionTagRequest) -> CreateTransactionTagResponse: + tag = await self.get_tag_by_name(request.tag.name, request.tag.is_income) + if tag: + return CreateTransactionTagResponse(ok=False, message='Такой тег уже есть.') + + tag_dict = request.tag.model_dump() + stmt = ( + insert(TransactionTag) + .values(**tag_dict) + ) + await self.session.execute(stmt) + await self.session.commit() + return CreateTransactionTagResponse(ok=True, message='Тег успешно создан.') + + async def update_tag(self, request: UpdateTransactionTagRequest) -> UpdateTransactionTagResponse: + tag = await self.get_tag_by_name(request.tag.name, request.tag.is_income) + if tag: + return UpdateTransactionTagResponse(ok=False, message='Тег с таким названием уже есть.') + + tag = await self.get_tag_by_id(request.tag.id) + if not tag: + return UpdateTransactionTagResponse(ok=False, message='Тег не найден.') + + tag_dict = request.tag.model_dump() + del tag_dict['id'] + stmt = ( + update(TransactionTag) + .where(TransactionTag.id == request.tag.id) + .values(**tag_dict) + ) + await self.session.execute(stmt) + await self.session.commit() + return UpdateTransactionTagResponse(ok=True, message='Тег успешно изменен.') + + async def delete_tag(self, tag_id: int) -> DeleteTransactionTagResponse: + tag = await self.get_tag_by_id(tag_id) + if not tag: + return DeleteTransactionTagResponse(ok=False, message='Тег не найден.') + if len(tag.transactions) > 0: + return DeleteTransactionTagResponse(ok=False, message='Тег прикреплен к записи о расходах/доходах.') + + stmt = ( + delete(TransactionTag) + .where(TransactionTag.id == tag_id) + ) + await self.session.execute(stmt) + await self.session.commit() + return DeleteTransactionTagResponse(ok=True, message='Тег удален') diff --git a/test.py b/test.py index d09eb7a..742c52c 100644 --- a/test.py +++ b/test.py @@ -20,7 +20,7 @@ async def main(): stmt = ( select( stmt.c.date, - stmt.c.expenses, + stmt.c.transactions, ) )