From d6da54ecf4173ef94ffe273c16945e87f4355d62 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Fri, 25 Sep 2026 14:20:04 +0300 Subject: [PATCH] =?UTF-8?q?form3-expense:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=D0=B8=20=D0=B2=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D1=83=203,=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/0038_form3_expense_item_links.py | 15 ++ .../0039_budget_form_delete_workflow.py | 15 ++ .../sql/0038_form3_expense_item_links.sql | 22 +++ .../sql/0039_budget_form_delete_workflow.sql | 75 ++++++++ api/src/api/v1/forms.py | 46 ++++- api/src/db/models/budget_form.py | 5 +- api/src/domain/schemas.py | 3 + api/src/repository/budget_form_repository.py | 70 ++++++-- api/src/services/budget_form_service.py | 165 ++++++++++++++++-- web/src/api/tasks.js | 9 + .../components/common/TaskCardComponent.jsx | 99 ++++++++++- web/src/components/styles/TaskCard.style.jsx | 5 +- .../AuditLogsPage/constants/auditLogs.jsx | 3 + .../AuditLogsPage/constants/configEvent.jsx | 15 ++ web/src/pages/TasksPage/TasksPage.jsx | 31 +++- 15 files changed, 540 insertions(+), 38 deletions(-) create mode 100644 api/alembic/versions/0038_form3_expense_item_links.py create mode 100644 api/alembic/versions/0039_budget_form_delete_workflow.py create mode 100644 api/alembic/versions/sql/0038_form3_expense_item_links.sql create mode 100644 api/alembic/versions/sql/0039_budget_form_delete_workflow.sql diff --git a/api/alembic/versions/0038_form3_expense_item_links.py b/api/alembic/versions/0038_form3_expense_item_links.py new file mode 100644 index 0000000..1f8e78f --- /dev/null +++ b/api/alembic/versions/0038_form3_expense_item_links.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0038" +down_revision = "0037" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0038_form3_expense_item_links.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/0039_budget_form_delete_workflow.py b/api/alembic/versions/0039_budget_form_delete_workflow.py new file mode 100644 index 0000000..fac05d4 --- /dev/null +++ b/api/alembic/versions/0039_budget_form_delete_workflow.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0039" +down_revision = "0038" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0039_budget_form_delete_workflow.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0038_form3_expense_item_links.sql b/api/alembic/versions/sql/0038_form3_expense_item_links.sql new file mode 100644 index 0000000..fba791c --- /dev/null +++ b/api/alembic/versions/sql/0038_form3_expense_item_links.sql @@ -0,0 +1,22 @@ +insert into v3.expense_item_form_type (expense_item_id, form_type_code) +values + (84, 'FORM_3'), + (85, 'FORM_3'), + (86, 'FORM_3'), + (87, 'FORM_3'), + (88, 'FORM_3'), + (89, 'FORM_3'), + (90, 'FORM_3'), + (91, 'FORM_3'), + (92, 'FORM_3'), + (93, 'FORM_3'), + (94, 'FORM_3'), + (95, 'FORM_3'), + (97, 'FORM_3'), + (98, 'FORM_3'), + (99, 'FORM_3'), + (100, 'FORM_3'), + (101, 'FORM_3'), + (83, 'FORM_3'), + (96, 'FORM_3') +on conflict (expense_item_id, form_type_code) do nothing; diff --git a/api/alembic/versions/sql/0039_budget_form_delete_workflow.sql b/api/alembic/versions/sql/0039_budget_form_delete_workflow.sql new file mode 100644 index 0000000..807ff5a --- /dev/null +++ b/api/alembic/versions/sql/0039_budget_form_delete_workflow.sql @@ -0,0 +1,75 @@ +ALTER TABLE v3.budget_form + ADD COLUMN IF NOT EXISTS to_delete boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS deleted_by integer NULL REFERENCES v3.app_user(id), + ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone NULL; + +CREATE INDEX IF NOT EXISTS ix_v3_budget_form_to_delete + ON v3.budget_form (to_delete); + +CREATE OR REPLACE FUNCTION v3.hard_delete_budget_form(p_form_id integer) +RETURNS void +LANGUAGE plpgsql +AS $function$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM v3.budget_form WHERE id = p_form_id) THEN + RAISE EXCEPTION 'budget_form id=% не существует', p_form_id; + END IF; + + DELETE FROM v3.booking + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.sequestration + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.budget_line_quarter + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.rent_detail + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.utility_detail + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.security_detail + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.contract_detail + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.contract_summary + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.plan + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.reserve + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.allocation + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.ckk + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.collegial_approval + WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id); + + DELETE FROM v3.fixed_asset_report + WHERE budget_form_id = p_form_id; + + DELETE FROM v3.form_limit + WHERE budget_form_id = p_form_id; + + DELETE FROM v3.form_phase + WHERE budget_form_id = p_form_id; + + DELETE FROM v3.budget_line + WHERE budget_form_id = p_form_id; + + DELETE FROM v3.form4_project + WHERE form_id = p_form_id; + + DELETE FROM v3.budget_form + WHERE id = p_form_id; +END; +$function$; diff --git a/api/src/api/v1/forms.py b/api/src/api/v1/forms.py index 06904fc..23b0962 100644 --- a/api/src/api/v1/forms.py +++ b/api/src/api/v1/forms.py @@ -8,8 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.services.org_unit_service import OrgUnitService from src.db.models.form_type import FormTypeEnum +from src.db.models.role import UserRoleEnum from src.services.sheet_service import SheetService -from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse +from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, ResponseBase, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse from src.db.models.app_user import AppUser from src.services.budget_form_service import BudgetFormService from src.api.v1.deps import get_current_active_user_with_set_db, require_admin @@ -80,6 +81,7 @@ async def get_forms( org_units: str | None = None, form_type: Literal["FORM_1", "FORM_2", "FORM_4"] | None = None, year: int | None = None, + delete_status: Literal["active", "marked", "all"] | None = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), ) -> BaseListResponse[BudgetFormResponse]: @@ -95,6 +97,7 @@ async def get_forms( form_type=form_type, year=year, load_org=True, + delete_status=delete_status, ) return BaseListResponse( result = [ @@ -112,12 +115,15 @@ async def get_form( ) -> BaseSingleResponse[BudgetFormResponse]: bf_service = BudgetFormService(db) form = await bf_service.get( - user=current_user, + user=current_user, budget_form_id=form_id, load_org=True, + include_to_delete=current_user.role_id == UserRoleEnum.ADMIN, ) + if not form: + raise HTTPException(404, f"Форма {form_id} не найдена") return BaseSingleResponse( - result = BudgetFormResponse.model_validate(form), + result=BudgetFormResponse.model_validate(form), ) @@ -464,6 +470,7 @@ async def add_form( form_type_code=form.form_type_code, year=form.year, org_unit_ids=org_unit_ids, + user=current_user, ) db_ms = (time.perf_counter() - t0) * 1000 @@ -485,3 +492,36 @@ async def add_form( result=[BudgetFormResponse.model_validate(new_form) for new_form in new_forms], count=len(new_forms), ) + + +@router.post("/{form_id}/mark-delete") +async def mark_form_for_deletion( + form_id: int, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(require_admin), +) -> BaseSingleResponse[BudgetFormResponse]: + bf_service = BudgetFormService(db) + form = await bf_service.mark_for_deletion(budget_form_id=form_id, user=current_user) + return BaseSingleResponse(result=BudgetFormResponse.model_validate(form)) + + +@router.post("/{form_id}/restore") +async def restore_form( + form_id: int, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(require_admin), +) -> BaseSingleResponse[BudgetFormResponse]: + bf_service = BudgetFormService(db) + form = await bf_service.restore(budget_form_id=form_id, user=current_user) + return BaseSingleResponse(result=BudgetFormResponse.model_validate(form)) + + +@router.delete("/{form_id}/hard") +async def hard_delete_form( + form_id: int, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(require_admin), +) -> ResponseBase: + bf_service = BudgetFormService(db) + await bf_service.hard_delete(budget_form_id=form_id, user=current_user) + return ResponseBase(success=True, message="Задача полностью удалена") diff --git a/api/src/db/models/budget_form.py b/api/src/db/models/budget_form.py index 7ad9a46..5a0911a 100644 --- a/api/src/db/models/budget_form.py +++ b/api/src/db/models/budget_form.py @@ -1,7 +1,7 @@ from datetime import datetime import typing -from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column, relationship from src.db.models.form_type import FormTypeEnum @@ -34,6 +34,9 @@ class BudgetForm(Base): org_unit_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit") ) + to_delete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + deleted_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id")) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) form_type: Mapped["FormType"] = relationship( "FormType", #back_populates="budget_forms" diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index bfc4ccb..c7ef75c 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -330,6 +330,9 @@ class BudgetFormResponse(BaseModel): form_type_code: FormTypeSchemaEnum year: int org_unit_id: int + to_delete: bool = False + deleted_by: int | None = None + deleted_at: datetime | None = None org_unit: OrgUnitSchema | None = None model_config = ConfigDict(from_attributes=True) diff --git a/api/src/repository/budget_form_repository.py b/api/src/repository/budget_form_repository.py index a97f3ab..13fedb6 100644 --- a/api/src/repository/budget_form_repository.py +++ b/api/src/repository/budget_form_repository.py @@ -1,4 +1,4 @@ -from sqlalchemy import func, select, text +from sqlalchemy import func, select, text, update from sqlalchemy.ext.asyncio import AsyncSession from src.db.models.form_type import FormTypeEnum @@ -6,20 +6,20 @@ from src.db.models.budget_form import BudgetForm from sqlalchemy.orm import joinedload - class BudgetFormRepository: def __init__(self, db: AsyncSession): self.db = db async def get_list( - self, - offset: int | None = None, + self, + offset: int | None = None, limit: int | None = None, org_unit: int | list[int] | None = None, with_count: bool = False, load_org: bool = False, form_type: FormTypeEnum | str | None = None, year: int | None = None, + delete_status: str = "active", ) -> list[BudgetForm] | tuple[int, list[BudgetForm]]: if with_count: query = select(func.count().over().label("total_count"), BudgetForm) @@ -38,6 +38,11 @@ class BudgetFormRepository: if year is not None: where.append(BudgetForm.year == year) + if delete_status == "marked": + where.append(BudgetForm.to_delete.is_(True)) + elif delete_status != "all": + where.append(BudgetForm.to_delete.is_(False)) + query = query.where(*where) if load_org: query = query.options( @@ -63,19 +68,20 @@ class BudgetFormRepository: return (await self.db.execute(count_query)).scalar(), [] else: return (await self.db.execute(query)).scalars().all() - + async def get( - self, + self, budget_form_id: int | None = None, org_unit: int | list[int] | None = None, form_type_code: FormTypeEnum | str | None = None, year: int | None = None, load_form_type: bool = False, load_org: bool = False, + include_to_delete: bool = False, ) -> BudgetForm | None: assert budget_form_id or all((form_type_code, year, org_unit)) query = select(BudgetForm) - + where = [] if budget_form_id is not None: where.append(BudgetForm.id == budget_form_id) @@ -85,16 +91,19 @@ class BudgetFormRepository: where.append(BudgetForm.org_unit_id == org_unit) else: where.append(BudgetForm.org_unit_id.in_(org_unit)) - + if form_type_code is not None: if isinstance(form_type_code, FormTypeEnum): where.append(BudgetForm.form_type_code == form_type_code.value) else: where.append(BudgetForm.form_type_code == form_type_code) - + if year is not None: where.append(BudgetForm.year == year) + if not include_to_delete: + where.append(BudgetForm.to_delete.is_(False)) + query = query.where(*where) if load_org: query = query.options( @@ -106,12 +115,13 @@ class BudgetFormRepository: joinedload(BudgetForm.form_type) ) return (await self.db.execute(query)).scalars().first() - + async def create( - self, + self, form_type_code: str, - year: int, + year: int, org_unit_id: int, + user_id: int | None = None, ) -> BudgetForm | None: form_id = ( await self.db.execute( @@ -134,6 +144,42 @@ class BudgetFormRepository: ).scalar_one_or_none() if form_id is None: return None + if user_id is not None: + await self.db.execute( + update(BudgetForm) + .where(BudgetForm.id == form_id) + .values(created_by=user_id, updated_by=user_id) + ) return await self.get( budget_form_id=form_id, ) + + async def mark_for_deletion(self, budget_form_id: int, user_id: int) -> None: + await self.db.execute( + text( + """ + UPDATE v3.budget_form + SET to_delete = true, deleted_by = :user_id, deleted_at = now(), updated_by = :user_id, updated_at = now() + WHERE id = :form_id + """ + ), + {"form_id": budget_form_id, "user_id": user_id}, + ) + + async def restore(self, budget_form_id: int, user_id: int) -> None: + await self.db.execute( + text( + """ + UPDATE v3.budget_form + SET to_delete = false, deleted_by = NULL, deleted_at = NULL, updated_by = :user_id, updated_at = now() + WHERE id = :form_id + """ + ), + {"form_id": budget_form_id, "user_id": user_id}, + ) + + async def hard_delete(self, budget_form_id: int) -> None: + await self.db.execute( + text("SELECT v3.hard_delete_budget_form(:form_id)"), + {"form_id": budget_form_id}, + ) diff --git a/api/src/services/budget_form_service.py b/api/src/services/budget_form_service.py index b2dee2b..f18bd57 100644 --- a/api/src/services/budget_form_service.py +++ b/api/src/services/budget_form_service.py @@ -4,12 +4,15 @@ from typing import Literal from sqlalchemy.ext.asyncio import AsyncSession -from src.repository.user_repository import UserRepository +from src.core.errors import AccessDeniedException, ValidationException from src.db.models.app_user import AppUser +from src.db.models.audit_log import AuditLog from src.db.models.budget_form import BudgetForm from src.db.models.role import UserRoleEnum from src.repository.budget_form_repository import BudgetFormRepository +from src.repository.user_repository import UserRepository +DeleteStatus = Literal["active", "marked", "all"] class BudgetFormService: @@ -28,8 +31,15 @@ class BudgetFormService: org_unit: list[int] | None = None, form_type: Literal["FORM_1", "FORM_2", "FORM_4"] | None = None, year: int | None = None, + delete_status: DeleteStatus | None = None, ) -> list[BudgetForm] | tuple[int, list[BudgetForm]]: - if user.role_id == UserRoleEnum.ADMIN: + if delete_status is not None and delete_status not in {"active", "marked", "all"}: + raise ValidationException("delete_status должен быть active, marked или all") + + is_admin = user.role_id == UserRoleEnum.ADMIN + effective_delete_status = (delete_status or "all") if is_admin else "active" + + if is_admin: return await self.bf_repo.get_list( offset=offset, limit=limit, @@ -38,6 +48,7 @@ class BudgetFormService: org_unit=org_unit, form_type=form_type, year=year, + delete_status=effective_delete_status, ) user = await self.user_repo.get( user_id=user.id, @@ -58,6 +69,7 @@ class BudgetFormService: load_org=load_org, form_type=form_type, year=year, + delete_status=effective_delete_status, ) async def get( @@ -66,12 +78,15 @@ class BudgetFormService: user: AppUser, load_form_type: bool = False, load_org: bool = False, + include_to_delete: bool = False, ) -> BudgetForm | None: - if user.role_id == UserRoleEnum.ADMIN: + is_admin = user.role_id == UserRoleEnum.ADMIN + if is_admin: return await self.bf_repo.get( budget_form_id=budget_form_id, load_form_type=load_form_type, load_org=load_org, + include_to_delete=True, ) user = await self.user_repo.get( user_id=user.id, @@ -82,30 +97,35 @@ class BudgetFormService: org_unit=[ou.id for ou in user.org_units], load_form_type=load_form_type, load_org=load_org, + include_to_delete=False, ) async def create( - self, + self, form_type_code: str, - year: int, + year: int, org_unit_id: int, + user: AppUser | None = None, ) -> BudgetForm | None: return await self.bf_repo.create( form_type_code=form_type_code, year=year, org_unit_id=org_unit_id, + user_id=user.id if user else None, ) async def create_if_not_exists( - self, + self, form_type_code: str, - year: int, + year: int, org_unit_id: int, + user: AppUser | None = None, ) -> BudgetForm | None: existing_bf = await self.bf_repo.get( form_type_code=form_type_code, year=year, org_unit=org_unit_id, + include_to_delete=True, ) if existing_bf: return existing_bf @@ -113,13 +133,15 @@ class BudgetFormService: form_type_code=form_type_code, year=year, org_unit_id=org_unit_id, + user_id=user.id if user else None, ) - + async def bulk_create( - self, + self, form_type_code: str, - year: int, + year: int, org_unit_ids: list[int], + user: AppUser | None = None, ) -> BudgetForm | None: return await asyncio.gather( *[ @@ -127,16 +149,18 @@ class BudgetFormService: form_type_code=form_type_code, year=year, org_unit_id=org_unit_id, + user=user, ) for org_unit_id in org_unit_ids - ] + ] ) - + async def bulk_create_if_not_exists( - self, + self, form_type_code: str, - year: int, + year: int, org_unit_ids: list[int], + user: AppUser | None = None, ) -> BudgetForm | None: return await asyncio.gather( *[ @@ -144,7 +168,118 @@ class BudgetFormService: form_type_code=form_type_code, year=year, org_unit_id=org_unit_id, + user=user, ) for org_unit_id in org_unit_ids - ] + ] ) + + async def mark_for_deletion(self, budget_form_id: int, user: AppUser) -> BudgetForm: + self._ensure_admin(user) + form = await self._get_admin_form(budget_form_id) + if form.to_delete: + return form + await self.bf_repo.mark_for_deletion(budget_form_id=budget_form_id, user_id=user.id) + await self.db.refresh(form) + self._add_audit_log( + event="FORM_MARKED_FOR_DELETION", + user=user, + form=form, + event_data={ + "entity_type": "form", + "entity_id": form.id, + "form_id": form.id, + "form_type_code": self._form_type_value(form.form_type_code), + "year": form.year, + "org_unit_id": form.org_unit_id, + }, + ) + return form + + async def restore(self, budget_form_id: int, user: AppUser) -> BudgetForm: + self._ensure_admin(user) + form = await self._get_admin_form(budget_form_id) + if not form.to_delete: + return form + snapshot = self._form_snapshot(form) + await self.bf_repo.restore(budget_form_id=budget_form_id, user_id=user.id) + await self.db.refresh(form) + self._add_audit_log( + event="FORM_RESTORED", + user=user, + form=form, + event_data={ + "entity_type": "form", + "entity_id": form.id, + "before": snapshot, + }, + ) + return form + + async def hard_delete(self, budget_form_id: int, user: AppUser) -> None: + self._ensure_admin(user) + form = await self._get_admin_form(budget_form_id) + if not form.to_delete: + raise ValidationException("Задача не помечена на удаление") + if form.deleted_by == user.id: + raise AccessDeniedException("Полностью удалить задачу должен другой администратор") + snapshot = self._form_snapshot(form) + self._add_audit_log( + event="FORM_HARD_DELETED", + user=user, + form=form, + event_data={ + "entity_type": "form", + "entity_id": form.id, + "before": snapshot, + }, + ) + await self.db.flush() + await self.bf_repo.hard_delete(budget_form_id=budget_form_id) + + def _ensure_admin(self, user: AppUser) -> None: + if user.role_id != UserRoleEnum.ADMIN: + raise AccessDeniedException("Недостаточно прав") + + async def _get_admin_form(self, budget_form_id: int) -> BudgetForm: + form = await self.bf_repo.get(budget_form_id=budget_form_id, include_to_delete=True, load_org=True) + if not form: + raise ValidationException("Форма не найдена") + return form + + def _add_audit_log( + self, + event: str, + user: AppUser, + form: BudgetForm, + event_data: dict, + ) -> None: + self.db.add( + AuditLog( + user_id=user.id, + org_unit_id=form.org_unit_id, + form_id=form.id, + task_id=form.id, + event=event, + event_type="FORM", + event_data=event_data, + ) + ) + + def _form_type_value(self, form_type_code) -> str: + return getattr(form_type_code, "value", str(form_type_code)) + + def _form_snapshot(self, form: BudgetForm) -> dict: + return { + "id": form.id, + "form_type_code": self._form_type_value(form.form_type_code), + "year": form.year, + "org_unit_id": form.org_unit_id, + "created_by": form.created_by, + "created_at": form.created_at.isoformat() if form.created_at else None, + "updated_by": form.updated_by, + "updated_at": form.updated_at.isoformat() if form.updated_at else None, + "to_delete": form.to_delete, + "deleted_by": form.deleted_by, + "deleted_at": form.deleted_at.isoformat() if form.deleted_at else None, + } diff --git a/web/src/api/tasks.js b/web/src/api/tasks.js index cd36193..d9b2f74 100644 --- a/web/src/api/tasks.js +++ b/web/src/api/tasks.js @@ -31,6 +31,15 @@ export const TasksApi = { responseType: 'blob', }); }, + markForDeletion(taskId) { + return api.post(`/form/${taskId}/mark-delete`).then((r) => r.data); + }, + restore(taskId) { + return api.post(`/form/${taskId}/restore`).then((r) => r.data); + }, + hardDelete(taskId) { + return api.delete(`/form/${taskId}/hard`).then((r) => r.data); + }, exportTasks(payload, params = {}) { return api.post(`export/bulk`, payload, { params, diff --git a/web/src/components/common/TaskCardComponent.jsx b/web/src/components/common/TaskCardComponent.jsx index fdbc4d3..07cde07 100644 --- a/web/src/components/common/TaskCardComponent.jsx +++ b/web/src/components/common/TaskCardComponent.jsx @@ -59,6 +59,9 @@ export const TaskCardComponent = ({ onAddForExport, onExportClick, isLastOpened = false, + isAdmin = false, + onChanged, + currentUserId = null, }) => { const navigate = useNavigate(); const location = useLocation(); @@ -67,11 +70,14 @@ export const TaskCardComponent = ({ if (isLastOpened) cardRef.current?.scrollIntoView({ block: 'nearest' }); }, [isLastOpened]); const { id, title, org_unit, form_type_code, year } = taskForm; + const canHardDelete = taskForm.to_delete && taskForm.deleted_by !== currentUserId; const [isEditModalOpen, setEditModalOpen] = useState(false); const [editedTitle, setEditedTitle] = useState(''); const [currentTypeCode, setCurrentTypeCode] = useState(FORM_TYPE_TRANSLATE[form_type_code] || ''); const [isChecked, setIsChecked] = useState(false); + const [confirmAction, setConfirmAction] = useState(null); + const [actionLoading, setActionLoading] = useState(false); useEffect(() => { setIsChecked(false); @@ -102,6 +108,52 @@ export const TaskCardComponent = ({ onExportClick?.(id, title); }; + const openConfirmAction = (event, action) => { + event.stopPropagation(); + setConfirmAction(action); + }; + + const closeConfirmAction = () => { + if (!actionLoading) setConfirmAction(null); + }; + + const runConfirmedAction = async () => { + if (!confirmAction) return; + try { + setActionLoading(true); + if (confirmAction === 'mark') { + await TasksApi.markForDeletion(id); + toast.success('Задача помечена на удаление'); + } + if (confirmAction === 'restore') { + await TasksApi.restore(id); + toast.success('Задача возвращена'); + } + if (confirmAction === 'hardDelete') { + await TasksApi.hardDelete(id); + toast.success('Задача полностью удалена'); + } + setConfirmAction(null); + onChanged?.(); + } catch (error) { + toast.error(error?.response?.data?.detail || 'Не удалось выполнить действие'); + } finally { + setActionLoading(false); + } + }; + + const actionTitle = { + mark: 'Удалить задачу', + restore: 'Вернуть задачу', + hardDelete: 'Полностью удалить задачу', + }[confirmAction]; + + const actionText = { + mark: 'Задача будет скрыта от пользователей. Админы смогут вернуть её или удалить окончательно.', + restore: 'Задача снова станет доступна пользователям.', + hardDelete: 'Будут удалены задача и все связанные данные. Это действие нельзя отменить.', + }[confirmAction]; + const updateTask = async (nextTitle) => { try { await TasksApi.editTask(id, { title: nextTitle }); @@ -147,6 +199,7 @@ export const TaskCardComponent = ({ { if (exportMode) { handleCheckboxClick(event); @@ -159,7 +212,10 @@ export const TaskCardComponent = ({ ID: {id} - {isLastOpened && Последняя открытая} + + {taskForm.to_delete && На удаление} + {isLastOpened && Последняя открытая} + {org_unit?.title || 'Подразделение не указано'} @@ -182,10 +238,47 @@ export const TaskCardComponent = ({ - {!isForm && !exportMode && } - {exportMode &&
{isChecked ? : }
} + event.stopPropagation()}> + {isAdmin && !isForm && !exportMode && !taskForm.to_delete && ( + + )} + {isAdmin && !isForm && !exportMode && taskForm.to_delete && ( + <> + + {canHardDelete && ( + + )} + + )} + {!isForm && !exportMode && !taskForm.to_delete && } + {exportMode &&
{isChecked ? : }
} +
+ + + + + + }> + {actionText} + ); }; diff --git a/web/src/components/styles/TaskCard.style.jsx b/web/src/components/styles/TaskCard.style.jsx index 7e10dd5..a5cd4dc 100644 --- a/web/src/components/styles/TaskCard.style.jsx +++ b/web/src/components/styles/TaskCard.style.jsx @@ -10,12 +10,13 @@ export const TaskCard = styled.div` justify-content: flex-start; align-items: flex-start; box-sizing: border-box; - border: 0.06rem solid var(--Stroke, rgba(0, 0, 0, 0.1)); + border: 0.06rem solid ${({ $marked }) => ($marked ? 'rgba(211, 47, 47, 0.35)' : 'var(--Stroke, rgba(0, 0, 0, 0.1))')}; border-radius: 1rem; box-shadow: 0rem 2rem 4rem -2rem rgba(0, 0, 0, 0.02), 0rem 4rem 6rem -1rem rgba(0, 0, 0, 0.02); - background: var(--White, rgba(255, 255, 255, 1)); + background: ${({ $marked }) => ($marked ? '#fff7f7' : 'var(--White, rgba(255, 255, 255, 1))')}; + opacity: ${({ $marked }) => ($marked ? 0.72 : 1)}; overflow: hidden; cursor: pointer; position: relative; diff --git a/web/src/pages/AdminPanel/AuditLogsPage/constants/auditLogs.jsx b/web/src/pages/AdminPanel/AuditLogsPage/constants/auditLogs.jsx index c8428b1..f7eeccd 100644 --- a/web/src/pages/AdminPanel/AuditLogsPage/constants/auditLogs.jsx +++ b/web/src/pages/AdminPanel/AuditLogsPage/constants/auditLogs.jsx @@ -5,6 +5,9 @@ export const DEFAULT_ACTIONS = { ACCESS_WINDOW_CHANGE: 'Изменение этапа', USER_ROLE_CHANGE: 'Изменение роли пользователя', CREATE_TASK: 'Создание задачи', + FORM_MARKED_FOR_DELETION: 'Задача отмечена на удаление', + FORM_RESTORED: 'Задача возвращена', + FORM_HARD_DELETED: 'Задача полностью удалена', // UPDATE_TASK: 'Обновление задачи', //сейчас нет события // DELETE_TASK: 'Удаление задачи', USER_CREATE: 'Создание пользователя', diff --git a/web/src/pages/AdminPanel/AuditLogsPage/constants/configEvent.jsx b/web/src/pages/AdminPanel/AuditLogsPage/constants/configEvent.jsx index 2256814..e5fe150 100644 --- a/web/src/pages/AdminPanel/AuditLogsPage/constants/configEvent.jsx +++ b/web/src/pages/AdminPanel/AuditLogsPage/constants/configEvent.jsx @@ -58,6 +58,21 @@ export const EVENT_CONFIGS = { form_type: 'Форма', ssp_list: 'Создана для СПП/РФ', }, + FORM_MARKED_FOR_DELETION: { + entity_id: 'Задача', + form_id: 'Задача', + form_type_code: 'Форма', + year: 'Год', + org_unit_id: 'ССП/РФ', + }, + FORM_RESTORED: { + entity_id: 'Задача', + before: 'Данные до возврата', + }, + FORM_HARD_DELETED: { + entity_id: 'Задача', + before: 'Удалённые данные', + }, ROW_CREATE: { sheet: 'Лист', expense_item_id: 'Статья расходов', diff --git a/web/src/pages/TasksPage/TasksPage.jsx b/web/src/pages/TasksPage/TasksPage.jsx index 28d01eb..a71cf42 100644 --- a/web/src/pages/TasksPage/TasksPage.jsx +++ b/web/src/pages/TasksPage/TasksPage.jsx @@ -40,6 +40,7 @@ export default function TasksPage() { const yearFilter = searchParams.get('year') || ''; const formTypeFilter = searchParams.get('form_type') || ''; const orgUnitsFilter = searchParams.get('org_units') || ''; + const deleteStatusFilter = searchParams.get('delete_status') || 'all'; const orgUnitIdsFilter = useMemo( () => orgUnitsFilter @@ -185,6 +186,7 @@ export default function TasksPage() { ...(yearFilter && { year: Number(yearFilter) }), ...(formTypeFilter && { form_type: formTypeFilter }), ...(orgUnitIdsFilter.length > 0 && { org_units: orgUnitIdsFilter.join(',') }), + ...(user?.role_id === ROLES_NAME_ID.admin && deleteStatusFilter && { delete_status: deleteStatusFilter }), }; const res = await TasksApi.list(params); @@ -216,7 +218,7 @@ export default function TasksPage() { if (currentRequest === requestId.current) setLoading(false); } }, - [yearFilter, formTypeFilter, orgUnitIdsFilter, setSearchParams], + [yearFilter, formTypeFilter, orgUnitIdsFilter, deleteStatusFilter, user?.role_id, setSearchParams], ); const handlePageChange = (_event, value) => { @@ -263,6 +265,7 @@ export default function TasksPage() { nextParams.delete('year'); nextParams.delete('form_type'); nextParams.delete('org_units'); + nextParams.delete('delete_status'); return nextParams; }); }; @@ -286,7 +289,18 @@ export default function TasksPage() { onLimitChange={handleLimitChange} entityName='задач' emptyMessage='Задачи не найдены' - renderItem={(task, exportProps) => } + renderItem={(task, exportProps) => ( + loadTasks(page, limit)} + {...exportProps} + /> + )} getItemId={(task) => task.id} getItemTitle={(task) => task.title || @@ -345,6 +359,19 @@ export default function TasksPage() { sx={{ width: 480, maxWidth: '100%' }} /> + {user.role_id === ROLES_NAME_ID.admin && ( + + )} + }