From 9095cad992707d4b7251bbeadc7a0469d08b0d66 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Thu, 20 Aug 2026 21:00:22 +0300 Subject: [PATCH] =?UTF-8?q?project-back-fix:=20=D1=8D=D0=BA=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2?= =?UTF-8?q?,=20=D0=BD=D0=B5=D0=BB=D1=8C=D0=B7=D1=8F=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BE=D1=82=D1=80=D0=B8=D1=86=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=87=D0=B8=D1=81?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=B2=20=D0=BF=D0=BE=D0=BB=D1=8F=D1=85=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2,=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B3=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=D0=B4=D1=83=D1=8E=D1=89=D0=B8=D0=B9=20=D0=B3=D0=BE?= =?UTF-8?q?=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/alembic/versions/0019_project_type.py | 53 +++ api/src/api/v1/export.py | 21 + api/src/api/v1/projects.py | 3 - api/src/db/models/project.py | 20 +- api/src/domain/schemas.py | 20 +- api/src/repository/project_repository.py | 16 + .../export_service/export_mappings.py | 405 +++++++++++++++++- .../export_service/export_normalizers.py | 51 +-- .../services/export_service/export_service.py | 110 ++++- api/src/services/project_service.py | 11 +- .../test_db_functions_integration.py | 50 ++- .../integration/test_projects_api_smoke.py | 28 +- api/tests/sqls/fixture.sql | 2 +- api/tests/unit/test_export_normalizers.py | 39 +- web/src/api/projects.js | 5 + web/src/pages/TaskPage/TaskPage.jsx | 9 +- web/src/utils/exportFile.js | 10 + 17 files changed, 741 insertions(+), 112 deletions(-) create mode 100644 api/alembic/versions/0019_project_type.py diff --git a/api/alembic/versions/0019_project_type.py b/api/alembic/versions/0019_project_type.py new file mode 100644 index 0000000..443e78b --- /dev/null +++ b/api/alembic/versions/0019_project_type.py @@ -0,0 +1,53 @@ +from alembic import op + + +revision = "0019" +down_revision = "0018" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Приводим перечень project_type к значениям из UI (projectConfig.jsx / ProjectTypeLiteral) + op.execute("ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS project_project_type_check") + op.execute( + """ + ALTER TABLE v3.project ADD CONSTRAINT project_project_type_check + CHECK (project_type IS NULL OR project_type IN ( + 'Открытие ВСП', 'Закрытие ВСП', 'Переезд ВСП', 'Реновация РФ', 'Открытие УРМ' + )) + """ + ) + + # Обязательные поля проекта (как во фронтенде): заполняем NULL-ы дефолтами, + # затем накладываем NOT NULL + op.execute("UPDATE v3.project SET name = 'Проект' WHERE name IS NULL OR name = ''") + op.execute("UPDATE v3.project SET project_type = 'Открытие ВСП' WHERE project_type IS NULL") + op.execute("UPDATE v3.project SET vsp_format = 'Типовой' WHERE vsp_format IS NULL") + op.execute("UPDATE v3.project SET object_address = '' WHERE object_address IS NULL") + op.execute("UPDATE v3.project SET placement_type = 'own' WHERE placement_type IS NULL") + + op.execute("ALTER TABLE v3.project ALTER COLUMN name SET NOT NULL") + op.execute("ALTER TABLE v3.project ALTER COLUMN project_type SET NOT NULL") + op.execute("ALTER TABLE v3.project ALTER COLUMN vsp_format SET NOT NULL") + op.execute("ALTER TABLE v3.project ALTER COLUMN object_address SET NOT NULL") + op.execute("ALTER TABLE v3.project ALTER COLUMN placement_type SET NOT NULL") + + # Числовые значения проекта не могут быть отрицательными: + # сначала обнуляем уже существующие отрицательные значения, + # затем накладываем ограничения + op.execute("UPDATE v3.project SET staff_count = 0 WHERE staff_count < 0") + op.execute("UPDATE v3.project SET total_area = 0 WHERE total_area < 0") + + op.execute( + "ALTER TABLE v3.project ADD CONSTRAINT project_staff_count_check " + "CHECK (staff_count IS NULL OR staff_count >= 0)" + ) + op.execute( + "ALTER TABLE v3.project ADD CONSTRAINT project_total_area_check " + "CHECK (total_area IS NULL OR total_area >= 0)" + ) + + +def downgrade() -> None: + pass diff --git a/api/src/api/v1/export.py b/api/src/api/v1/export.py index b6d9c1b..9f8966b 100644 --- a/api/src/api/v1/export.py +++ b/api/src/api/v1/export.py @@ -126,6 +126,27 @@ async def export_forms_bulk( ) +@router.get("/project/{project_id}") +async def export_project( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +): + export_service = ExportService(db) + try: + stream, filename, media_type = await export_service.export_project_payload( + project_id=project_id, + current_user=current_user, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + return StreamingResponse( + stream, + media_type=media_type, + headers={"Content-Disposition": export_service.build_content_disposition(filename)}, + ) + + @router.get("/project/{project_id}/report/{year}/{report_type}") async def export_project_report( project_id: int, diff --git a/api/src/api/v1/projects.py b/api/src/api/v1/projects.py index 1c896f5..15d6a2e 100644 --- a/api/src/api/v1/projects.py +++ b/api/src/api/v1/projects.py @@ -14,7 +14,6 @@ from src.db.session import get_db from src.domain.schemas import ( AddForm3LineBody, AddProjectBody, - AddProjectYearBody, BaseListResponse, BaseSingleResponse, CellPatch, @@ -329,14 +328,12 @@ async def delete_project( @router.post("/projects/{project_id}/year") async def add_project_year( project_id: int, - body: AddProjectYearBody, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(require_executor), ) -> dict: project_service = ProjectService(db) return await project_service.add_project_year( project_id=project_id, - year=body.year, user=current_user, ) diff --git a/api/src/db/models/project.py b/api/src/db/models/project.py index 9345a64..14fb466 100644 --- a/api/src/db/models/project.py +++ b/api/src/db/models/project.py @@ -17,7 +17,7 @@ class Project(Base): name="project_placement_type_check", ), CheckConstraint( - "project_type IS NULL OR project_type IN ('current', 'development')", + "project_type IS NULL OR project_type IN ('Открытие ВСП', 'Закрытие ВСП', 'Переезд ВСП', 'Реновация РФ', 'Открытие УРМ')", name="project_project_type_check", ), CheckConstraint( @@ -36,18 +36,26 @@ class Project(Base): "funding_by_ko_decision IS NULL OR funding_by_ko_decision IN ('prrs_budget', 'bank_reserve')", name="project_funding_ko_check", ), + CheckConstraint( + "staff_count IS NULL OR staff_count >= 0", + name="project_staff_count_check", + ), + CheckConstraint( + "total_area IS NULL OR total_area >= 0", + name="project_total_area_check", + ), Index("ix_v3_project_ssp", "org_unit_id"), {"schema": "v3"}, ) id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str | None] = mapped_column(String) + name: Mapped[str] = mapped_column(String, nullable=False) level: Mapped[str | None] = mapped_column(String) parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id")) - project_type: Mapped[str | None] = mapped_column(String) - vsp_format: Mapped[str | None] = mapped_column(String) - placement_type: Mapped[str | None] = mapped_column(String) - object_address: Mapped[str | None] = mapped_column(String) + project_type: Mapped[str] = mapped_column(String, nullable=False) + vsp_format: Mapped[str] = mapped_column(String, nullable=False) + placement_type: Mapped[str] = mapped_column(String, nullable=False) + object_address: Mapped[str] = mapped_column(String, nullable=False) staff_count: Mapped[int | None] = mapped_column(Integer) total_area: Mapped[float | None] = mapped_column(Numeric) org_unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.org_unit.id")) diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 3de8c4a..71402b4 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -388,8 +388,8 @@ class UpdProjectBody(BaseModel): vsp_format: Optional[VspFormatLiteral] = None placement_type: Optional[PlacementTypeLiteral] = None object_address: Optional[str] = None - staff_count: Optional[int] = None - total_area: Optional[float] = None + staff_count: Optional[int] = Field(None, ge=0) + total_area: Optional[float] = Field(None, ge=0) org_unit_id: Optional[int] = None krf_decision_date: Optional[date] = None fk_decision_date: Optional[date] = None @@ -398,10 +398,6 @@ class UpdProjectBody(BaseModel): funding_by_ko_decision: Optional[Literal["prrs_budget", "bank_reserve"]] = None -class AddProjectYearBody(BaseModel): - year: int - - class UpdSmetaBody(BaseModel): is_in_plan: Optional[bool] = None is_in_plan_q2: Optional[bool] = None @@ -423,12 +419,12 @@ class AddProjectBody(BaseModel): level: Literal["project", "program"] = "project" parent_id: Optional[int] = None ssp_id: Optional[int] = None - project_type: Optional[ProjectTypeLiteral] = None - vsp_format: Optional[VspFormatLiteral] = None - placement_type: Optional[PlacementTypeLiteral] = None - object_address: Optional[str] = None - staff_count: Optional[int] = None - total_area: Optional[float] = None + project_type: ProjectTypeLiteral + vsp_format: VspFormatLiteral + placement_type: PlacementTypeLiteral + object_address: str = Field(..., min_length=1) + staff_count: Optional[int] = Field(None, ge=0) + total_area: Optional[float] = Field(None, ge=0) @field_validator("year", mode="before") @classmethod diff --git a/api/src/repository/project_repository.py b/api/src/repository/project_repository.py index 8b0a865..90f3d71 100644 --- a/api/src/repository/project_repository.py +++ b/api/src/repository/project_repository.py @@ -480,6 +480,22 @@ class ProjectRepository: ) await self.db.flush() + async def get_max_year( + self, + project_id: int, + org_unit_ids: list[int] | None = None, + ) -> int | None: + query = ( + select(func.max(RfProjectYear.year)) + .join(Project, Project.id == RfProjectYear.project_id) + .where(Project.id == project_id) + ) + if org_unit_ids is not None: + if len(org_unit_ids) == 0: + return None + query = query.where(Project.org_unit_id.in_(org_unit_ids)) + return (await self.db.execute(query)).scalar_one_or_none() + async def resolve_rf_project_year_id( self, project_id: int, diff --git a/api/src/services/export_service/export_mappings.py b/api/src/services/export_service/export_mappings.py index 797319b..eb5bf2c 100644 --- a/api/src/services/export_service/export_mappings.py +++ b/api/src/services/export_service/export_mappings.py @@ -1426,6 +1426,15 @@ PALETTE = { "INPUT": "FFFFFF", "COLOR": "000000", }, + "project": { + "ROOT": "C2D59A", + "GROUP": "D7E3BC", + "ITEM": "EAF0DD", + "SUB_ITEM": "FFFFFF", + "SUB_ITEM": "EAF0DD", + "INPUT": "FFFFFF", + "COLOR": "000000", + }, "orange": { "ROOT": "F1C297", "GROUP": "F6D6B9", @@ -3132,13 +3141,389 @@ SMETA_COLUMNS: list[tuple[str, str]] = [ ("corrected_development_q3", "Корректировка развитие III квартал"), ("corrected_development_q4", "Корректировка развитие IV квартал"), ] -FORM3_REPORT_COLUMNS: list[tuple[str, str]] = [ - ("section", "Код раздела"), - ("item_id", "ID статьи"), - ("name", "Наименование"), - ("q1", "I квартал"), - ("q2", "II квартал"), - ("q3", "III квартал"), - ("q4", "IV квартал"), - ("totals", "ИТОГО"), -] +FORM3_LIMIT_DEPTH_COLUMNS: dict = { + "header": { + "name": "Данные", + "children": { + "section_code": {"name": "Код раздела"}, + "item_id": {"name": "ID Статьи"}, + "num_group_id": {"name": "ID Группы номенклатуры"}, + "name": {"name": "Наименование"}, + }, + }, + "q1": { + "name": "Отчет об исполнении лимита затрат, утверждённого Правлением Банка в I квартале", + "children": { + "korrektirovka_limita": { + "key": None, + "name": "Корректировка лимита", + "children": { + "adj_by_items": {"name": "По статьям сметы*"}, + "adj_increase": {"name": "Увеличение сметы**"}, + }, + }, + "itogo": { + "key": None, + "name": "Итого", + "children": {"total_corr": {"name": ""}}, + }, + "fakt": { + "key": None, + "name": "Факт", + "children": { + "m1": {"name": "За январь"}, + "m2": {"name": "За февраль"}, + "m3": {"name": "За март"}, + "quarter_actual": {"name": "Итого I квартал"}, + }, + }, + "ekonomiya": { + "key": None, + "name": "Экономия (+), перерасход (-)", + "children": {"economy": {"name": ""}}, + }, + }, + }, + "q2": { + "name": "Отчет об исполнении лимита затрат, утверждённого Правлением Банка в II квартале", + "children": { + "limit_ostatok": { + "key": None, + "name": "Лимит затрат / остаток лимита затрат на II квартал", + "children": {"carryover": {"name": ""}}, + }, + "korrektirovka_limita": { + "key": None, + "name": "Корректировка лимита", + "children": { + "adj_by_items": {"name": "По статьям сметы*"}, + "adj_increase": {"name": "Увеличение сметы**"}, + }, + }, + "itogo": { + "key": None, + "name": "Итого", + "children": {"total_corr": {"name": ""}}, + }, + "fakt": { + "key": None, + "name": "Факт", + "children": { + "m1": {"name": "За апрель"}, + "m2": {"name": "За май"}, + "m3": {"name": "За июнь"}, + "quarter_actual": {"name": "Итого II квартал"}, + }, + }, + "ekonomiya": { + "key": None, + "name": "Экономия (+), перерасход (-)", + "children": {"economy": {"name": ""}}, + }, + }, + }, + "q3": { + "name": "Отчет об исполнении лимита затрат, утверждённого Правлением Банка в III квартале", + "children": { + "limit_ostatok": { + "key": None, + "name": "Лимит затрат / остаток лимита затрат на III квартал", + "children": {"carryover": {"name": ""}}, + }, + "korrektirovka_limita": { + "key": None, + "name": "Корректировка лимита", + "children": { + "adj_by_items": {"name": "По статьям сметы*"}, + "adj_increase": {"name": "Увеличение сметы**"}, + }, + }, + "itogo": { + "key": None, + "name": "Итого", + "children": {"total_corr": {"name": ""}}, + }, + "fakt": { + "key": None, + "name": "Факт", + "children": { + "m1": {"name": "За июль"}, + "m2": {"name": "За август"}, + "m3": {"name": "За сентябрь"}, + "quarter_actual": {"name": "Итого III квартал"}, + }, + }, + "ekonomiya": { + "key": None, + "name": "Экономия (+), перерасход (-)", + "children": {"economy": {"name": ""}}, + }, + }, + }, + "q4": { + "name": "Отчет об исполнении лимита затрат, утверждённого Правлением Банка в IV квартале", + "children": { + "limit_ostatok": { + "key": None, + "name": "Лимит затрат / остаток лимита затрат на IV квартал", + "children": {"carryover": {"name": ""}}, + }, + "korrektirovka_limita": { + "key": None, + "name": "Корректировка лимита", + "children": { + "adj_by_items": {"name": "По статьям сметы*"}, + "adj_increase": {"name": "Увеличение сметы**"}, + }, + }, + "itogo": { + "key": None, + "name": "Итого", + "children": {"total_corr": {"name": ""}}, + }, + "fakt": { + "key": None, + "name": "Факт", + "children": { + "m1": {"name": "За октябрь"}, + "m2": {"name": "За ноябрь"}, + "m3": {"name": "За декабрь"}, + "spod": {"name": "СПОД"}, + "quarter_actual": {"name": "Итого IV квартал"}, + }, + }, + "ekonomiya": { + "key": None, + "name": "Экономия (+), перерасход (-)", + "children": {"economy": {"name": ""}}, + }, + }, + }, +} + +FORM3_CURRENT_EXPENSES_DEPTH_COLUMNS: dict = { + "header": { + "name": "Данные", + "children": { + "section_code": {"name": "Код раздела"}, + "item_id": {"name": "ID Статьи"}, + "num_group_id": {"name": "ID Группы номенклатуры"}, + "name": {"name": "Наименование"}, + }, + }, + "q1": { + "name": "Отчет об исполнении текущих расходов в I квартале", + "children": { + "korrektirovka_limita": { + "key": None, + "name": "Корректировка лимита", + "children": { + "adj_by_items": {"name": "По статьям сметы*"}, + "adj_increase": {"name": "Увеличение сметы**"}, + }, + }, + "itogo": { + "key": None, + "name": "Итого", + "children": {"total_corr": {"name": ""}}, + }, + "fakt": { + "key": None, + "name": "Факт", + "children": { + "m1": {"name": "За январь"}, + "m2": {"name": "За февраль"}, + "m3": {"name": "За март"}, + "quarter_actual": {"name": "Итого I квартал"}, + }, + }, + "ekonomiya": { + "key": None, + "name": "Экономия (+), перерасход (-)", + "children": {"economy": {"name": ""}}, + }, + }, + }, + "q2": { + "name": "Отчет об исполнении текущих расходов в II квартале", + "children": { + "limit_ostatok": { + "key": None, + "name": "Лимит затрат / остаток лимита затрат на II квартал", + "children": {"carryover": {"name": ""}}, + }, + "korrektirovka_limita": { + "key": None, + "name": "Корректировка лимита", + "children": { + "adj_by_items": {"name": "По статьям сметы*"}, + "adj_increase": {"name": "Увеличение сметы**"}, + }, + }, + "itogo": { + "key": None, + "name": "Итого", + "children": {"total_corr": {"name": ""}}, + }, + "fakt": { + "key": None, + "name": "Факт", + "children": { + "m1": {"name": "За апрель"}, + "m2": {"name": "За май"}, + "m3": {"name": "За июнь"}, + "quarter_actual": {"name": "Итого II квартал"}, + }, + }, + "ekonomiya": { + "key": None, + "name": "Экономия (+), перерасход (-)", + "children": {"economy": {"name": ""}}, + }, + }, + }, + "q3": { + "name": "Отчет об исполнении текущих расходов в III квартале", + "children": { + "limit_ostatok": { + "key": None, + "name": "Лимит затрат / остаток лимита затрат на III квартал", + "children": {"carryover": {"name": ""}}, + }, + "korrektirovka_limita": { + "key": None, + "name": "Корректировка лимита", + "children": { + "adj_by_items": {"name": "По статьям сметы*"}, + "adj_increase": {"name": "Увеличение сметы**"}, + }, + }, + "itogo": { + "key": None, + "name": "Итого", + "children": {"total_corr": {"name": ""}}, + }, + "fakt": { + "key": None, + "name": "Факт", + "children": { + "m1": {"name": "За июль"}, + "m2": {"name": "За август"}, + "m3": {"name": "За сентябрь"}, + "quarter_actual": {"name": "Итого III квартал"}, + }, + }, + "ekonomiya": { + "key": None, + "name": "Экономия (+), перерасход (-)", + "children": {"economy": {"name": ""}}, + }, + }, + }, + "q4": { + "name": "Отчет об исполнении текущих расходов в IV квартале", + "children": { + "limit_ostatok": { + "key": None, + "name": "Лимит затрат / остаток лимита затрат на IV квартал", + "children": {"carryover": {"name": ""}}, + }, + "korrektirovka_limita": { + "key": None, + "name": "Корректировка лимита", + "children": { + "adj_by_items": {"name": "По статьям сметы*"}, + "adj_increase": {"name": "Увеличение сметы**"}, + }, + }, + "itogo": { + "key": None, + "name": "Итого", + "children": {"total_corr": {"name": ""}}, + }, + "fakt": { + "key": None, + "name": "Факт", + "children": { + "m1": {"name": "За октябрь"}, + "m2": {"name": "За ноябрь"}, + "m3": {"name": "За декабрь"}, + "spod": {"name": "СПОД"}, + "quarter_actual": {"name": "Итого IV квартал"}, + }, + }, + "ekonomiya": { + "key": None, + "name": "Экономия (+), перерасход (-)", + "children": {"economy": {"name": ""}}, + }, + }, + }, +} + +FORM3_COLORS: dict = { + "header": {"background": "C2D69B", "palette": "green"}, + "header.section_code": {"background": "C2D69B", "palette": "green"}, + "header.item_id": {"background": "C2D69B", "palette": "green"}, + "header.num_group_id": {"background": "C2D69B", "palette": "green"}, + "header.name": {"background": "C2D69B", "palette": "green"}, + "q1": {"background": "C2D69B", "palette": "green"}, + "q1.korrektirovka_limita": {"background": "C2D69B", "palette": "green"}, + "q1.korrektirovka_limita.adj_by_items": {"background": "C2D69B", "palette": "green"}, + "q1.korrektirovka_limita.adj_increase": {"background": "C2D69B", "palette": "green"}, + "q1.itogo": {"background": "C2D69B", "palette": "green"}, + "q1.itogo.total_corr": {"background": "C2D69B", "palette": "green"}, + "q1.fakt": {"background": "C2D69B", "palette": "green"}, + "q1.fakt.m1": {"background": "C2D69B", "palette": "green"}, + "q1.fakt.m2": {"background": "C2D69B", "palette": "green"}, + "q1.fakt.m3": {"background": "C2D69B", "palette": "green"}, + "q1.fakt.quarter_actual": {"background": "C2D69B", "palette": "green"}, + "q1.ekonomiya": {"background": "C2D69B", "palette": "green"}, + "q1.ekonomiya.economy": {"background": "C2D69B", "palette": "green"}, + "q2": {"background": "C2D69B", "palette": "green"}, + "q2.limit_ostatok": {"background": "C2D69B", "palette": "green"}, + "q2.limit_ostatok.carryover": {"background": "C2D69B", "palette": "green"}, + "q2.korrektirovka_limita": {"background": "C2D69B", "palette": "green"}, + "q2.korrektirovka_limita.adj_by_items": {"background": "C2D69B", "palette": "green"}, + "q2.korrektirovka_limita.adj_increase": {"background": "C2D69B", "palette": "green"}, + "q2.itogo": {"background": "C2D69B", "palette": "green"}, + "q2.itogo.total_corr": {"background": "C2D69B", "palette": "green"}, + "q2.fakt": {"background": "C2D69B", "palette": "green"}, + "q2.fakt.m1": {"background": "C2D69B", "palette": "green"}, + "q2.fakt.m2": {"background": "C2D69B", "palette": "green"}, + "q2.fakt.m3": {"background": "C2D69B", "palette": "green"}, + "q2.fakt.quarter_actual": {"background": "C2D69B", "palette": "green"}, + "q2.ekonomiya": {"background": "C2D69B", "palette": "green"}, + "q2.ekonomiya.economy": {"background": "C2D69B", "palette": "green"}, + "q3": {"background": "C2D69B", "palette": "green"}, + "q3.limit_ostatok": {"background": "C2D69B", "palette": "green"}, + "q3.limit_ostatok.carryover": {"background": "C2D69B", "palette": "green"}, + "q3.korrektirovka_limita": {"background": "C2D69B", "palette": "green"}, + "q3.korrektirovka_limita.adj_by_items": {"background": "C2D69B", "palette": "green"}, + "q3.korrektirovka_limita.adj_increase": {"background": "C2D69B", "palette": "green"}, + "q3.itogo": {"background": "C2D69B", "palette": "green"}, + "q3.itogo.total_corr": {"background": "C2D69B", "palette": "green"}, + "q3.fakt": {"background": "C2D69B", "palette": "green"}, + "q3.fakt.m1": {"background": "C2D69B", "palette": "green"}, + "q3.fakt.m2": {"background": "C2D69B", "palette": "green"}, + "q3.fakt.m3": {"background": "C2D69B", "palette": "green"}, + "q3.fakt.quarter_actual": {"background": "C2D69B", "palette": "green"}, + "q3.ekonomiya": {"background": "C2D69B", "palette": "green"}, + "q3.ekonomiya.economy": {"background": "C2D69B", "palette": "green"}, + "q4": {"background": "C2D69B", "palette": "green"}, + "q4.limit_ostatok": {"background": "C2D69B", "palette": "green"}, + "q4.limit_ostatok.carryover": {"background": "C2D69B", "palette": "green"}, + "q4.korrektirovka_limita": {"background": "C2D69B", "palette": "green"}, + "q4.korrektirovka_limita.adj_by_items": {"background": "C2D69B", "palette": "green"}, + "q4.korrektirovka_limita.adj_increase": {"background": "C2D69B", "palette": "green"}, + "q4.itogo": {"background": "C2D69B", "palette": "green"}, + "q4.itogo.total_corr": {"background": "C2D69B", "palette": "green"}, + "q4.fakt": {"background": "C2D69B", "palette": "green"}, + "q4.fakt.m1": {"background": "C2D69B", "palette": "green"}, + "q4.fakt.m2": {"background": "C2D69B", "palette": "green"}, + "q4.fakt.m3": {"background": "C2D69B", "palette": "green"}, + "q4.fakt.spod": {"background": "C2D69B", "palette": "green"}, + "q4.fakt.quarter_actual": {"background": "C2D69B", "palette": "green"}, + "q4.ekonomiya": {"background": "C2D69B", "palette": "green"}, + "q4.ekonomiya.economy": {"background": "C2D69B", "palette": "green"}, +} diff --git a/api/src/services/export_service/export_normalizers.py b/api/src/services/export_service/export_normalizers.py index ea6412c..d2c99d5 100644 --- a/api/src/services/export_service/export_normalizers.py +++ b/api/src/services/export_service/export_normalizers.py @@ -246,38 +246,25 @@ class SmetaRowNormalizer: return cur -class Form3ReportRowNormalizer: +class Form3DepthRowNormalizer: + def __init__(self, serializer: ExcelValueSerializer): + self.serializer = serializer + def normalize(self, data: dict[str, Any]) -> dict[str, Any]: - normalized = dict(data) - header = data.get("header") - if isinstance(header, dict): - if "section_code" in header and "section" not in normalized: - normalized["section"] = header.get("section_code") - if "item_id" in header and "item_id" not in normalized: - normalized["item_id"] = header.get("item_id") - if "name" in header and "name" not in normalized: - normalized["name"] = header.get("name") - for quarter_key in ("q1", "q2", "q3", "q4"): - normalized[quarter_key] = self._extract_quarter_metric(normalized.get(quarter_key)) - normalized["totals"] = self._extract_totals_metric(normalized.get("totals")) - normalized.pop("header", None) - normalized.pop("line_id", None) + normalized: dict[str, Any] = {} + self._normalize_depth(normalized=normalized, original=data) return normalized - @staticmethod - def _extract_quarter_metric(value: Any) -> Any: - if not isinstance(value, dict): - return value - for key in ("quarter_actual", "total_corr", "economy"): - if value.get(key) is not None: - return value.get(key) - return None - - @staticmethod - def _extract_totals_metric(value: Any) -> Any: - if not isinstance(value, dict): - return value - for key in ("total_actual", "total_corr", "economy"): - if value.get(key) is not None: - return value.get(key) - return None + def _normalize_depth( + self, + normalized: dict[str, Any], + original: dict[str, Any], + prefix: str = "", + ) -> None: + for key, value in original.items(): + if key == "line_id": + continue + if isinstance(value, dict): + self._normalize_depth(normalized=normalized, original=value, prefix=f"{prefix}{key}.") + else: + normalized[f"{prefix}{key}"] = value diff --git a/api/src/services/export_service/export_service.py b/api/src/services/export_service/export_service.py index e280a6e..56b556c 100644 --- a/api/src/services/export_service/export_service.py +++ b/api/src/services/export_service/export_service.py @@ -18,14 +18,16 @@ from src.services.budget_form_service import BudgetFormService from src.services.export_service.export_normalizers import ( ExcelValueSerializer, Form1DepthRowNormalizer, - Form3ReportRowNormalizer, + Form3DepthRowNormalizer, SmetaRowNormalizer, ) from src.services.export_service.export_mappings import ( FORM1_COLORS, FORM1_DEPTH_COLUMNS, FORM2_DEPTH_AHR_SUB_COLUMNS, - FORM3_REPORT_COLUMNS, + FORM3_COLORS, + FORM3_CURRENT_EXPENSES_DEPTH_COLUMNS, + FORM3_LIMIT_DEPTH_COLUMNS, SMETA_COLUMNS, ) from src.services.export_service.export_writers import ( @@ -50,6 +52,8 @@ SHEET_NAMES = { 'AHR_UTILITY': 'АХР Коммунальные услуги', 'SMETA': 'Смета', 'STRUCTURE': 'Структура', + 'LIMIT': 'Лимиты', + 'CURRENT_EXPENSES': 'Текущие расходы', } SKIP_SHEETS = ('OTCH9F',) @@ -69,7 +73,7 @@ class ExportService: self.project_service = ProjectService(db) self.value_serializer = ExcelValueSerializer() self.form1_row_normalizer = Form1DepthRowNormalizer(self.value_serializer) - self.form3_report_row_normalizer = Form3ReportRowNormalizer() + self.form3_row_normalizer = Form3DepthRowNormalizer(self.value_serializer) self.smeta_row_normalizer = SmetaRowNormalizer() self.sheet_writers: dict[str, TabularSheetWriter] = { "__default__": DepthSheetWriter( @@ -122,11 +126,6 @@ class ExportService: colors_config=FORM1_COLORS["AHR"], ), } - self.form3_report_writer = TabularSheetWriter( - columns=FORM3_REPORT_COLUMNS, - row_normalizer=self.form3_report_row_normalizer.normalize, - value_serializer=self.value_serializer.to_excel_value, - ) async def export_form_payload( self, @@ -164,6 +163,17 @@ class ExportService: ) return stream, filename, XLSX_MEDIA_TYPE + async def export_project_payload( + self, + project_id: int, + current_user: AppUser, + ) -> tuple[io.BytesIO, str, str]: + stream, filename = await self.export_project_to_xlsx( + project_id=project_id, + current_user=current_user, + ) + return stream, filename, XLSX_MEDIA_TYPE + async def export_bulk_payload( self, form_ids: list[int], @@ -300,14 +310,20 @@ class ExportService: user=current_user, ) report_type_upper = report_type.upper() + project = await self.project_service.get(project_id, current_user) workbook = Workbook() meta_sheet = workbook.active meta_sheet.title = "metadata" metadata_rows = [ ("Тип формы", "FORM_3"), - ("Project ID", project_id), - ("Year", year), - ("Report Type", report_type_upper), + ("Название проекта", project.get("name") if project else None), + ("Технический номер", project.get("technical_number") if project else None), + ("Тип проекта", project.get("project_type") if project else None), + ("Формат ВСП", project.get("vsp_format") if project else None), + ("ССП/РФ", project.get("org_unit_name") if project else None), + ("Статус", project.get("status") if project else None), + ("Год", year), + ("Тип отчёта", report_type_upper), ("Sections", ", ".join(sections) if sections else None), ("Экспортировано", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")), ] @@ -315,7 +331,13 @@ class ExportService: meta_sheet.cell(row=row_idx, column=1, value=key) meta_sheet.cell(row=row_idx, column=2, value=value) worksheet = workbook.create_sheet(title=self._safe_sheet_name(report_type_upper)) - self.form3_report_writer.write(worksheet, rows) + report_writer = DepthSheetWriter( + columns_dict=self._form3_columns(report_type_upper), + row_normalizer=self.form3_row_normalizer.normalize, + value_serializer=self.value_serializer.to_excel_value, + colors_config=FORM3_COLORS, + ) + report_writer.write(worksheet, rows) stream = io.BytesIO() workbook.save(stream) workbook.close() @@ -324,11 +346,75 @@ class ExportService: filename = f"{year}_FORM_3_{project_id}_{report_type_upper}_{date_str}.xlsx" return stream, filename + async def export_project_to_xlsx( + self, + project_id: int, + current_user: AppUser, + ) -> tuple[io.BytesIO, str]: + project = await self.project_service.get(project_id, current_user) + if not project: + raise ValueError(f"Проект {project_id} не найден") + + reports = project.get("reports") or [] + if not reports: + raise ValueError("У проекта нет отчётов для экспорта") + + workbook = Workbook() + meta_sheet = workbook.active + meta_sheet.title = "metadata" + metadata_rows = [ + ("Тип формы", "FORM_3"), + ("Название проекта", project.get("name")), + ("Технический номер", project.get("technical_number")), + ("Тип проекта", project.get("project_type")), + ("Формат ВСП", project.get("vsp_format")), + ("ССП/РФ", project.get("org_unit_name")), + ("Статус", project.get("status")), + ("Экспортировано", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")), + ] + for row_idx, (key, value) in enumerate(metadata_rows, start=1): + meta_sheet.cell(row=row_idx, column=1, value=key) + meta_sheet.cell(row=row_idx, column=2, value=value) + + for report in reports: + report_type = str(report.get("report_type", "")).upper() + year = report.get("year") + rows = await self.project_service.get_report_rows( + project_id=project_id, + year=year, + report_type=report_type, + sections=None, + user=current_user, + ) + sheet_title = f"{year}_{self._safe_sheet_name(report_type)}" + worksheet = workbook.create_sheet(title=sheet_title) + report_writer = DepthSheetWriter( + columns_dict=self._form3_columns(report_type), + row_normalizer=self.form3_row_normalizer.normalize, + value_serializer=self.value_serializer.to_excel_value, + colors_config=FORM3_COLORS, + ) + report_writer.write(worksheet, rows) + + stream = io.BytesIO() + workbook.save(stream) + workbook.close() + stream.seek(0) + date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + filename = f"project_{project_id}_FORM_3_{date_str}.xlsx" + return stream, filename + @staticmethod def build_content_disposition(filename: str) -> str: encoded = quote(filename) return f"attachment; filename*=UTF-8''{encoded}" + @staticmethod + def _form3_columns(report_type: str) -> dict: + if (report_type or "").upper() == "LIMIT": + return FORM3_LIMIT_DEPTH_COLUMNS + return FORM3_CURRENT_EXPENSES_DEPTH_COLUMNS + def _fill_metadata(self, sheet: Worksheet, form: Any) -> None: metadata_rows = [ ("Форма ID", form.id), diff --git a/api/src/services/project_service.py b/api/src/services/project_service.py index fa92a1f..a81bf74 100644 --- a/api/src/services/project_service.py +++ b/api/src/services/project_service.py @@ -224,7 +224,6 @@ class ProjectService: async def add_project_year( self, project_id: int, - year: int, user: AppUser, ) -> dict: org_unit_ids = await self._allowed_org_unit_ids(user) @@ -234,14 +233,14 @@ class ProjectService: ) if not project: raise ValidationException("Проект не найден") - existing = await self.project_repo.resolve_rf_project_year_id( + last_year = await self.project_repo.get_max_year( project_id=project_id, - year=year, org_unit_ids=org_unit_ids, ) - if existing is not None: - raise ValidationException(f"Год {year} у проекта уже заведён") - return await self.project_repo.add_project_year(project_id=project_id, year=year) + if not last_year: + raise ValidationException("У проекта нет года") + next_year = last_year + 1 + return await self.project_repo.add_project_year(project_id=project_id, year=next_year) async def add_project( self, diff --git a/api/tests/integration/test_db_functions_integration.py b/api/tests/integration/test_db_functions_integration.py index 64a02a6..19760af 100644 --- a/api/tests/integration/test_db_functions_integration.py +++ b/api/tests/integration/test_db_functions_integration.py @@ -123,7 +123,15 @@ def test_backend_calls_form3_add_project_and_add_line_functions( ): create = client.post( "/api/v1/projects", - json={"name": f"ITEST_DB_FUNC_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1}, + json={ + "name": f"ITEST_DB_FUNC_{uuid.uuid4().hex[:6]}", + "year": 2026, + "branch_id": 1, + "project_type": "Открытие ВСП", + "vsp_format": "Типовой", + "placement_type": "Собственность", + "object_address": "Тестовая улица 1", + }, headers=auth_headers(admin_tokens), ) assert create.status_code == 200 @@ -214,7 +222,15 @@ def test_forms_validation_direction_forbidden_on_non_form1( def test_form3_validation_invalid_sections_returns_400(client, admin_tokens, auth_headers): create = client.post( "/api/v1/projects", - json={"name": f"ITEST_F3_SECT_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1}, + json={ + "name": f"ITEST_F3_SECT_{uuid.uuid4().hex[:6]}", + "year": 2026, + "branch_id": 1, + "project_type": "Открытие ВСП", + "vsp_format": "Типовой", + "placement_type": "Собственность", + "object_address": "Тестовая улица 1", + }, headers=auth_headers(admin_tokens), ) assert create.status_code == 200 @@ -232,7 +248,15 @@ def test_form3_validation_invalid_sections_returns_400(client, admin_tokens, aut def test_form3_validation_empty_sections_csv_is_ignored(client, admin_tokens, auth_headers): create = client.post( "/api/v1/projects", - json={"name": f"ITEST_F3_EMPTY_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1}, + json={ + "name": f"ITEST_F3_EMPTY_{uuid.uuid4().hex[:6]}", + "year": 2026, + "branch_id": 1, + "project_type": "Открытие ВСП", + "vsp_format": "Типовой", + "placement_type": "Собственность", + "object_address": "Тестовая улица 1", + }, headers=auth_headers(admin_tokens), ) assert create.status_code == 200 @@ -248,7 +272,15 @@ def test_form3_validation_empty_sections_csv_is_ignored(client, admin_tokens, au def test_form3_validation_duplicate_sections_is_allowed(client, admin_tokens, auth_headers): create = client.post( "/api/v1/projects", - json={"name": f"ITEST_F3_DUP_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1}, + json={ + "name": f"ITEST_F3_DUP_{uuid.uuid4().hex[:6]}", + "year": 2026, + "branch_id": 1, + "project_type": "Открытие ВСП", + "vsp_format": "Типовой", + "placement_type": "Собственность", + "object_address": "Тестовая улица 1", + }, headers=auth_headers(admin_tokens), ) assert create.status_code == 200 @@ -265,7 +297,15 @@ def test_form3_add_project_invalid_name_returns_422(client, admin_tokens, auth_h # Пробел запрещён regex-правилом AddProjectBody. response = client.post( "/api/v1/projects", - json={"name": "INVALID NAME", "year": 2026, "branch_id": 1}, + json={ + "name": "INVALID NAME", + "year": 2026, + "branch_id": 1, + "project_type": "Открытие ВСП", + "vsp_format": "Типовой", + "placement_type": "Собственность", + "object_address": "Тестовая улица 1", + }, headers=auth_headers(admin_tokens), ) assert response.status_code == 422 diff --git a/api/tests/integration/test_projects_api_smoke.py b/api/tests/integration/test_projects_api_smoke.py index 3a0d356..6fcef53 100644 --- a/api/tests/integration/test_projects_api_smoke.py +++ b/api/tests/integration/test_projects_api_smoke.py @@ -18,7 +18,15 @@ def _create_project(client, admin_tokens, auth_headers, year=2026, branch_id=1) name = f"PT_{uuid.uuid4().hex[:10]}" response = client.post( "/api/v1/projects", - json={"name": name, "year": year, "branch_id": branch_id}, + json={ + "name": name, + "year": year, + "branch_id": branch_id, + "project_type": "Открытие ВСП", + "vsp_format": "Типовой", + "placement_type": "Собственность", + "object_address": "Тестовая улица 1", + }, headers=auth_headers(admin_tokens), ) assert response.status_code == 200 @@ -54,7 +62,15 @@ def test_create_project_smoke(client, admin_tokens, auth_headers): name = f"PT_{uuid.uuid4().hex[:10]}" response = client.post( "/api/v1/projects", - json={"name": name, "year": None, "branch_id": 1}, + json={ + "name": name, + "year": None, + "branch_id": 1, + "project_type": "Открытие ВСП", + "vsp_format": "Типовой", + "placement_type": "Собственность", + "object_address": "Тестовая улица 1", + }, headers=auth_headers(admin_tokens), ) assert response.status_code == 200 @@ -361,7 +377,7 @@ def test_add_project_year_smoke(client, admin_tokens, auth_headers): response = client.post( f"/api/v1/projects/{project_id}/year", - json={"year": 2027}, + json={"year": 2050}, headers=auth_headers(admin_tokens), ) assert response.status_code == 200 @@ -377,7 +393,7 @@ def test_add_project_year_smoke(client, admin_tokens, auth_headers): assert 2027 in sub_years -def test_add_project_year_duplicate(client, admin_tokens, auth_headers): +def test_add_project_year_ignores_requested_year(client, admin_tokens, auth_headers): project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026) response = client.post( @@ -385,9 +401,9 @@ def test_add_project_year_duplicate(client, admin_tokens, auth_headers): json={"year": 2026}, headers=auth_headers(admin_tokens), ) - assert response.status_code == 400 + assert response.status_code == 200 payload = response.json() - assert payload.get("message") == "Год 2026 у проекта уже заведён" + assert payload["year"] == 2027 def test_add_project_year_project_not_found(client, admin_tokens, auth_headers): diff --git a/api/tests/sqls/fixture.sql b/api/tests/sqls/fixture.sql index 1d55800..e9652c7 100644 --- a/api/tests/sqls/fixture.sql +++ b/api/tests/sqls/fixture.sql @@ -35,7 +35,7 @@ INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_t SELECT setval('v3.vsp_id_seq', 3); INSERT INTO v3.project ("id","name","level",parent_id,project_type,vsp_format,placement_type,object_address,staff_count,total_area,org_unit_id,status,technical_number,krf_decision_date,fk_decision_date,board_decision_date,open_relocate_close_date,funding_by_ko_decision) VALUES - (2,'PT6d112310_U','project',NULL,NULL,NULL,NULL,NULL,NULL,NULL,1,'created','2606_180801','2026-06-11','2026-06-18','2026-07-02','2026-07-15','prrs_budget'); + (2,'PT6d112310_U','project',NULL,'Открытие ВСП','Типовой','own','Тестовая улица 1',NULL,NULL,1,'created','2606_180801','2026-06-11','2026-06-18','2026-07-02','2026-07-15','prrs_budget'); SELECT setval('v3.project_id_seq', 3); INSERT INTO v3.rf_project_year ("id",project_id,"year") VALUES diff --git a/api/tests/unit/test_export_normalizers.py b/api/tests/unit/test_export_normalizers.py index 0463844..9d650fb 100644 --- a/api/tests/unit/test_export_normalizers.py +++ b/api/tests/unit/test_export_normalizers.py @@ -1,7 +1,7 @@ from src.services.export_service.export_normalizers import ( ExcelValueSerializer, Form1RowNormalizer, - Form3ReportRowNormalizer, + Form3DepthRowNormalizer, SmetaRowNormalizer, ) @@ -52,24 +52,29 @@ def test_excel_value_serializer_formats_collections(): assert serializer.to_excel_value({"k": {"nested": 1}}) == "k=1" -def test_form3_normalizer_flattens_quarters_and_totals_to_scalars(): - normalizer = Form3ReportRowNormalizer() +def test_form3_depth_normalizer_flattens_nested_data(): + normalizer = Form3DepthRowNormalizer(ExcelValueSerializer()) row = { - "header": {"section_code": "1.00.0.", "item_id": "R001", "name": "Строка"}, - "q1": {"quarter_actual": 10, "total_corr": 12}, - "q2": {"total_corr": 22}, - "q3": {"economy": 1.5}, - "q4": {"m1": 100}, - "totals": {"total_actual": 40, "economy": 2.1}, + "line_id": 5, + "header": {"section_code": "1.00.0.", "item_id": "R001", "num_group_id": 7, "name": "Строка"}, + "q1": {"base_plan": 1, "adj_by_items": 2, "m1": 10, "quarter_actual": 20, "economy": 3}, + "q2": {"corrected_plan": 4, "carryover": 5}, + "totals": {"base_plan": 100, "total_actual": 40, "economy": 2.1}, } normalized = normalizer.normalize(row) - assert normalized["section"] == "1.00.0." - assert normalized["item_id"] == "R001" - assert normalized["name"] == "Строка" - assert normalized["q1"] == 10 - assert normalized["q2"] == 22 - assert normalized["q3"] == 1.5 - assert normalized["q4"] is None - assert normalized["totals"] == 40 + assert normalized["header.section_code"] == "1.00.0." + assert normalized["header.item_id"] == "R001" + assert normalized["header.num_group_id"] == 7 + assert normalized["header.name"] == "Строка" + assert normalized["q1.base_plan"] == 1 + assert normalized["q1.adj_by_items"] == 2 + assert normalized["q1.m1"] == 10 + assert normalized["q1.quarter_actual"] == 20 + assert normalized["q2.corrected_plan"] == 4 + assert normalized["q2.carryover"] == 5 + assert normalized["totals.base_plan"] == 100 + assert normalized["totals.total_actual"] == 40 + assert normalized["totals.economy"] == 2.1 + assert "line_id" not in normalized diff --git a/web/src/api/projects.js b/web/src/api/projects.js index 8f26d7e..323ec0b 100644 --- a/web/src/api/projects.js +++ b/web/src/api/projects.js @@ -43,4 +43,9 @@ export const ProjectsApi = { responseType: 'blob', }); }, + exportProject: (id) => { + return api.get(`/export/project/${id}`, { + responseType: 'blob', + }); + }, }; diff --git a/web/src/pages/TaskPage/TaskPage.jsx b/web/src/pages/TaskPage/TaskPage.jsx index 4ee2a46..395b90e 100644 --- a/web/src/pages/TaskPage/TaskPage.jsx +++ b/web/src/pages/TaskPage/TaskPage.jsx @@ -17,7 +17,7 @@ import TablesList from '../../components/common/TableList/TableList'; import { TableIcon } from '../../components/common/icons/icons'; import { Chip } from '../../components/styles/StyledChip'; import { FORM_TYPE_TRANSLATE, SHEET_NAME } from '../../constants/constants'; -import { exportSingleForm } from '../../utils/exportFile'; +import { exportProject, exportSingleForm } from '../../utils/exportFile'; const TaskInfo = ({ task, isProject }) => { const portalContent = ( @@ -113,7 +113,12 @@ export default function TaskPage() { // Функция для экспорта const handleExport = () => { - const fileName = isProject ? `project_${task?.title || id}` : `form_${task?.title || taskId}`; + if (isProject) { + const fileName = `project_${task?.name || id}`; + exportProject(id, fileName); + return; + } + const fileName = `form_${task?.title || taskId}`; exportSingleForm(id, fileName); }; diff --git a/web/src/utils/exportFile.js b/web/src/utils/exportFile.js index e0b410e..5a3ebd3 100644 --- a/web/src/utils/exportFile.js +++ b/web/src/utils/exportFile.js @@ -37,6 +37,16 @@ export const exportSheetProject = async (formId, sheetName, year) => { } }; +export const exportProject = async (projectId, projectTitle) => { + try { + const response = await ProjectsApi.exportProject(projectId); + await downloadFile(response, projectTitle, 'xlsx'); + } catch (error) { + const message = await getApiErrorMessage(error, 'Ошибка при скачивании файла'); + toast.error(message); + } +}; + export const exportBulkForms = async (formIds, fileName = 'Задачи') => { try { const response = await TasksApi.exportTasks({ form_ids: formIds }); -- 2.47.2