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 && (
+
+ )}
+
}