feat: additional expenses

This commit is contained in:
2024-11-26 01:36:59 +04:00
parent 81d5261af1
commit 48c88cb93a
15 changed files with 318 additions and 80 deletions

View File

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

View File

@@ -13,5 +13,6 @@ from .payroll import *
from .billing import *
from .marketplace_products import *
from .deal_group import *
from .expense import *
configure_mappers()

24
models/expense.py Normal file
View File

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

View File

@@ -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
from .statistics import statistics_router
from .expense import expense_router

49
routers/expense.py Normal file
View File

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

View File

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

View File

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

52
schemas/expense.py Normal file
View File

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

85
services/expenses.py Normal file
View File

@@ -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='Запись о расходах успешно удалена')

View File

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

View File

@@ -0,0 +1 @@
from .profit_statistics import ProfitStatisticsService

View File

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

View File

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

View File

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

View File

@@ -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"])