From 9ee3f87de98aafa285ab2c6e01d7c0efa246984c Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Fri, 7 Feb 2025 20:08:14 +0400 Subject: [PATCH] feat: projects and boards --- enums/profit_table_group_by.py | 10 +- main.py | 4 + models/__init__.py | 4 + models/board.py | 31 +++ models/deal.py | 48 ++--- models/project.py | 24 +++ models/status.py | 61 ++++++ routers/__init__.py | 4 + routers/board.py | 79 ++++++++ routers/deal.py | 84 +------- routers/group.py | 79 ++++++++ routers/project.py | 65 ++++++ routers/status.py | 67 +++++++ schemas/board.py | 61 ++++++ schemas/deal.py | 84 ++------ schemas/group.py | 66 ++++++ schemas/project.py | 50 +++++ schemas/statistics.py | 3 + schemas/status.py | 65 ++++++ services/board.py | 119 +++++++++++ services/deal.py | 244 ++++++----------------- services/deal_group.py | 138 ++++++++++++- services/project.py | 73 +++++++ services/statistics/profit_statistics.py | 108 ++++++++-- services/status.py | 128 ++++++++++++ 25 files changed, 1312 insertions(+), 387 deletions(-) create mode 100644 models/board.py create mode 100644 models/project.py create mode 100644 models/status.py create mode 100644 routers/board.py create mode 100644 routers/group.py create mode 100644 routers/project.py create mode 100644 routers/status.py create mode 100644 schemas/board.py create mode 100644 schemas/group.py create mode 100644 schemas/project.py create mode 100644 schemas/status.py create mode 100644 services/board.py create mode 100644 services/project.py create mode 100644 services/status.py diff --git a/enums/profit_table_group_by.py b/enums/profit_table_group_by.py index 6a111c7..a545941 100644 --- a/enums/profit_table_group_by.py +++ b/enums/profit_table_group_by.py @@ -4,7 +4,9 @@ from enum import IntEnum class ProfitTableGroupBy(IntEnum): BY_DATES = 0 BY_CLIENTS = 1 - BY_STATUSES = 2 - BY_WAREHOUSES = 3 - BY_MARKETPLACES = 4 - BY_MANAGERS = 5 + BY_PROJECTS = 2 + BY_BOARDS = 3 + BY_STATUSES = 4 + BY_WAREHOUSES = 5 + BY_MARKETPLACES = 6 + BY_MANAGERS = 7 diff --git a/main.py b/main.py index 5003f71..162551f 100644 --- a/main.py +++ b/main.py @@ -32,6 +32,7 @@ app.add_middleware( routers_list = [ routers.auth_router, routers.deal_router, + routers.deal_group_router, routers.client_router, routers.service_router, routers.product_router, @@ -52,6 +53,9 @@ routers_list = [ routers.shipping_router, routers.department_router, routers.residues_router, + routers.project_router, + routers.board_router, + routers.status_router, ] for router in routers_list: app.include_router(router) diff --git a/models/__init__.py b/models/__init__.py index a5ab0dc..4003892 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,6 +1,9 @@ from sqlalchemy.orm import configure_mappers from .auth import * +from .project import * +from .board import * +from .status import * from .deal import * from .client import * from .service import * @@ -15,5 +18,6 @@ from .marketplace_products import * from .deal_group import * from .transaction import * from .residues import * +from .shipping import * configure_mappers() diff --git a/models/board.py b/models/board.py new file mode 100644 index 0000000..f6e3ec9 --- /dev/null +++ b/models/board.py @@ -0,0 +1,31 @@ +from datetime import datetime +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 Project, DealStatus, Deal + + +class Board(BaseModel): + __tablename__ = "boards" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(nullable=False) + created_at: Mapped[datetime] = mapped_column(nullable=False) + is_deleted: Mapped[bool] = mapped_column(default=False) + ordinal_number: Mapped[int] = mapped_column(nullable=False) + + project_id: Mapped[int] = mapped_column(ForeignKey('projects.id'), nullable=False) + project: Mapped["Project"] = relationship( + "Project", + back_populates="boards", + lazy="selectin", + ) + + deal_statuses: Mapped[list["DealStatus"]] = relationship("DealStatus", back_populates="board", lazy="selectin", cascade="all,delete") + + deals: Mapped[list["Deal"]] = relationship("Deal", uselist=True, back_populates="board", lazy="selectin") diff --git a/models/deal.py b/models/deal.py index 572e809..cf2b241 100644 --- a/models/deal.py +++ b/models/deal.py @@ -7,6 +7,8 @@ from sqlalchemy.orm import relationship, backref, Mapped, mapped_column from models.base import BaseModel from .marketplace import BaseMarketplace +from .board import Board +from .status import DealStatus from .shipping import Pallet, Box from .shipping_warehouse import ShippingWarehouse @@ -19,18 +21,8 @@ if TYPE_CHECKING: ) -# @unique -# class DealStatus(IntEnum): -# CREATED = 0 -# AWAITING_ACCEPTANCE = 1 -# PACKAGING = 2 -# AWAITING_SHIPMENT = 3 -# AWAITING_PAYMENT = 4 -# COMPLETED = 5 -# CANCELLED = 6 - @unique -class DealStatus(IntEnum): +class DealStatusEnum(IntEnum): CREATED = 0 AWAITING_ACCEPTANCE = 1 READY_FOR_WORK = 2 @@ -53,7 +45,6 @@ class Deal(BaseModel): id = Column(Integer, autoincrement=True, primary_key=True, index=True) name = Column(String, nullable=False, comment='Название сделки') created_at = Column(DateTime, nullable=False, comment='Дата создания') - current_status = Column(Integer, nullable=False, comment='Текущий статус') client_id = Column(Integer, ForeignKey('clients.id', ondelete='CASCADE'), nullable=False, comment='ID клиента') client = relationship('Client', backref=backref('deals', cascade="all, delete-orphan")) @@ -65,6 +56,14 @@ class Deal(BaseModel): is_locked: Mapped[bool] = mapped_column(default=False, server_default='0') is_accounted: Mapped[bool] = mapped_column(default=True, server_default='1') + current_status_id: Mapped[int] = mapped_column( + ForeignKey("deal_statuses.id"), + nullable=False, + comment='Текущий статус', + ) + status: Mapped["DealStatus"] = relationship(lazy="selectin") + + shipping_warehouse_id: Mapped[int] = mapped_column(ForeignKey('shipping_warehouses.id'), nullable=True) shipping_warehouse: Mapped["ShippingWarehouse"] = relationship() @@ -88,6 +87,12 @@ class Deal(BaseModel): order_by="desc(DealProduct.product_id)" ) + board_id: Mapped[int] = mapped_column(ForeignKey('boards.id'), nullable=True, server_default='1') + board: Mapped[Board] = relationship( + "Board", + back_populates="deals", + ) + # TODO remake with sequence lexorank = Column(String, nullable=False, comment='Lexorank', index=True) @@ -112,25 +117,6 @@ class Deal(BaseModel): employees: Mapped[list['DealEmployees']] = relationship(back_populates='deal', lazy='selectin') -class DealStatusHistory(BaseModel): - __tablename__ = 'deals_status_history' - id = Column(Integer, autoincrement=True, primary_key=True, index=True) - - deal_id = Column(Integer, ForeignKey('deals.id'), nullable=False, comment='ID сделки') - deal = relationship('Deal', back_populates='status_history') - - user_id = Column(Integer, ForeignKey('users.id'), nullable=False) - user = relationship('User') - - changed_at = Column(DateTime, nullable=False, comment='Дата и время когда произошла смена статуса') - from_status = Column(Integer, nullable=False, comment='Предыдущий статус') - to_status = Column(Integer, nullable=False, comment='Новый статус') - - next_status_deadline = Column(DateTime, - comment='Дедлайн до которого сделку нужно перевести на следующий этап') - comment = Column(String, nullable=False, comment='Коментарий', server_default='') - - class DealEmployees(BaseModel): __tablename__ = 'deal_employees' user_id: Mapped[int] = mapped_column(ForeignKey('users.id'), primary_key=True) diff --git a/models/project.py b/models/project.py new file mode 100644 index 0000000..f56d713 --- /dev/null +++ b/models/project.py @@ -0,0 +1,24 @@ +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy.orm import mapped_column, Mapped, relationship + +from models import BaseModel + +if TYPE_CHECKING: + from board import Board + + +class Project(BaseModel): + __tablename__ = "projects" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(nullable=False) + created_at: Mapped[datetime] = mapped_column(nullable=False) + is_deleted: Mapped[bool] = mapped_column(default=False) + + boards: Mapped[list["Board"]] = relationship( + "Board", + back_populates="project", + lazy="noload", + ) diff --git a/models/status.py b/models/status.py new file mode 100644 index 0000000..e34cbdd --- /dev/null +++ b/models/status.py @@ -0,0 +1,61 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, Column, Integer, DateTime, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from models import BaseModel + +if TYPE_CHECKING: + from models import Board + + +class DealStatus(BaseModel): + __tablename__ = "deal_statuses" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(nullable=False) + ordinal_number: Mapped[int] = mapped_column(nullable=False) + is_finishing: Mapped[bool] = mapped_column(default=False, nullable=False) + is_deleted: Mapped[bool] = mapped_column(default=False, nullable=False) + + board_id: Mapped[int] = mapped_column(ForeignKey('boards.id'), nullable=False) + board: Mapped["Board"] = relationship("Board", back_populates="deal_statuses") + + +class DealStatusHistory(BaseModel): + __tablename__ = 'deals_status_history' + id = Column(Integer, autoincrement=True, primary_key=True, index=True) + + deal_id = Column(Integer, ForeignKey('deals.id'), nullable=False, comment='ID сделки') + deal = relationship('Deal', back_populates='status_history') + + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + user = relationship('User') + + changed_at = Column(DateTime, nullable=False, comment='Дата и время когда произошла смена статуса') + + from_status_id: Mapped[int] = mapped_column( + ForeignKey('deal_statuses.id'), + nullable=False, + comment='Предыдущий статус', + ) + from_status: Mapped[DealStatus] = relationship( + 'DealStatus', + foreign_keys=[from_status_id], + lazy='joined', + ) + + to_status_id: Mapped[int] = mapped_column( + ForeignKey('deal_statuses.id'), + nullable=False, + comment='Новый статус', + ) + to_status: Mapped[DealStatus] = relationship( + 'DealStatus', + foreign_keys=[to_status_id], + lazy='joined', + ) + + next_status_deadline = Column(DateTime, + comment='Дедлайн до которого сделку нужно перевести на следующий этап') + comment = Column(String, nullable=False, comment='Коментарий', server_default='') diff --git a/routers/__init__.py b/routers/__init__.py index 5f2cd19..3fa94a5 100644 --- a/routers/__init__.py +++ b/routers/__init__.py @@ -1,5 +1,6 @@ from .auth import auth_router from .deal import deal_router +from .group import deal_group_router from .client import client_router from .service import service_router from .product import product_router @@ -20,3 +21,6 @@ from .transaction import transaction_router from .shipping import shipping_router from .department import department_router from .residues import residues_router +from .project import project_router +from .board import board_router +from .status import status_router diff --git a/routers/board.py b/routers/board.py new file mode 100644 index 0000000..eb85b65 --- /dev/null +++ b/routers/board.py @@ -0,0 +1,79 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.session import get_session +from schemas.board import * +from services.auth import guest_user, authorized_user +from services.board import BoardService + +board_router = APIRouter( + prefix="/board", + tags=["board"], + dependencies=[Depends(guest_user)] +) + + +@board_router.get( + "/{project_id}", + response_model=GetBoardsResponse, + operation_id="get_boards", +) +async def get_boards( + session: Annotated[AsyncSession, Depends(get_session)], + project_id: int, +): + return await BoardService(session).get_boards(project_id) + + +@board_router.post( + '/', + response_model=CreateBoardResponse, + operation_id="create_board", + dependencies=[Depends(authorized_user)] +) +async def create_board( + session: Annotated[AsyncSession, Depends(get_session)], + request: CreateBoardRequest, +): + return await BoardService(session).create_board(request) + + +@board_router.patch( + '/', + response_model=UpdateBoardResponse, + operation_id="update_board", + dependencies=[Depends(authorized_user)] +) +async def update_board( + session: Annotated[AsyncSession, Depends(get_session)], + request: UpdateBoardRequest, +): + return await BoardService(session).update_board(request) + + +@board_router.patch( + '/order', + response_model=UpdateBoardOrderResponse, + operation_id="update_board_order", + dependencies=[Depends(authorized_user)] +) +async def update_board_order( + session: Annotated[AsyncSession, Depends(get_session)], + request: UpdateBoardOrderRequest, +): + return await BoardService(session).update_board_order(request) + + +@board_router.delete( + '/{board_id}', + response_model=DeleteBoardResponse, + operation_id="delete_board", + dependencies=[Depends(authorized_user)] +) +async def delete_board( + session: Annotated[AsyncSession, Depends(get_session)], + board_id: int, +): + return await BoardService(session).delete_board(board_id) diff --git a/routers/deal.py b/routers/deal.py index 6314080..5fc7569 100644 --- a/routers/deal.py +++ b/routers/deal.py @@ -24,17 +24,6 @@ deal_router = APIRouter( # region Deal -@deal_router.post( - '/create', - dependencies=[Depends(authorized_user)] -) -async def create( - request: DealCreateRequest, - session: Annotated[AsyncSession, Depends(get_session)], - user: Annotated[User, Depends(get_current_user)] -): - return await DealService(session).create(request, user) - @deal_router.post( '/delete', @@ -151,9 +140,10 @@ async def get_deal_by_id( ) async def update_general_info( request: DealUpdateGeneralInfoRequest, - session: Annotated[AsyncSession, Depends(get_session)] + session: Annotated[AsyncSession, Depends(get_session)], + user: CurrentUserDependency, ): - return await DealService(session).update_general_info(request) + return await DealService(session).update_general_info(request, user) @deal_router.post( @@ -494,71 +484,3 @@ async def get_deal_products_barcodes_pdf( # endregion - -# region Deal groups -@deal_router.post( - '/add-to-group', - response_model=DealAddToGroupResponse, - operation_id='add_deal_to_group', - dependencies=[Depends(authorized_user)] -) -async def add_to_group( - request: DealAddToGroupRequest, - session: SessionDependency, - user: CurrentUserDependency -): - return await DealService(session).add_to_group(user, request) - -@deal_router.post( - '/create-group', - response_model=DealCreateGroupResponse, - operation_id='create_deal_group', - dependencies=[Depends(authorized_user)] -) -async def create_group( - request: DealCreateGroupRequest, - session: SessionDependency, - user: CurrentUserDependency -): - return await DealService(session).create_group(user, request) - - -@deal_router.post( - '/remove-from-group', - response_model=DealRemoveFromGroupResponse, - operation_id='remove_deal_from_group', - dependencies=[Depends(authorized_user)] -) -async def remove_from_group( - request: DealRemoveFromGroupRequest, - session: SessionDependency, -): - return await DealService(session).remove_from_group( request) - -# route to update group name -@deal_router.post( - '/group/update', - response_model=DealGroupUpdateResponse, - operation_id='update_deal_group', - dependencies=[Depends(authorized_user)] -) -async def update_group( - request: DealGroupUpdateRequest, - session: SessionDependency, -): - return await DealService(session).update_group(request) - -# route to change group status -@deal_router.post( - '/group/change-status', - response_model=DealGroupChangeStatusResponse, - operation_id='change_deal_group_status', - dependencies=[Depends(authorized_user)] -) -async def change_group_status( - request: DealGroupChangeStatusRequest, - session: SessionDependency, - user: CurrentUserDependency -): - return await DealService(session).change_group_status(user,request) -# endregion diff --git a/routers/group.py b/routers/group.py new file mode 100644 index 0000000..01ca1aa --- /dev/null +++ b/routers/group.py @@ -0,0 +1,79 @@ +from fastapi import APIRouter, Depends + +from backend.dependecies import SessionDependency, CurrentUserDependency +from schemas.group import * +from services.auth import authorized_user +from services.deal_group import DealGroupService + +deal_group_router = APIRouter( + prefix='/deal-group', + tags=['deal-group'], +) + + +@deal_group_router.patch( + '/', + response_model=DealGroupUpdateResponse, + operation_id='update_deal_group', + dependencies=[Depends(authorized_user)] +) +async def update_group( + request: DealGroupUpdateRequest, + session: SessionDependency, +): + return await DealGroupService(session).update_group(request) + + +@deal_group_router.post( + '/', + response_model=DealCreateGroupResponse, + operation_id='create_deal_group', + dependencies=[Depends(authorized_user)] +) +async def create_group( + request: DealCreateGroupRequest, + session: SessionDependency, + user: CurrentUserDependency +): + return await DealGroupService(session).create_group(user, request) + + +@deal_group_router.patch( + '/change-status', + response_model=DealGroupChangeStatusResponse, + operation_id='change_status', + dependencies=[Depends(authorized_user)] +) +async def change_status( + request: DealGroupChangeStatusRequest, + session: SessionDependency, + user: CurrentUserDependency +): + return await DealGroupService(session).change_group_status(user,request) + + +@deal_group_router.post( + '/deal', + response_model=DealAddToGroupResponse, + operation_id='add_deal', + dependencies=[Depends(authorized_user)] +) +async def add_deal( + request: DealAddToGroupRequest, + session: SessionDependency, + user: CurrentUserDependency +): + return await DealGroupService(session).add_deal(user, request) + + +@deal_group_router.delete( + '/deal', + response_model=DealRemoveFromGroupResponse, + operation_id='remove_deal', + dependencies=[Depends(authorized_user)] +) +async def remove_deal( + request: DealRemoveFromGroupRequest, + session: SessionDependency, +): + return await DealGroupService(session).remove_deal(request) diff --git a/routers/project.py b/routers/project.py new file mode 100644 index 0000000..4935e54 --- /dev/null +++ b/routers/project.py @@ -0,0 +1,65 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.session import get_session +from schemas.project import * +from services.auth import guest_user, authorized_user +from services.project import ProjectService + +project_router = APIRouter( + prefix="/project", + tags=["project"], + dependencies=[Depends(guest_user)] +) + + +@project_router.get( + "/", + response_model=GetProjectsResponse, + operation_id="get_projects", +) +async def get_projects( + session: Annotated[AsyncSession, Depends(get_session)], +): + return await ProjectService(session).get_projects() + + +@project_router.post( + '/', + response_model=CreateProjectResponse, + operation_id="create_project", + dependencies=[Depends(authorized_user)] +) +async def create_project( + session: Annotated[AsyncSession, Depends(get_session)], + request: CreateProjectRequest, +): + return await ProjectService(session).create_project(request) + + +@project_router.patch( + '/', + response_model=UpdateProjectResponse, + operation_id="update_project", + dependencies=[Depends(authorized_user)] +) +async def update_project( + session: Annotated[AsyncSession, Depends(get_session)], + request: UpdateProjectRequest, +): + return await ProjectService(session).update_project(request) + + +@project_router.delete( + '/{project_id}', + response_model=DeleteProjectResponse, + operation_id="delete_project", + dependencies=[Depends(authorized_user)] +) +async def delete_project( + session: Annotated[AsyncSession, Depends(get_session)], + project_id: int, +): + return await ProjectService(session).delete_project(project_id) diff --git a/routers/status.py b/routers/status.py new file mode 100644 index 0000000..ee74e4f --- /dev/null +++ b/routers/status.py @@ -0,0 +1,67 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.session import get_session +from schemas.status import * +from services.auth import guest_user, authorized_user +from services.status import StatusService + +status_router = APIRouter( + prefix="/status", + tags=["status"], + dependencies=[Depends(guest_user)] +) + + +@status_router.post( + '/', + response_model=CreateStatusResponse, + operation_id="create_status", + dependencies=[Depends(authorized_user)] +) +async def create_status( + session: Annotated[AsyncSession, Depends(get_session)], + request: CreateStatusRequest, +): + return await StatusService(session).create_status(request) + + +@status_router.patch( + '/', + response_model=UpdateStatusResponse, + operation_id="update_status", + dependencies=[Depends(authorized_user)] +) +async def update_status( + session: Annotated[AsyncSession, Depends(get_session)], + request: UpdateStatusRequest, +): + return await StatusService(session).update_status(request) + + +@status_router.patch( + '/order', + response_model=UpdateStatusOrderResponse, + operation_id="update_status_order", + dependencies=[Depends(authorized_user)] +) +async def update_status_order( + session: Annotated[AsyncSession, Depends(get_session)], + request: UpdateStatusOrderRequest, +): + return await StatusService(session).update_status_order(request) + + +@status_router.delete( + '/{status_id}', + response_model=DeleteStatusResponse, + operation_id="delete_status", + dependencies=[Depends(authorized_user)] +) +async def delete_status( + session: Annotated[AsyncSession, Depends(get_session)], + status_id: int, +): + return await StatusService(session).delete_status(status_id) diff --git a/schemas/board.py b/schemas/board.py new file mode 100644 index 0000000..9295e12 --- /dev/null +++ b/schemas/board.py @@ -0,0 +1,61 @@ +from schemas.base import BaseSchema, OkMessageSchema +from schemas.project import ProjectSchema +from schemas.status import StatusSchema + + +# region Entities + +class BaseBoardSchema(BaseSchema): + name: str + project_id: int + + +class BoardSchema(BaseBoardSchema): + id: int + ordinal_number: int + deal_statuses: list[StatusSchema] + project: ProjectSchema + + +# endregion + +# region Requests + +class CreateBoardRequest(BaseSchema): + board: BaseBoardSchema + + +class UpdateBoardRequest(BaseSchema): + board: BoardSchema + + +class UpdateBoardOrderRequest(BaseSchema): + project_id: int + board_id: int + new_ordinal_number: int + + +# endregion + +# region Responses + +class GetBoardsResponse(BaseSchema): + boards: list[BoardSchema] + + +class CreateBoardResponse(OkMessageSchema): + pass + + +class UpdateBoardResponse(OkMessageSchema): + pass + + +class UpdateBoardOrderResponse(OkMessageSchema): + pass + + +class DeleteBoardResponse(OkMessageSchema): + pass + +# endregion diff --git a/schemas/deal.py b/schemas/deal.py index 07a106d..d5f79f6 100644 --- a/schemas/deal.py +++ b/schemas/deal.py @@ -1,18 +1,19 @@ import datetime -from collections import defaultdict -from typing import List, Optional, Union, Dict, Tuple, TypedDict +from typing import List, Optional, Union -from pydantic import constr, field_validator +from pydantic import constr -from models import ServiceCategoryPrice, ServicePriceCategory, Deal, Product, DealProduct, DealStatusHistory from schemas.base import BaseSchema, OkMessageSchema -from schemas.billing import DealBillRequestSchema,GroupBillRequestSchema +from schemas.billing import DealBillRequestSchema +from schemas.board import BoardSchema from schemas.client import ClientSchema +from schemas.group import DealGroupSchema from schemas.marketplace import BaseMarketplaceSchema from schemas.product import ProductSchema from schemas.service import ServiceSchema, ServicePriceCategorySchema from schemas.shipping import PalletSchema, BoxSchema from schemas.shipping_warehouse import ShippingWarehouseSchema, BaseShippingWarehouseSchema +from schemas.status import StatusSchema, DealStatusHistorySchema from schemas.user import UserSchema @@ -24,21 +25,13 @@ class FastDeal(BaseSchema): acceptance_date: datetime.datetime -class DealGroupSchema(BaseSchema): - id: int - name: Optional[str] = None - lexorank: str - bill_request: Optional[GroupBillRequestSchema] = None - - class DealSummary(BaseSchema): id: int name: str client_name: str - changed_at: datetime.datetime created_at: datetime.datetime - deadline: Optional[datetime.datetime] = None - status: int + status: StatusSchema + board: BoardSchema total_price: int rank: int base_marketplace: Optional[BaseMarketplaceSchema] = None @@ -75,15 +68,6 @@ class DealProductSchema(BaseSchema): comment: str = "" -class DealStatusHistorySchema(BaseSchema): - user: UserSchema - changed_at: datetime.datetime - from_status: int - to_status: int - next_status_deadline: datetime.datetime | None - comment: str | None = None - - class DealEmployeesSchema(BaseSchema): user: UserSchema created_at: datetime.datetime @@ -94,7 +78,8 @@ class DealSchema(BaseSchema): name: str client_id: int created_at: datetime.datetime - current_status: int + status: StatusSchema + board: BoardSchema services: List[DealServiceSchema] products: List[DealProductSchema] status_history: List[DealStatusHistorySchema] @@ -127,6 +112,8 @@ class DealGeneralInfoSchema(BaseSchema): delivery_date: Optional[datetime.datetime] = None receiving_slot_date: Optional[datetime.datetime] = None manager: Optional[UserSchema] = None + board: BoardSchema + status: StatusSchema class OptionalShippingWarehouseSchema(BaseShippingWarehouseSchema): @@ -166,6 +153,7 @@ class DealChangeStatusRequest(BaseSchema): class DealCreateRequest(BaseSchema): name: str + status_id: int class DealQuickCreateRequest(BaseSchema): @@ -176,6 +164,7 @@ class DealQuickCreateRequest(BaseSchema): shipping_warehouse: constr(strip_whitespace=True) base_marketplace: BaseMarketplaceSchema category: Optional[ServicePriceCategorySchema] = None + status_id: int class DealSummaryRequest(BaseSchema): @@ -243,7 +232,7 @@ class DealUpdateGeneralInfoRequest(BaseSchema): class DealSummaryReorderRequest(BaseSchema): deal_id: int - status: int + status_id: int index: int deadline: datetime.datetime | None = None comment: str | None = None @@ -300,32 +289,10 @@ class ManageEmployeeRequest(BaseSchema): class CreateDealsFromExcelRequest(BaseSchema): client_id: int + status_id: int products: list[ProductFromExcelSchema] -class DealAddToGroupRequest(BaseSchema): - deal_id: int - group_id: int - - -class DealCreateGroupRequest(BaseSchema): - dragging_deal_id: int - hovered_deal_id: int - - -class DealRemoveFromGroupRequest(BaseSchema): - deal_id: int - - -class DealGroupUpdateRequest(BaseSchema): - data: DealGroupSchema - - -class DealGroupChangeStatusRequest(BaseSchema): - group_id: int - new_status: int - - # endregion Requests # region Responses @@ -452,23 +419,4 @@ class CreateDealsFromExcelResponse(OkMessageSchema): pass -class DealAddToGroupResponse(OkMessageSchema): - pass - - -class DealCreateGroupResponse(OkMessageSchema): - pass - - -class DealRemoveFromGroupResponse(OkMessageSchema): - pass - - -class DealGroupUpdateResponse(OkMessageSchema): - pass - - -class DealGroupChangeStatusResponse(OkMessageSchema): - pass - # endregion Responses diff --git a/schemas/group.py b/schemas/group.py new file mode 100644 index 0000000..b2102e3 --- /dev/null +++ b/schemas/group.py @@ -0,0 +1,66 @@ +from typing import Optional + +from schemas.base import BaseSchema, OkMessageSchema +from schemas.billing import GroupBillRequestSchema + + +# region Entities + +class DealGroupSchema(BaseSchema): + id: int + name: Optional[str] = None + lexorank: str + bill_request: Optional[GroupBillRequestSchema] = None + + +# endregion + +# region Requests + +class DealGroupUpdateRequest(BaseSchema): + data: DealGroupSchema + + +class DealCreateGroupRequest(BaseSchema): + dragging_deal_id: int + hovered_deal_id: int + + +class DealGroupChangeStatusRequest(BaseSchema): + group_id: int + new_status: int + + +class DealAddToGroupRequest(BaseSchema): + deal_id: int + group_id: int + + +class DealRemoveFromGroupRequest(BaseSchema): + deal_id: int + + +# endregion + +# region Responses + +class DealCreateGroupResponse(OkMessageSchema): + pass + + +class DealGroupUpdateResponse(OkMessageSchema): + pass + + +class DealGroupChangeStatusResponse(OkMessageSchema): + pass + + +class DealAddToGroupResponse(OkMessageSchema): + pass + + +class DealRemoveFromGroupResponse(OkMessageSchema): + pass + +# endregion diff --git a/schemas/project.py b/schemas/project.py new file mode 100644 index 0000000..c6c5199 --- /dev/null +++ b/schemas/project.py @@ -0,0 +1,50 @@ +from schemas.base import BaseSchema, OkMessageSchema + + +# region Entities + + +class BaseProjectSchema(BaseSchema): + name: str + + +class ProjectSchema(BaseProjectSchema): + id: int + + +class ProjectSchemaWithCount(ProjectSchema): + boards_count: int + + +# endregion + +# region Requests + +class CreateProjectRequest(BaseSchema): + project: BaseProjectSchema + + +class UpdateProjectRequest(BaseSchema): + project: ProjectSchema + +# endregion + + +# region Responses + +class GetProjectsResponse(BaseSchema): + projects: list[ProjectSchemaWithCount] + + +class CreateProjectResponse(OkMessageSchema): + pass + + +class UpdateProjectResponse(OkMessageSchema): + pass + + +class DeleteProjectResponse(OkMessageSchema): + pass + +# endregion diff --git a/schemas/statistics.py b/schemas/statistics.py index 316530b..ff4587f 100644 --- a/schemas/statistics.py +++ b/schemas/statistics.py @@ -29,10 +29,13 @@ class CommonProfitFilters(BaseSchema): date_range: Tuple[datetime.date, datetime.date] client_id: int base_marketplace_key: str + project_id: int + board_id: int deal_status_id: int manager_id: int expense_tag_id: int income_tag_id: int + is_completed_only: bool class GetProfitChartDataRequest(CommonProfitFilters): diff --git a/schemas/status.py b/schemas/status.py new file mode 100644 index 0000000..3369e7c --- /dev/null +++ b/schemas/status.py @@ -0,0 +1,65 @@ +from datetime import datetime + +from schemas.base import BaseSchema, OkMessageSchema +from schemas.user import UserSchema + + +# region Entities + +class BaseStatusSchema(BaseSchema): + name: str + board_id: int + + +class StatusSchema(BaseStatusSchema): + id: int + ordinal_number: int + is_deleted: bool = False + + +class DealStatusHistorySchema(BaseSchema): + user: UserSchema + changed_at: datetime + from_status: StatusSchema + to_status: StatusSchema + next_status_deadline: datetime | None + comment: str | None = None + +# endregion + +# region Requests + +class CreateStatusRequest(BaseSchema): + status: BaseStatusSchema + + +class UpdateStatusRequest(BaseSchema): + status: StatusSchema + + +class UpdateStatusOrderRequest(BaseSchema): + board_id: int + status_id: int + new_ordinal_number: int + +# endregion + + +# region Responses + +class CreateStatusResponse(OkMessageSchema): + pass + + +class UpdateStatusResponse(OkMessageSchema): + pass + + +class UpdateStatusOrderResponse(OkMessageSchema): + pass + + +class DeleteStatusResponse(OkMessageSchema): + pass + +# endregion diff --git a/services/board.py b/services/board.py new file mode 100644 index 0000000..26aad60 --- /dev/null +++ b/services/board.py @@ -0,0 +1,119 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import select, and_, func + +from models import Board, Deal +from schemas.board import * +from services.base import BaseService + + +class BoardService(BaseService): + async def _get_boards_for_project(self, project_id: int) -> list[Board]: + stmt = ( + select(Board) + .where( + and_( + Board.is_deleted == False, + Board.project_id == project_id, + ) + ) + .order_by(Board.ordinal_number) + ) + boards = (await self.session.scalars(stmt)).all() + return list(boards) + + async def get_boards(self, project_id: int) -> GetBoardsResponse: + boards = await self._get_boards_for_project(project_id) + return GetBoardsResponse(boards=boards) + + async def create_board(self, request: CreateBoardRequest) -> CreateBoardResponse: + boards = await self._get_boards_for_project(request.board.project_id) + if len(boards) == 0: + ordinal_number = 1 + else: + ordinal_number = boards[-1].ordinal_number + 1 + + board = Board( + **request.board.model_dump(), + created_at=datetime.now(), + ordinal_number=ordinal_number, + ) + self.session.add(board) + await self.session.commit() + + return CreateBoardResponse(ok=True, message="Доска успешно создана") + + async def _get_board_by_id(self, board_id: int) -> Optional[Board]: + return await self.session.get(Board, board_id) + + async def update_board(self, request: UpdateBoardRequest) -> UpdateBoardResponse: + board = await self._get_board_by_id(request.board.id) + if not board: + return UpdateBoardResponse(ok=False, message=f"Доска с ID {request.board.id} не найдена") + + board.name = request.board.name + await self.session.commit() + return UpdateBoardResponse(ok=True, message="Доска успешно обновлена") + + async def update_board_order(self, request: UpdateBoardOrderRequest) -> UpdateBoardOrderResponse: + boards = await self._get_boards_for_project(request.project_id) + board_idx = 0 + while board_idx < len(boards) and boards[board_idx].id != request.board_id: + board_idx += 1 + if board_idx == len(boards): + return UpdateBoardOrderResponse(ok=False, message=f"Доска с ID {request.board_id} не найдена в проекте") + + board = boards.pop(board_idx) + boards.insert(request.new_ordinal_number - 1, board) + new_ordinal_number = 1 + for board in boards: + board.ordinal_number = new_ordinal_number + new_ordinal_number += 1 + + await self.session.commit() + return UpdateBoardOrderResponse(ok=True, message="Порядок досок изменен") + + async def _count_deals_in_progress(self, board_id: int) -> int: + stmt = ( + select(func.count(Deal.id)) + .where( + and_( + Deal.board_id == board_id, + Deal.is_deleted == False, + Deal.is_completed == False, + ) + ) + ) + return (await self.session.scalars(stmt)).first() + + async def _count_deals(self, board_id: int) -> int: + stmt = ( + select(func.count(Deal.id)) + .where(Deal.board_id == board_id) + ) + return (await self.session.scalars(stmt)).first() + + async def delete_board(self, board_id: int) -> DeleteBoardResponse: + board = await self._get_board_by_id(board_id) + if not board: + return DeleteBoardResponse(ok=False, message=f"Доска с ID {board_id} не найдена") + + count_deals_in_progress = await self._count_deals_in_progress(board_id) + if count_deals_in_progress != 0: + return DeleteBoardResponse( + ok=False, + message=f"Нельзя удалить доску с активными сделками", + ) + + count_deals = await self._count_deals(board_id) + if count_deals == 0: + await self.session.delete(board) + else: + board.is_deleted = True + for status in board.deal_statuses: + status.is_deleted = True + + await self.session.commit() + + return DeleteBoardResponse(ok=True, message="Доска успешно удалена") diff --git a/services/deal.py b/services/deal.py index 8e4a418..ff63da9 100644 --- a/services/deal.py +++ b/services/deal.py @@ -1,13 +1,14 @@ +from collections import defaultdict + import lexorank -from attr import dataclass from fastapi import HTTPException from sqlalchemy import select, func, update, delete, insert, and_ from sqlalchemy.orm import joinedload, selectinload from starlette import status +from models import User, Service, Client, DealProductService, deal_relations, DealStatusHistory, Product, DealProduct import models.deal import models.secondary -from models import User, Service, Client, DealProductService, deal_relations, GroupBillRequest from models.deal import * from models.deal_group import DealGroup from models.shipping import ShippingProduct @@ -16,9 +17,9 @@ from schemas.deal import * from services.auth import AuthService from services.base import BaseService from services.client import ClientService -from services.deal_group import DealGroupService from services.service import ServiceService from services.shipping_warehouse import ShippingWarehouseService +from services import deal_group class DealService(BaseService): @@ -36,9 +37,13 @@ class DealService(BaseService): async def _get_deal_by_id(self, deal_id) -> Union[Deal, None]: return await self.session.get(Deal, deal_id) - async def _get_rank_for_deal(self, deal_status: DealStatus) -> str: + async def _get_rank_for_deal(self, status_id: int) -> str: deal_query = await self.session.execute( - select(Deal).where(Deal.current_status == deal_status).order_by(Deal.lexorank.desc()).limit(1)) + select(Deal) + .where(Deal.current_status_id == status_id) + .order_by(Deal.lexorank.desc()) + .limit(1) + ) deal = deal_query.scalar_one_or_none() if not deal: prev = lexorank.middle(lexorank.Bucket.BUCEKT_0) @@ -46,59 +51,46 @@ class DealService(BaseService): return str(lexorank.parse(deal.lexorank).next()) async def change_status(self, deal: Deal, - status: DealStatus, - user: User, + status_id: int, + user: models.User, deadline: datetime.datetime = None, rank=None, comment: str = ''): - if not deal.current_status == status: + if not deal.current_status_id == status_id: deadline = deadline status_change = DealStatusHistory( deal_id=deal.id, user_id=user.id, changed_at=datetime.datetime.now(), - from_status=deal.current_status, - to_status=status, + from_status_id=deal.current_status_id, + to_status_id=status_id, next_status_deadline=deadline, comment=comment ) self.session.add(status_change) - deal.current_status = status + deal.current_status_id = status_id if not rank: - rank = await self._get_rank_for_deal(status) + rank = await self._get_rank_for_deal(status_id) if rank: deal.lexorank = rank await self.session.flush() - async def create(self, request: DealCreateRequest, user: User) -> DealCreateResponse: - rank = self._get_rank_for_deal(DealStatus.CREATED) - deal = Deal( - name=request.name, - created_at=datetime.datetime.now(), - current_status=DealStatus.CREATED, - lexorank=rank - ) - self.session.add(deal) - await self.session.flush() - - # Append status history - await self.change_status(deal, DealStatus.AWAITING_ACCEPTANCE, user) - - await self.session.commit() - return DealCreateResponse(ok=True) - async def delete(self, request: DealDeleteRequest) -> DealDeleteResponse: deal = await self._get_deal_by_id(request.deal_id) if not deal: return DealDeleteResponse(ok=False, message="Сделка не найдена") if deal.group: - await DealGroupService(self.session).delete_group(deal.group.id) + await deal_group.DealGroupService(self.session).delete_group(deal.group.id) else: deal.is_deleted = True await self.session.commit() return DealDeleteResponse(ok=True, message="Сделка успешно удалена") - async def quick_create(self, request: DealQuickCreateRequest, user: User) -> DealQuickCreateResponse: + async def quick_create(self, request: DealQuickCreateRequest, user: models.User) -> DealQuickCreateResponse: + deal_status = await self.session.get(DealStatus, request.status_id) + if not deal_status: + raise HTTPException(status_code=400, detail="Указан некорректный статус") + client_service = ClientService(self.session) client = await client_service.get_by_name(request.client_name) @@ -108,28 +100,25 @@ class DealService(BaseService): request.client_name, ClientDetailsSchema() ) + shipping_warehouse_service = ShippingWarehouseService(self.session) shipping_warehouse = await shipping_warehouse_service.get_by_name(name=request.shipping_warehouse) if not shipping_warehouse: shipping_warehouse = await shipping_warehouse_service.create_by_name(name=request.shipping_warehouse) - rank = await self._get_rank_for_deal(DealStatus.CREATED) + rank = await self._get_rank_for_deal(request.status_id) deal = Deal( name=request.name, created_at=datetime.datetime.now(), client_id=client.id, - current_status=DealStatus.CREATED, + current_status_id=request.status_id, + board_id=deal_status.board_id, lexorank=rank, shipping_warehouse_id=shipping_warehouse.id, base_marketplace_key=request.base_marketplace.key ) self.session.add(deal) await self.session.flush() - await self.change_status(deal, - DealStatus.AWAITING_ACCEPTANCE, - user, - deadline=request.acceptance_date, - comment=request.comment) # add category if specified if request.category: deal_category = DealPriceCategory( @@ -141,12 +130,12 @@ class DealService(BaseService): await self.session.commit() return DealQuickCreateResponse(deal_id=deal.id) - async def change_status_manual(self, request: DealChangeStatusRequest, user: User) -> DealChangeStatusResponse: + async def change_status_manual(self, request: DealChangeStatusRequest, user: models.User) -> DealChangeStatusResponse: # Changing current status deal = await self._get_deal_by_id(request.deal_id) if not deal: return DealChangeStatusResponse(ok=False) - await self.change_status(deal, DealStatus(request.new_status), user) + await self.change_status(deal, request.new_status, user) await self.session.commit() return DealChangeStatusResponse(ok=True) @@ -199,7 +188,7 @@ class DealService(BaseService): Deal, func.coalesce(price_subquery.c.total_price, 0), func.row_number().over( - partition_by=Deal.current_status, + partition_by=Deal.current_status_id, order_by=Deal.lexorank ).label('rank'), func.coalesce(products_quantity_subquery.c.total_quantity, 0) @@ -208,7 +197,9 @@ class DealService(BaseService): selectinload(Deal.status_history), joinedload(Deal.client), joinedload(Deal.shipping_warehouse), - joinedload(Deal.bill_request) + joinedload(Deal.bill_request), + joinedload(Deal.status), + joinedload(Deal.board), ) .outerjoin( price_subquery, Deal.id == price_subquery.c.deal_id, @@ -231,8 +222,6 @@ class DealService(BaseService): summaries = [] for deal, total_price, rank, products_count in deals_query.all(): deal: Deal - last_status: DealStatusHistory = max(deal.status_history, key=lambda status: status.changed_at) - deadline = last_status.next_status_deadline base_marketplace = None if deal.base_marketplace: base_marketplace = BaseMarketplaceSchema.model_validate(deal.base_marketplace) @@ -242,9 +231,8 @@ class DealService(BaseService): id=deal.id, client_name=deal.client.name, name=deal.name, - changed_at=last_status.changed_at, - deadline=deadline, - status=last_status.to_status, + status=deal.status, + board=deal.board, total_price=total_price, rank=rank, base_marketplace=base_marketplace, @@ -308,6 +296,9 @@ class DealService(BaseService): deal = await self.session.scalar( select(Deal) .options( + joinedload(Deal.status), + joinedload(Deal.board) + .joinedload(Board.project), joinedload(Deal.shipping_warehouse), joinedload(Deal.client) .joinedload(Client.details), @@ -350,7 +341,7 @@ class DealService(BaseService): raise HTTPException(status_code=404, detail="Сделка не найдена") return DealSchema.model_validate(deal) - async def update_general_info(self, request: DealUpdateGeneralInfoRequest) -> DealUpdateGeneralInfoResponse: + async def update_general_info(self, request: DealUpdateGeneralInfoRequest, user: User) -> DealUpdateGeneralInfoResponse: try: deal: Deal = await self.session.scalar( select(Deal) @@ -369,6 +360,15 @@ class DealService(BaseService): deal.delivery_date = request.data.delivery_date deal.receiving_slot_date = request.data.receiving_slot_date + if deal.board_id != request.data.board.id or deal.current_status_id != request.data.status.id: + if deal.group: + for deal in deal.group.deals: + deal.board_id = request.data.board.id + await self.change_status(deal, request.data.status.id, user) + else: + deal.board_id = request.data.board.id + await self.change_status(deal, request.data.status.id, user) + if deal.group: for deal in deal.group.deals: deal.is_accounted = request.data.is_accounted @@ -402,7 +402,7 @@ class DealService(BaseService): stmt = ( select(Deal) .where( - Deal.current_status == request.status, + Deal.current_status_id == request.status_id, Deal.id != request.deal_id, Deal.is_deleted == False, Deal.is_completed == False @@ -433,7 +433,7 @@ class DealService(BaseService): else: new_rank = lexorank.middle(lexorank.Bucket.BUCEKT_0) - await self.change_status(deal, request.status, user, + await self.change_status(deal, request.status_id, user, deadline=request.deadline, comment=request.comment, rank=str(new_rank)) @@ -1079,12 +1079,9 @@ class DealService(BaseService): return DealCompleteResponse(ok=False, message="Сделка не найдена") if deal.group: - deals = await DealGroupService(self.session).complete_group(deal.group.id) - for completed_deal in deals: - await self.change_status(completed_deal, DealStatus.COMPLETED, user) + await deal_group.DealGroupService(self.session).complete_group(deal.group.id) else: deal.is_completed = True - await self.change_status(deal, DealStatus.COMPLETED, user) await self.session.commit() return DealCompleteResponse(ok=True, message="Сделка успешно завершена") @@ -1234,14 +1231,15 @@ class DealService(BaseService): async def _create_deal_from_excel( self, client: Client, + deal_status: DealStatus, breakdown: CityBreakdownFromExcelSchema, user: User, ) -> Deal: - rank = await self._get_rank_for_deal(DealStatus.CREATED) + rank = await self._get_rank_for_deal(deal_status.id) deal = Deal( name=f"{client.name} - {breakdown.base_marketplace.key.upper()} - {breakdown.shipping_warehouse.name}", created_at=datetime.datetime.now(), - current_status=DealStatus.CREATED, + current_status_id=deal_status.id, lexorank=rank, client_id=client.id, base_marketplace_key=breakdown.base_marketplace.key, @@ -1250,19 +1248,8 @@ class DealService(BaseService): self.session.add(deal) await self.session.flush() - await self.change_status(deal, DealStatus.AWAITING_ACCEPTANCE, user) - return deal - async def _create_group(self) -> DealGroup: - group = models.DealGroup( - name='', - lexorank=lexorank.middle(lexorank.Bucket.BUCEKT_0).__str__(), - ) - self.session.add(group) - await self.session.flush() - return group - async def _get_or_create_warehouse( self, shipping_warehouse: OptionalShippingWarehouseSchema, @@ -1288,8 +1275,12 @@ class DealService(BaseService): if not client: return CreateDealsFromExcelResponse(ok=False, message=f"Клиент с ID {request.client_id} не найден") + deal_status: Optional[DealStatus] = await self.session.get(DealStatus, request.status_id) + if not deal_status: + return CreateDealsFromExcelResponse(ok=False, message=f"Статус с ID {request.status_id} не найден") + deals_dict: dict[str, Deal] = {} - group = await self._create_group() + group = await deal_group.DealGroupService(self.session).create_group_model() for product_data in request.products: for breakdown in product_data.cities_breakdown: @@ -1298,7 +1289,7 @@ class DealService(BaseService): key = f"{breakdown.shipping_warehouse.id} - {breakdown.base_marketplace.key}" deal = deals_dict.get(key) if not deal: - deal = await self._create_deal_from_excel(client, breakdown, user) + deal = await self._create_deal_from_excel(client, deal_status, breakdown, user) deals_dict[key] = deal insert_stmt = insert(deal_relations).values({ @@ -1317,121 +1308,6 @@ class DealService(BaseService): await self.session.commit() return CreateDealsFromExcelResponse(ok=True, message="Сделки успешно созданы") - async def add_to_group(self, user: User, request: DealAddToGroupRequest) -> DealAddToGroupResponse: - try: - group_bill_request = await self.session.get(GroupBillRequest, request.group_id) - if group_bill_request: - raise Exception("Нельзя добавить сделку, так как на группу выставлен счёт.") - - # changing status if needed - deal_id = await self.session.scalar( - select(deal_relations.c.deal_id) - .where(deal_relations.c.group_id == request.group_id) - ) - group_deal_status = await self.session.scalar( - select(Deal.current_status) - .where(Deal.id == deal_id) - ) - request_deal = await self.session.scalar( - select(Deal).where(Deal.id == request.deal_id) - ) - if group_deal_status != request_deal.current_status: - await self.change_status(request_deal, group_deal_status, user) - insert_stmt = insert(deal_relations).values({ - 'deal_id': request.deal_id, - 'group_id': request.group_id - }) - await self.session.execute(insert_stmt) - await self.session.commit() - - return DealAddToGroupResponse(ok=True, message="Сделка успешно добавлена в группу") - except Exception as e: - await self.session.rollback() - return DealAddToGroupResponse(ok=False, message=str(e)) - - async def create_group(self, user: User, request: DealCreateGroupRequest) -> DealCreateGroupResponse: - try: - # getting lexorank for grop - - group = await self._create_group() - - for deal_id in [request.dragging_deal_id, request.hovered_deal_id]: - insert_stmt = insert(deal_relations).values({ - 'deal_id': deal_id, - 'group_id': group.id - }) - await self.session.execute(insert_stmt) - # changing status if needed on draggable deal - dragging_deal = await self.session.scalar( - select(Deal).where(Deal.id == request.dragging_deal_id) - ) - dropped_deal = await self.session.scalar( - select(Deal).where(Deal.id == request.hovered_deal_id) - ) - if dragging_deal.current_status != dropped_deal.current_status: - await self.change_status(dragging_deal, DealStatus(dropped_deal.current_status), user) - await self.session.commit() - return DealCreateGroupResponse(ok=True, message="Группа успешно создана") - except Exception as e: - return DealCreateGroupResponse(ok=False, message=str(e)) - - async def remove_from_group(self, request: DealRemoveFromGroupRequest) -> DealRemoveFromGroupResponse: - try: - delete_stmt = ( - delete(deal_relations) - .where( - deal_relations.c.deal_id == request.deal_id, - ) - ) - await self.session.execute(delete_stmt) - await self.session.commit() - return DealRemoveFromGroupResponse(ok=True, message="Сделка успешно удалена из группы") - except Exception as e: - await self.session.rollback() - return DealRemoveFromGroupResponse(ok=False, message=str(e)) - - async def update_group(self, request: DealGroupUpdateRequest) -> DealGroupUpdateResponse: - try: - group = await self.session.scalar( - select(models.DealGroup).where(models.DealGroup.id == request.data.id) - ) - if not group: - return DealGroupUpdateResponse(ok=False, message="Группа не найдена") - # update by dictionary - request_dict = request.data.dict() - update_stmt = ( - update( - models.DealGroup - ) - .where(models.DealGroup.id == request.data.id) - .values(**request_dict) - ) - await self.session.execute(update_stmt) - await self.session.commit() - return DealGroupUpdateResponse(ok=True, message="Группа успешно обновлена") - except Exception as e: - await self.session.rollback() - return DealGroupUpdateResponse(ok=False, message=str(e)) - - async def change_group_status(self, user: User, - request: DealGroupChangeStatusRequest) -> DealGroupChangeStatusResponse: - try: - # getting all deals in group - deals = await self.session.scalars( - select(deal_relations.c.deal_id) - .where(deal_relations.c.group_id == request.group_id) - ) - for deal_id in deals: - deal = await self.session.scalar( - select(Deal).where(Deal.id == deal_id) - ) - await self.change_status(deal, DealStatus(request.new_status), user) - await self.session.commit() - return DealGroupChangeStatusResponse(ok=True, message="Статус группы успешно изменен") - except Exception as e: - await self.session.rollback() - return DealGroupChangeStatusResponse(ok=False, message=str(e)) - async def get_deals_grouped(self, deal: models.Deal) -> List[models.Deal]: if not deal.group: return [deal] diff --git a/services/deal_group.py b/services/deal_group.py index a8591e2..c206b9f 100644 --- a/services/deal_group.py +++ b/services/deal_group.py @@ -1,10 +1,12 @@ -from sqlalchemy import select +from lexorank import lexorank +from sqlalchemy import select, insert, update, delete from sqlalchemy.orm import selectinload -from models import DealService as DealServiceModel -from models.deal_group import DealGroup -from schemas.deal import * +from models import DealService as DealServiceModel, User, Deal, DealProduct, Product, GroupBillRequest +from models.deal_group import DealGroup, deal_relations +from schemas.group import * from services.base import BaseService +from services.deal import DealService class DealGroupService(BaseService): @@ -25,6 +27,63 @@ class DealGroupService(BaseService): ) return group.deals if group else [] + async def create_group_model(self) -> DealGroup: + group = DealGroup( + name='', + lexorank=lexorank.middle(lexorank.Bucket.BUCEKT_0).__str__(), + ) + self.session.add(group) + await self.session.flush() + return group + + async def create_group(self, user: User, request: DealCreateGroupRequest) -> DealCreateGroupResponse: + try: + group = await self.create_group_model() + + for deal_id in [request.dragging_deal_id, request.hovered_deal_id]: + insert_stmt = insert(deal_relations).values({ + 'deal_id': deal_id, + 'group_id': group.id + }) + await self.session.execute(insert_stmt) + # changing status if needed on draggable deal + dragging_deal = await self.session.scalar( + select(Deal).where(Deal.id == request.dragging_deal_id) + ) + dropped_deal = await self.session.scalar( + select(Deal).where(Deal.id == request.hovered_deal_id) + ) + if dragging_deal.current_status_id != dropped_deal.current_status_id: + deal_service = DealService(self.session) + await deal_service.change_status(dragging_deal, dropped_deal.current_status_id, user) + await self.session.commit() + return DealCreateGroupResponse(ok=True, message="Группа успешно создана") + except Exception as e: + return DealCreateGroupResponse(ok=False, message=str(e)) + + async def update_group(self, request: DealGroupUpdateRequest) -> DealGroupUpdateResponse: + try: + group = await self.session.scalar( + select(DealGroup).where(DealGroup.id == request.data.id) + ) + if not group: + return DealGroupUpdateResponse(ok=False, message="Группа не найдена") + # update by dictionary + request_dict = request.data.model_dump() + request_dict.pop("bill_request", None) + + update_stmt = ( + update(DealGroup) + .where(DealGroup.id == request.data.id) + .values(**request_dict) + ) + await self.session.execute(update_stmt) + await self.session.commit() + return DealGroupUpdateResponse(ok=True, message="Группа успешно обновлена") + except Exception as e: + await self.session.rollback() + return DealGroupUpdateResponse(ok=False, message=str(e)) + async def complete_group(self, group_id: int) -> list[Deal]: deals = await self.get_deals_by_group_id(group_id) for deal in deals: @@ -36,3 +95,74 @@ class DealGroupService(BaseService): for deal in deals: deal.is_deleted = True await self.session.commit() + + async def change_group_status( + self, + user: User, + request: DealGroupChangeStatusRequest, + ) -> DealGroupChangeStatusResponse: + try: + # getting all deals in group + deals = await self.session.scalars( + select(deal_relations.c.deal_id) + .where(deal_relations.c.group_id == request.group_id) + ) + + deal_service = DealService(self.session) + for deal_id in deals: + deal = await self.session.scalar( + select(Deal).where(Deal.id == deal_id) + ) + await deal_service.change_status(deal, request.new_status, user) + await self.session.commit() + return DealGroupChangeStatusResponse(ok=True, message="Статус группы успешно изменен") + except Exception as e: + await self.session.rollback() + return DealGroupChangeStatusResponse(ok=False, message=str(e)) + + async def add_deal(self, user: User, request: DealAddToGroupRequest) -> DealAddToGroupResponse: + try: + group_bill_request = await self.session.get(GroupBillRequest, request.group_id) + if group_bill_request: + raise Exception("Нельзя добавить сделку, так как на группу выставлен счёт.") + + # changing status if needed + deal_id = await self.session.scalar( + select(deal_relations.c.deal_id) + .where(deal_relations.c.group_id == request.group_id) + ) + group_deal_status = await self.session.scalar( + select(Deal.current_status_id) + .where(Deal.id == deal_id) + ) + request_deal = await self.session.scalar( + select(Deal).where(Deal.id == request.deal_id) + ) + if group_deal_status != request_deal.current_status_id: + await DealService(self.session).change_status(request_deal, group_deal_status, user) + insert_stmt = insert(deal_relations).values({ + 'deal_id': request.deal_id, + 'group_id': request.group_id + }) + await self.session.execute(insert_stmt) + await self.session.commit() + + return DealAddToGroupResponse(ok=True, message="Сделка успешно добавлена в группу") + except Exception as e: + await self.session.rollback() + return DealAddToGroupResponse(ok=False, message=str(e)) + + async def remove_deal(self, request: DealRemoveFromGroupRequest) -> DealRemoveFromGroupResponse: + try: + delete_stmt = ( + delete(deal_relations) + .where( + deal_relations.c.deal_id == request.deal_id, + ) + ) + await self.session.execute(delete_stmt) + await self.session.commit() + return DealRemoveFromGroupResponse(ok=True, message="Сделка успешно удалена из группы") + except Exception as e: + await self.session.rollback() + return DealRemoveFromGroupResponse(ok=False, message=str(e)) diff --git a/services/project.py b/services/project.py new file mode 100644 index 0000000..ee95100 --- /dev/null +++ b/services/project.py @@ -0,0 +1,73 @@ +from datetime import datetime + +from sqlalchemy import select, update, func, delete, and_ + +from models import Project, Board +from schemas.project import * +from services.base import BaseService + + +class ProjectService(BaseService): + async def get_projects(self) -> GetProjectsResponse: + boards_sub = ( + select(Board) + .where(Board.is_deleted == False) + .subquery() + ) + stmt = ( + select( + Project.id, + Project.name, + func.count(boards_sub.c.id) + ) + .join(boards_sub, Project.id == boards_sub.c.project_id, isouter=True) + .where(Project.is_deleted == False) + .group_by(Project.id, Project.name) + .order_by(Project.name) + ) + project_data = (await self.session.execute(stmt)).all() + + projects = [] + for project_id, name, boards_count in project_data: + project = ProjectSchemaWithCount(id=project_id, name=name, boards_count=boards_count) + projects.append(project) + + return GetProjectsResponse(projects=projects) + + async def create_project(self, request: CreateProjectRequest) -> CreateProjectResponse: + project = Project( + name=request.project.name, + created_at=datetime.now(), + ) + self.session.add(project) + await self.session.commit() + return UpdateProjectResponse(ok=True, message="Проект успешно создан") + + async def update_project(self, request: UpdateProjectRequest) -> UpdateProjectResponse: + stmt = ( + update(Project) + .where(Project.id == request.project.id) + .values(name=request.project.name) + ) + await self.session.execute(stmt) + await self.session.commit() + + return UpdateProjectResponse(ok=True, message="Проект успешно изменен") + + async def delete_project(self, project_id: int) -> DeleteProjectResponse: + stmt_boards = select(Board).where(Board.project_id == project_id) + boards = (await self.session.scalars(stmt_boards)).all() + if len(boards) == 0: + stmt = ( + delete(Project) + .where(Project.id == project_id) + ) + else: + stmt = ( + update(Project) + .where(Project.id == project_id) + .values(is_deleted=True) + ) + await self.session.execute(stmt) + await self.session.commit() + return DeleteProjectResponse(ok=True, message="Проект успешно удален") diff --git a/services/statistics/profit_statistics.py b/services/statistics/profit_statistics.py index 2a83f2e..8955445 100644 --- a/services/statistics/profit_statistics.py +++ b/services/statistics/profit_statistics.py @@ -5,7 +5,7 @@ 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 + ShippingWarehouse, BaseMarketplace, User, Project, Board from schemas.statistics import GetProfitChartDataResponse, GetProfitChartDataRequest, ProfitChartDataItem, \ GetProfitTableDataResponse, GetProfitTableDataRequest, ProfitTableDataItem, CommonProfitFilters from services.base import BaseService @@ -24,7 +24,7 @@ class ProfitStatisticsService(BaseService): 'day', Deal.created_at, ).label('date'), - Deal.current_status, + Deal.current_status_id, ) .subquery() ) @@ -51,7 +51,7 @@ class ProfitStatisticsService(BaseService): 'day', last_statuses.c.changed_at, ).label('date'), - Deal.current_status, + Deal.current_status_id, ) .join(last_statuses, last_statuses.c.deal_id == Deal.id) .subquery() @@ -97,8 +97,7 @@ class ProfitStatisticsService(BaseService): return data - @staticmethod - def _get_stmt_deal_services(sub_filtered_status_history: Subquery): + def _get_stmt_deal_services(self, sub_filtered_status_history: Subquery): return ( select( Deal.id.label("deal_id"), @@ -112,10 +111,24 @@ class ProfitStatisticsService(BaseService): .join(DealService, Deal.id == DealService.deal_id) .join(Service, DealService.service_id == Service.id) .join(sub_filtered_status_history, Deal.id == sub_filtered_status_history.c.deal_id) - .where(and_(Deal.is_deleted == False, Deal.is_accounted == True)) + .where( + and_( + Deal.is_deleted == False, + Deal.is_accounted == True, + Deal.is_completed == True if self.is_completed_only else True + ) + ) .group_by(Deal.id, "date") ) + @staticmethod + def _board_ids_for_project(project_id: int, stmt): + board_ids_stmt = ( + select(Board.id) + .where(Board.project_id == project_id) + ) + return stmt.where(Deal.board_id.in_(board_ids_stmt)) + @staticmethod def _apply_filters(request: CommonProfitFilters, stmt_deal_services, stmt_deal_product_services): if request.client_id != -1: @@ -127,9 +140,21 @@ class ProfitStatisticsService(BaseService): stmt_deal_product_services = stmt_deal_product_services.where( Deal.base_marketplace_key == request.base_marketplace_key) - if request.deal_status_id != -1: - stmt_deal_services = stmt_deal_services.where(Deal.current_status == request.deal_status_id) - stmt_deal_product_services = stmt_deal_product_services.where(Deal.current_status == request.deal_status_id) + if request.project_id != -1: + stmt_deal_services = ProfitStatisticsService._board_ids_for_project(request.project_id, stmt_deal_services) + stmt_deal_product_services = ProfitStatisticsService._board_ids_for_project( + request.project_id, + stmt_deal_product_services, + ) + + if request.board_id != -1: + stmt_deal_services = stmt_deal_services.where(Deal.board_id == request.board_id) + stmt_deal_product_services = stmt_deal_product_services.where(Deal.board_id == request.board_id) + + if request.deal_status_id != -1: + stmt_deal_services = stmt_deal_services.where(Deal.current_status_id == request.deal_status_id) + stmt_deal_product_services = stmt_deal_product_services.where( + Deal.current_status_id == request.deal_status_id) if request.manager_id != -1: stmt_deal_services = stmt_deal_services.where(Deal.manager_id == request.manager_id) @@ -137,8 +162,7 @@ class ProfitStatisticsService(BaseService): return stmt_deal_services, stmt_deal_product_services - @staticmethod - def _get_stmt_product_services(): + def _get_stmt_product_services(self): return ( select( Deal.id.label("deal_id"), @@ -154,7 +178,13 @@ class ProfitStatisticsService(BaseService): ) ) .join(Service, DealProductService.service_id == Service.id) - .where(and_(Deal.is_deleted == False, Deal.is_accounted == True)) + .where( + and_( + Deal.is_deleted == False, + Deal.is_accounted == True, + Deal.is_completed == True if self.is_completed_only else True, + ) + ) .group_by(Deal.id) ) @@ -226,16 +256,47 @@ class ProfitStatisticsService(BaseService): ) @staticmethod - def _join_and_group_by_statuses(stmt): + def _join_and_group_by_projects(stmt): return ( select( - Deal.current_status.label("grouped_value"), + Project.id, + Project.name.label("grouped_value"), func.count(stmt.c.deal_id).label("deals_count"), func.sum(stmt.c.revenue).label("revenue"), func.sum(stmt.c.profit).label("profit"), ) .join(Deal, Deal.id == stmt.c.deal_id) - .group_by(Deal.current_status) + .join(Board, Board.id == Deal.board_id) + .join(Project, Project.id == Board.project_id) + .group_by(Project.id, Project.name) + ) + + @staticmethod + def _join_and_group_by_boards(stmt): + return ( + select( + Board.id, + Board.name.label("grouped_value"), + func.count(stmt.c.deal_id).label("deals_count"), + func.sum(stmt.c.revenue).label("revenue"), + func.sum(stmt.c.profit).label("profit"), + ) + .join(Deal, Deal.id == stmt.c.deal_id) + .join(Board, Board.id == Deal.board_id) + .group_by(Board.id, Board.name) + ) + + @staticmethod + def _join_and_group_by_statuses(stmt): + return ( + select( + Deal.current_status_id.label("grouped_value"), + func.count(stmt.c.deal_id).label("deals_count"), + func.sum(stmt.c.revenue).label("revenue"), + func.sum(stmt.c.profit).label("profit"), + ) + .join(Deal, Deal.id == stmt.c.deal_id) + .group_by(Deal.current_status_id) ) @staticmethod @@ -320,6 +381,7 @@ class ProfitStatisticsService(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.is_completed_only = request.is_completed_only self.filters = request sub_deals_dates = self._get_deals_dates(request.deal_status_id) @@ -399,6 +461,18 @@ class ProfitStatisticsService(BaseService): return await self._table_data_from_stmt(stmt_grouped_by_clients) + async def _get_table_grouped_by_projects(self, request: GetProfitTableDataRequest) -> GetProfitTableDataResponse: + sub_grouped_by_deals = self._get_common_table_grouped(request) + stmt_grouped_by_projects = self._join_and_group_by_projects(sub_grouped_by_deals) + + return await self._table_data_from_stmt(stmt_grouped_by_projects) + + async def _get_table_grouped_by_boards(self, request: GetProfitTableDataRequest) -> GetProfitTableDataResponse: + sub_grouped_by_deals = self._get_common_table_grouped(request) + stmt_grouped_by_boards = self._join_and_group_by_boards(sub_grouped_by_deals) + + return await self._table_data_from_stmt(stmt_grouped_by_boards) + async def _get_table_grouped_by_statuses(self, request: GetProfitTableDataRequest) -> GetProfitTableDataResponse: date_from, date_to = request.date_range @@ -449,6 +523,10 @@ class ProfitStatisticsService(BaseService): return await self._get_table_grouped_by_dates(request) case ProfitTableGroupBy.BY_CLIENTS: return await self._get_table_grouped_by_clients(request) + case ProfitTableGroupBy.BY_PROJECTS: + return await self._get_table_grouped_by_projects(request) + case ProfitTableGroupBy.BY_BOARDS: + return await self._get_table_grouped_by_boards(request) case ProfitTableGroupBy.BY_STATUSES: return await self._get_table_grouped_by_statuses(request) case ProfitTableGroupBy.BY_WAREHOUSES: diff --git a/services/status.py b/services/status.py new file mode 100644 index 0000000..760b484 --- /dev/null +++ b/services/status.py @@ -0,0 +1,128 @@ +from typing import Optional + +from sqlalchemy import select, and_, func + +from models import DealStatus, Deal +from schemas.status import * +from services.base import BaseService + + +class StatusService(BaseService): + async def _get_statuses_for_board(self, board_id: int) -> list[DealStatus]: + stmt = ( + select(DealStatus) + .where( + and_( + DealStatus.board_id == board_id, + DealStatus.is_deleted == False, + ) + ) + .order_by(DealStatus.ordinal_number) + ) + statuses = (await self.session.scalars(stmt)).all() + return list(statuses) + + async def _get_status_by_id(self, status_id: int) -> Optional[DealStatus]: + stmt = ( + select(DealStatus) + .where(DealStatus.id == status_id) + ) + status = await self.session.scalar(stmt) + return status + + async def create_status(self, request: CreateStatusRequest) -> CreateStatusResponse: + statuses = await self._get_statuses_for_board(request.status.board_id) + if len(statuses) == 0: + ordinal_number = 1 + else: + statuses[-1].is_finishing = False + ordinal_number = statuses[-1].ordinal_number + 1 + + status = DealStatus( + **request.status.model_dump(), + ordinal_number=ordinal_number, + is_finishing=True, + ) + self.session.add(status) + await self.session.commit() + + return CreateStatusResponse(ok=True, message="Статус успешно создан") + + async def update_status(self, request: UpdateStatusRequest) -> UpdateStatusResponse: + status = await self._get_status_by_id(request.status.id) + if not status: + return UpdateStatusResponse(ok=False, message=f"Статус с ID {request.status.id} не найден") + + status.name = request.status.name + await self.session.commit() + + return UpdateStatusResponse(ok=True, message="Статус успешно изменен") + + async def update_status_order(self, request: UpdateStatusOrderRequest) -> UpdateStatusOrderResponse: + statuses = await self._get_statuses_for_board(request.board_id) + status_idx = 0 + while status_idx < len(statuses) and statuses[status_idx].id != request.status_id: + status_idx += 1 + if status_idx == len(statuses): + return UpdateStatusOrderResponse(ok=False, message=f"Статус с ID {request.status_id} не найден") + + status = statuses.pop(status_idx) + statuses.insert(request.new_ordinal_number - 1, status) + new_ordinal_number = 1 + for status in statuses: + status.ordinal_number = new_ordinal_number + status.is_finishing = False + new_ordinal_number += 1 + + statuses[-1].is_finishing = True + + await self.session.commit() + return UpdateStatusOrderResponse(ok=True, message="Порядок статусов изменен") + + async def _count_deals_in_progress(self, status_id: int) -> int: + stmt = ( + select(func.count(Deal.id)) + .where( + and_( + Deal.current_status_id == status_id, + Deal.is_deleted == False, + Deal.is_completed == False, + ) + ) + ) + return (await self.session.scalars(stmt)).first() + + async def _count_deals(self, status_id: int) -> int: + stmt = ( + select(func.count(Deal.id)) + .where(Deal.current_status_id == status_id) + ) + return (await self.session.scalars(stmt)).first() + + async def _set_finishing_flag_to_prev_status(self, status: DealStatus): + statuses = await self._get_statuses_for_board(status.board_id) + if len(statuses) < 2: + return + statuses[-2].is_finishing = True + statuses[-1].is_finishing = False + + async def delete_status(self, status_id: int) -> DeleteStatusResponse: + status = await self._get_status_by_id(status_id) + if not status: + return DeleteStatusResponse(ok=False, message=f"Статус с ID {status_id} не найден") + + count_deals_in_progress = await self._count_deals_in_progress(status_id) + if count_deals_in_progress != 0: + return DeleteStatusResponse(ok=False, message="Нельзя удалить статус с активными сделками") + + if status.is_finishing: + await self._set_finishing_flag_to_prev_status(status) + + count_deals = await self._count_deals(status_id) + if count_deals == 0: + await self.session.delete(status) + else: + status.is_deleted = True + await self.session.commit() + + return DeleteStatusResponse(ok=True, message="Статус успешно удален")