feat: income

This commit is contained in:
2024-12-12 20:23:19 +04:00
parent daa34b2808
commit e5712224e1
16 changed files with 574 additions and 463 deletions

View File

@@ -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:

View File

@@ -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()

View File

@@ -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',
)

54
models/transaction.py Normal file
View File

@@ -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',
)

View File

@@ -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

View File

@@ -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

118
routers/transaction.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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):

97
schemas/transaction.py Normal file
View File

@@ -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

View File

@@ -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='Тег удален')

View File

@@ -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

View File

@@ -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

View File

@@ -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

186
services/transaction.py Normal file
View File

@@ -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='Тег удален')

View File

@@ -20,7 +20,7 @@ async def main():
stmt = (
select(
stmt.c.date,
stmt.c.expenses,
stmt.c.transactions,
)
)