diff --git a/api/alembic/versions/0001_initial_schema.py b/api/alembic/versions/0001_initial_schema.py index 7b0c29b..c4561d8 100644 --- a/api/alembic/versions/0001_initial_schema.py +++ b/api/alembic/versions/0001_initial_schema.py @@ -762,6 +762,43 @@ def upgrade() -> None: ['code'], schema='v3', ) + create_table_if_not_exists( + 'form3_phase', + sa.Column('rf_project_report_id', sa.Integer(),primary_key=True), + sa.Column('phase_code', sa.String(), primary_key=True), + sa.Column('role', sa.String(), nullable=False), + sa.Column('column_keys', postgresql.ARRAY(sa.Text()), nullable=False), + sa.Column('opens_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('closes_at', sa.DateTime(timezone=True), nullable=False), + schema='v3', + ) + + create_check_constraint_if_not_exists( + 'chk_v3_form3_phase_window', 'form3_phase', + sa.text('closes_at > opens_at'), + schema='v3', + ) + create_check_constraint_if_not_exists( + 'chk_v3_form3_phase_columns', 'form3_phase', + sa.text('cardinality(column_keys) > 0'), + schema='v3', + ) + create_check_constraint_if_not_exists( + 'chk_v3_form3_phase_no_admin', 'form3_phase', + sa.text("role != 'ADMIN'"), + schema='v3', + ) + create_index_if_not_exists( + 'ix_v3_form3_phase_active', 'form3_phase', + ['rf_project_report_id', 'role', 'opens_at', 'closes_at'], + schema='v3', + ) + create_index_if_not_exists( + 'ix_v3_form3_phase_columns_gin', 'form3_phase', + ['column_keys'], + schema='v3', + postgresql_using='gin', + ) create_table_if_not_exists( 'security_detail', sa.Column('id', sa.Integer(), diff --git a/api/alembic/versions/sql/0002_functions.sql b/api/alembic/versions/sql/0002_functions.sql index 7c6326c..987eb91 100644 --- a/api/alembic/versions/sql/0002_functions.sql +++ b/api/alembic/versions/sql/0002_functions.sql @@ -7772,4 +7772,407 @@ BEGIN END; $function$ -; \ No newline at end of file +; + + + +CREATE OR REPLACE FUNCTION v3.copy_template_to_report(p_report_id INT) +RETURNS INTEGER +AS $function$ +DECLARE + v_report_type VARCHAR; + v_inserted INTEGER; +BEGIN + SELECT report_type INTO v_report_type + FROM v3.rf_project_report + WHERE id = p_report_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id; + END IF; + + INSERT INTO v3.form3_phase + (rf_project_report_id, phase_code, role, column_keys, opens_at, closes_at) + SELECT p_report_id, pt.phase_code, pt.role, + pt.column_keys, pt.opens_at, pt.closes_at + FROM v3.phase_template pt + WHERE pt.form_type = 'FORM_3' + AND pt.sheet = v_report_type + ON CONFLICT (rf_project_report_id, phase_code) DO NOTHING; + + GET DIAGNOSTICS v_inserted = ROW_COUNT; + RETURN v_inserted; +END; +$function$ +LANGUAGE plpgsql VOLATILE; + +CREATE OR REPLACE FUNCTION v3.user_in_report_org(p_user_id INT, p_report_id INT) +RETURNS BOOLEAN +AS $function$ +DECLARE + v_org INT; +BEGIN + SELECT p.org_unit_id + INTO v_org + FROM v3.rf_project_report r + JOIN v3.project p ON p.id = r.project_id + WHERE r.id = p_report_id; + + IF v_org IS NULL THEN + RETURN TRUE; -- проект без org_unit — пропускаем фильтр + END IF; + + RETURN EXISTS ( + SELECT 1 + FROM v3.user_org + WHERE user_id = p_user_id + AND org_unit_id = v_org + ); +END; +$function$ +LANGUAGE plpgsql STABLE; + +CREATE OR REPLACE FUNCTION v3.editable_columns_for3( + p_report_id INT, + p_user_id INT +) +RETURNS TABLE ( + column_key TEXT, + closes_at TIMESTAMPTZ +) +AS $function$ +DECLARE + v_role VARCHAR; +BEGIN + IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN + RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id; + END IF; + + v_role := v3.user_role_code(p_user_id); + IF v_role IS NULL THEN + RETURN; -- неизвестный/неактивный юзер → пустая маска + END IF; + + -- ADMIN: UNION всех column_keys всех фаз отчёта. + IF v_role = 'ADMIN' THEN + RETURN QUERY + SELECT ck AS column_key, + MAX(fp.closes_at) AS closes_at + FROM v3.form3_phase fp, + LATERAL unnest(fp.column_keys) AS ck + WHERE fp.rf_project_report_id = p_report_id + GROUP BY ck; + RETURN; + END IF; + + -- EXECUTOR_RF без назначения → пустая маска (default deny). + IF v_role = 'EXECUTOR_RF' AND NOT v3.user_in_report_org(p_user_id, p_report_id) THEN + RETURN; + END IF; + + RETURN QUERY + SELECT ck AS column_key, + MAX(fp.closes_at) AS closes_at + FROM v3.form3_phase fp, + LATERAL unnest(fp.column_keys) AS ck + WHERE fp.rf_project_report_id = p_report_id + AND fp.role = v_role + AND now() BETWEEN fp.opens_at AND fp.closes_at + GROUP BY ck; +END; +$function$ +LANGUAGE plpgsql STABLE; + +CREATE OR REPLACE FUNCTION v3.can_edit3( + p_report_id INT, + p_column_key TEXT, + p_user_id INT +) +RETURNS JSONB +AS $function$ +DECLARE + v_role VARCHAR; + v_active_close TIMESTAMPTZ; + v_role_has_col BOOLEAN; + v_next_open TIMESTAMPTZ; + v_last_close TIMESTAMPTZ; +BEGIN + IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN + RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id; + END IF; + + v_role := v3.user_role_code(p_user_id); + IF v_role IS NULL THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'role_not_allowed', + 'detail', 'unknown or inactive user' + ); + END IF; + + -- ADMIN — bypass всех проверок. + IF v_role = 'ADMIN' THEN + RETURN jsonb_build_object('ok', true, 'admin', true); + END IF; + + -- EXECUTOR_RF: фильтр по user_org проекта отчёта. + IF v_role = 'EXECUTOR_RF' AND NOT v3.user_in_report_org(p_user_id, p_report_id) THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'org_not_assigned', + 'detail', p_column_key + ); + END IF; + + -- 1. Активная сейчас фаза, в которой эта колонка перечислена для этой роли? + SELECT MAX(fp.closes_at) + INTO v_active_close + FROM v3.form3_phase fp + WHERE fp.rf_project_report_id = p_report_id + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + AND now() BETWEEN fp.opens_at AND fp.closes_at; + + IF v_active_close IS NOT NULL THEN + RETURN jsonb_build_object( + 'ok', true, + 'closes_at', v_active_close + ); + END IF; + + -- 2. Колонка В ПРИНЦИПЕ есть хоть в одной фазе этой роли на этом отчёте? + SELECT EXISTS ( + SELECT 1 + FROM v3.form3_phase fp + WHERE fp.rf_project_report_id = p_report_id + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + ) INTO v_role_has_col; + + IF NOT v_role_has_col THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'role_not_allowed', + 'detail', p_column_key + ); + END IF; + + -- 3. Колонка доступна, но окно не активно. Подсказать ближайшее. + SELECT MIN(fp.opens_at) + INTO v_next_open + FROM v3.form3_phase fp + WHERE fp.rf_project_report_id = p_report_id + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + AND fp.opens_at > now(); + + IF v_next_open IS NOT NULL THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'window_closed', + 'detail', p_column_key, + 'opens_at', v_next_open + ); + END IF; + + SELECT MAX(fp.closes_at) + INTO v_last_close + FROM v3.form3_phase fp + WHERE fp.rf_project_report_id = p_report_id + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + AND fp.closes_at <= now(); + + RETURN jsonb_build_object( + 'ok', false, + 'code', 'window_closed', + 'detail', p_column_key, + 'closes_at', v_last_close + ); +END; +$function$ +LANGUAGE plpgsql STABLE; + +-- ─── 3. Authz-обёртки upd_form3_cell/cells (доп. сигнатуры с p_user_id) ───── + +CREATE OR REPLACE FUNCTION v3.upd_form3_cells( + p_report_id INT, + p_changes JSONB, + p_user_id INT +) +RETURNS TABLE ( + row_type VARCHAR, + depth INT, + sort_order BIGINT, + data JSONB +) +AS $function$ +DECLARE + v_change JSONB; + v_column TEXT; + v_decision JSONB; + v_msg TEXT; +BEGIN + IF p_user_id IS NULL THEN + RAISE EXCEPTION 'role_not_allowed: '; + END IF; + + IF NOT EXISTS (SELECT 1 FROM v3.app_user WHERE id = p_user_id AND is_active) THEN + RAISE EXCEPTION 'role_not_allowed: unknown or inactive user %', p_user_id; + END IF; + + IF p_changes IS NULL OR jsonb_typeof(p_changes) <> 'array' THEN + RAISE EXCEPTION 'p_changes must be a JSONB array'; + END IF; + + FOR v_change IN SELECT * FROM jsonb_array_elements(p_changes) + LOOP + v_column := v_change->>'column'; + IF v_column IS NULL THEN + RAISE EXCEPTION 'change must have column: %', v_change; + END IF; + + v_decision := v3.can_edit3(p_report_id, v_column, p_user_id); + + IF NOT (v_decision->>'ok')::boolean THEN + IF v_decision->>'code' = 'role_not_allowed' THEN + RAISE EXCEPTION 'role_not_allowed: %', v_column; + + ELSIF v_decision->>'code' = 'org_not_assigned' THEN + RAISE EXCEPTION 'org_not_assigned: %', v_column; + + ELSIF v_decision->>'code' = 'window_closed' THEN + IF v_decision ? 'opens_at' THEN + v_msg := format('window_closed: %s (opens %s)', + v_column, v_decision->>'opens_at'); + ELSIF v_decision->>'closes_at' IS NOT NULL THEN + v_msg := format('window_closed: %s (closed %s)', + v_column, v_decision->>'closes_at'); + ELSE + v_msg := format('window_closed: %s (no phase)', v_column); + END IF; + RAISE EXCEPTION '%', v_msg; + + ELSE + RAISE EXCEPTION '%: %', v_decision->>'code', v_column; + END IF; + END IF; + END LOOP; + + RETURN QUERY + SELECT * FROM v3.upd_form3_cells(p_report_id, p_changes); +END; +$function$ +LANGUAGE plpgsql VOLATILE; + +CREATE OR REPLACE FUNCTION v3.upd_form3_cell( + p_report_id INT, + p_line_id INT, + p_column TEXT, + p_value JSONB, + p_user_id INT +) +RETURNS TABLE ( + row_type VARCHAR, + depth INT, + sort_order BIGINT, + data JSONB +) +AS $function$ + SELECT * FROM v3.upd_form3_cells( + p_report_id, + jsonb_build_array(jsonb_build_object( + 'line_id', p_line_id, + 'column', p_column, + 'value', p_value + )), + p_user_id + ); +$function$ +LANGUAGE sql VOLATILE; + +-- ─── 4. add_project — раскладка snapshot обоих отчётов ────────────────────── + +CREATE OR REPLACE FUNCTION v3.add_project( + p_name VARCHAR, + p_year INT, + p_org_unit_id INT, + p_level VARCHAR DEFAULT 'project', + p_parent_id INT DEFAULT NULL, + p_project_type VARCHAR DEFAULT NULL, + p_vsp_format VARCHAR DEFAULT NULL, + p_placement_type VARCHAR DEFAULT NULL, + p_object_address VARCHAR DEFAULT NULL, + p_staff_count INT DEFAULT NULL, + p_total_area NUMERIC DEFAULT NULL +) +RETURNS TABLE ( + project_id INT, + limit_report_id INT, + current_expenses_report_id INT +) +AS $function$ +DECLARE + v_pid INT; + v_lim INT; + v_cur INT; +BEGIN + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + IF p_year IS NULL THEN + RAISE EXCEPTION 'p_year обязателен'; + END IF; + IF p_org_unit_id IS NULL THEN + RAISE EXCEPTION 'p_org_unit_id обязателен'; + END IF; + IF NOT EXISTS (SELECT 1 FROM v3.org_unit WHERE id = p_org_unit_id) THEN + RAISE EXCEPTION 'org_unit id=% не существует', p_org_unit_id; + END IF; + IF p_parent_id IS NOT NULL AND + NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_parent_id AND level = 'program') THEN + RAISE EXCEPTION 'parent project id=% не существует или не имеет level=program', p_parent_id; + END IF; + + INSERT INTO v3.project ( + name, level, parent_id, org_unit_id, + project_type, vsp_format, placement_type, object_address, staff_count, total_area + ) VALUES ( + p_name, p_level, p_parent_id, p_org_unit_id, + p_project_type, p_vsp_format, p_placement_type, p_object_address, p_staff_count, p_total_area + ) RETURNING id INTO v_pid; + + INSERT INTO v3.rf_project_report (project_id, year, report_type) + VALUES (v_pid, p_year, 'LIMIT') RETURNING id INTO v_lim; + INSERT INTO v3.rf_project_report (project_id, year, report_type) + VALUES (v_pid, p_year, 'CURRENT_EXPENSES') RETURNING id INTO v_cur; + + -- Snapshot RBAC-фаз (иначе default deny — отчёт read-only). + PERFORM v3.copy_template_to_report(v_lim); + PERFORM v3.copy_template_to_report(v_cur); + + PERFORM v3.log_event( + 'PROJECT_CREATE', 'PROJECT_CREATE', + jsonb_strip_nulls(jsonb_build_object( + 'project_id', v_pid, + 'name', p_name, + 'year', p_year, + 'level', p_level, + 'parent_id', p_parent_id, + 'org_unit_id', p_org_unit_id, + 'project_type', p_project_type, + 'vsp_format', p_vsp_format, + 'placement_type', p_placement_type, + 'object_address', p_object_address, + 'staff_count', p_staff_count, + 'total_area', p_total_area, + 'limit_report_id', v_lim, + 'current_expenses_report_id', v_cur + )) + ); + + RETURN QUERY SELECT v_pid, v_lim, v_cur; +END; +$function$ +LANGUAGE plpgsql VOLATILE; + diff --git a/api/alembic/versions/sql/0003_initial_data.sql b/api/alembic/versions/sql/0003_initial_data.sql index 6c98e5e..bef1b34 100644 --- a/api/alembic/versions/sql/0003_initial_data.sql +++ b/api/alembic/versions/sql/0003_initial_data.sql @@ -4250,4 +4250,29 @@ INSERT INTO v3.expense_item_form_type (expense_item_id,form_type_code) VALUES INSERT INTO v3.expense_item_form_type (expense_item_id,form_type_code) VALUES (798,'FORM_3'); -REFRESH MATERIALIZED VIEW CONCURRENTLY v3.mv_expense_item_tree; \ No newline at end of file +REFRESH MATERIALIZED VIEW CONCURRENTLY v3.mv_expense_item_tree; + + +-- ─── 5. Seed фаз FORM_3 (идемпотентно, только FORM_3) ────────────────────── +-- DELETE скоупится на FORM_3 → фазы FORM_1/2/4 не затрагиваются. +DELETE FROM v3.phase_template WHERE form_type = 'FORM_3'; + +INSERT INTO v3.phase_template + (form_type, sheet, phase_code, role, column_keys, opens_at, closes_at) +SELECT 'FORM_3', sh, 'RF_FILL_2026', 'EXECUTOR_RF', + ARRAY[ + 'q1.adj_by_items','q1.adj_increase','q1.m1','q1.m2','q1.m3', + 'q2.adj_by_items','q2.adj_increase','q2.m1','q2.m2','q2.m3', + 'q3.adj_by_items','q3.adj_increase','q3.m1','q3.m2','q3.m3', + 'q4.adj_by_items','q4.adj_increase','q4.m1','q4.m2','q4.m3','q4.spod' + ], + '2026-01-01 00:00:00+00'::timestamptz, + '2026-12-31 23:59:59+00'::timestamptz +FROM (VALUES ('LIMIT'), ('CURRENT_EXPENSES')) AS s(sh); + +-- ─── 6. Backfill v3.form3_phase для существующих отчётов ──────────────────── +-- ON CONFLICT DO NOTHING внутри copy_template_to_report → повторный прогон +-- ничего не дублирует. +SELECT id, v3.copy_template_to_report(id) AS phases_inserted + FROM v3.rf_project_report + ORDER BY id; \ No newline at end of file diff --git a/api/src/api/v1/websocket.py b/api/src/api/v1/websocket.py index d23a114..662b57c 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -14,6 +14,8 @@ from sqlalchemy.exc import IntegrityError from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status from sqlalchemy.ext.asyncio import AsyncSession +from src.services.rf_project_report_service import RfProjectReportService +from src.services.rf_project_report_line_service import RfProjectReportLineService from src.repository.user_repository import UserRepository from src.services.budget_line_service import BudgetLineService from src.api.v1.deps import get_user_by_token @@ -490,7 +492,8 @@ class FormEventProcess: class ProjectEventProcess: def __init__(self, db: AsyncSession): self.project_service: ProjectService = ProjectService(db) - self.bf_service: BudgetFormService = BudgetFormService(db) + self.prl_service: RfProjectReportLineService = RfProjectReportLineService(db) + self.pr_service: RfProjectReportService = RfProjectReportService(db) self.user_service: UserService = UserService(db) async def process( @@ -573,11 +576,16 @@ class ProjectEventProcess: expense_item_id: Optional[int] = None } """ - project = await self.project_service.get(project_id=project_id, user=user) - if not project: + project_report = await self.pr_service.get( + year=year, + report_type=report_type, + project_id=project_id, + ) + if not project_report: return None - - return await self.project_service.add_form3_line( + + ids = await self.prl_service.get_ids(project_report_id=project_report.id) + result = await self.project_service.add_form3_line( project_id=project_id, year=year, report_type=report_type, @@ -585,6 +593,16 @@ class ProjectEventProcess: user=user, ) + final_result = { + "data": result, + "new_line_id": None, + } + for el in result: + if el[3]["line_id"] and el[3]["line_id"] not in ids: + final_result["new_line_id"] = el[3]["line_id"] + return final_result + return final_result + async def __del_row( self, event_data: dict, @@ -595,7 +613,7 @@ class ProjectEventProcess: ) -> list[tuple]: """ data = { - "line_id": int, + "row_id": int, } """ project = await self.project_service.get(project_id=project_id, user=user) @@ -606,7 +624,7 @@ class ProjectEventProcess: project_id=project_id, year=year, report_type=report_type, - line_id=event_data["line_id"], + line_id=event_data["row_id"], user=user, ) @@ -776,6 +794,7 @@ async def process_websocket( async with get_db_session(user_id=user_id) as db: processor = processor_cls(db) t0 = time.perf_counter() + data_old = data.copy() data["result"] = await processor.process( event_data=data, user_id=user_id, @@ -789,7 +808,7 @@ async def process_websocket( "time_ms": f"{db_ms:.2f}", "event": data.get("event"), } - extra.update(data) + extra.update(data_old) logger.info( f"db_time: {extra}", extra=extra, diff --git a/api/src/db/models/__init__.py b/api/src/db/models/__init__.py index b1130b0..d8e60fa 100644 --- a/api/src/db/models/__init__.py +++ b/api/src/db/models/__init__.py @@ -10,6 +10,7 @@ from src.db.models.collegial_approval import CollegialApproval from src.db.models.contract_detail import ContractDetail from src.db.models.contract_summary import ContractSummary from src.db.models.expense_item import ExpenseItem +from src.db.models.form3_phase import Form3Phase from src.db.models.form_limit import FormLimit from src.db.models.form_phase import FormPhase from src.db.models.form_type import FormType diff --git a/api/src/db/models/form3_phase.py b/api/src/db/models/form3_phase.py new file mode 100644 index 0000000..efccb10 --- /dev/null +++ b/api/src/db/models/form3_phase.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column + +from src.db.base import Base + + +class Form3Phase(Base): + __tablename__ = "form3_phase" + __table_args__ = ( + CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v3_form3_phase_columns"), + CheckConstraint(text("role != 'ADMIN'"), name="chk_v3_form3_phase_no_admin"), + CheckConstraint(text("closes_at > opens_at"), name="chk_v3_form3_phase_window"), + Index("ix_v3_form3_phase_active", "rf_project_report_id", "role", "opens_at", "closes_at"), + Index("ix_v3_form3_phase_columns_gin", "column_keys", postgresql_using="gin"), + {"schema": "v3"}, + ) + + rf_project_report_id: Mapped[int] = mapped_column( + Integer, ForeignKey("v3.rf_project_report.id", ondelete="CASCADE"), primary_key=True + ) + phase_code: Mapped[str] = mapped_column(String, primary_key=True) + role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code")) + column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text)) + opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index d56835e..b11133e 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -362,7 +362,7 @@ class AddProjectBody(BaseModel): max_length=30, pattern=r"^[^+\-\/\\=&*\s]{1,30}$", ) - year: int + year: int = Field(default_factory=lambda: datetime.now().year) branch_id: int level: Literal["project", "program"] = "project" parent_id: Optional[int] = None diff --git a/api/src/repository/budget_line_repository.py b/api/src/repository/budget_line_repository.py index 0ca3c71..ed113c0 100644 --- a/api/src/repository/budget_line_repository.py +++ b/api/src/repository/budget_line_repository.py @@ -12,7 +12,7 @@ class BudgetLineRepository: query = select(BudgetLine).where(BudgetLine.id == budget_line_id).limit(1) return (await self.db.execute(query)).scalar_one_or_none() - async def get_list(self, budget_line_ids: list[int]) -> BudgetLine | None: + async def get_list(self, budget_line_ids: list[int]) -> list[BudgetLine]: query = select(BudgetLine).where(BudgetLine.id.in_(budget_line_ids)) return (await self.db.execute(query)).scalars().all() diff --git a/api/src/repository/project_repository.py b/api/src/repository/project_repository.py index 1faae5e..f0492bf 100644 --- a/api/src/repository/project_repository.py +++ b/api/src/repository/project_repository.py @@ -14,7 +14,7 @@ class ProjectRepository: self.db = db @staticmethod - def _serialize_project(project: Project, org_unit_name: str | None, report_count: int) -> dict: + def _serialize_project(project: Project, org_unit_name: str | None, report_count: int, years: list[InterruptedError] | None = None) -> dict: return { "id": project.id, "name": project.name, @@ -29,6 +29,7 @@ class ProjectRepository: "org_unit_id": project.org_unit_id, "org_unit_name": org_unit_name, "report_count": int(report_count), + "years": years, } @staticmethod @@ -66,11 +67,19 @@ class ProjectRepository: .scalar_subquery() ) + report_years_subq = ( + select(RfProjectReport.year) + .where(RfProjectReport.project_id == Project.id) + .distinct() + .scalar_subquery() + ) + query = ( select( Project, OrgUnit.title.label("org_unit_name"), report_count_subq.label("report_count"), + func.array(report_years_subq).label("years"), ) .outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id) .order_by(Project.id) @@ -82,7 +91,7 @@ class ProjectRepository: query = query.limit(limit) rows = (await self.db.execute(query)).all() - payload = [self._serialize_project(row[0], row[1], row[2]) for row in rows] + payload = [self._serialize_project(row[0], row[1], row[2], row[3]) for row in rows] if not with_count: return payload @@ -101,11 +110,18 @@ class ProjectRepository: .where(RfProjectReport.project_id == Project.id) .scalar_subquery() ) + report_years_subq = ( + select(RfProjectReport.year) + .where(RfProjectReport.project_id == Project.id) + .distinct() + .scalar_subquery() + ) query = ( select( Project, OrgUnit.title.label("org_unit_name"), report_count_subq.label("report_count"), + func.array(report_years_subq).label("years"), ) .outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id) .where(Project.id == project_id) @@ -117,7 +133,7 @@ class ProjectRepository: row = (await self.db.execute(query)).first() if not row: return None - return self._serialize_project(row[0], row[1], row[2]) + return self._serialize_project(row[0], row[1], row[2], row[3]) async def get_reports(self, project_id: int) -> list[dict]: line_count_subq = ( diff --git a/api/src/repository/rf_project_report_line_repository.py b/api/src/repository/rf_project_report_line_repository.py new file mode 100644 index 0000000..3ec5abe --- /dev/null +++ b/api/src/repository/rf_project_report_line_repository.py @@ -0,0 +1,39 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.rf_project_report_line import RfProjectReportLine + + + +class RfProjectReportLineRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get(self, line_id: int) -> RfProjectReportLine | None: + query = select( + RfProjectReportLine + ).where( + RfProjectReportLine.id == line_id + ).limit(1) + return (await self.db.execute(query)).scalar_one_or_none() + + async def get_list(self, line_ids: list[int]) -> list[RfProjectReportLine]: + query = select( + RfProjectReportLine + ).where( + RfProjectReportLine.id.in_(line_ids) + ) + return (await self.db.execute(query)).scalars().all() + + async def get_ids( + self, + project_report_id: int, + ) -> list[int]: + + query = select( + RfProjectReportLine.id + ).where( + RfProjectReportLine.rf_project_report_id == project_report_id + ) + return (await self.db.execute(query)).scalars().all() + diff --git a/api/src/repository/rf_project_report_repository.py b/api/src/repository/rf_project_report_repository.py new file mode 100644 index 0000000..6337a80 --- /dev/null +++ b/api/src/repository/rf_project_report_repository.py @@ -0,0 +1,33 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.rf_project_report import RfProjectReport + + + +class RfProjectReportRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get( + self, + report_id: int | None = None, + year: int | None = None, + report_type: str | None = None, + project_id: int | None = None, + ) -> RfProjectReport | None: + assert report_id is not None or all((el is not None for el in (year, report_type, project_id))) + query = select( + RfProjectReport + ) + if report_id is not None: + query = query.where(RfProjectReport.id == report_id) + else: + query = query.where( + RfProjectReport.year == year, + RfProjectReport.report_type == report_type, + RfProjectReport.project_id == project_id + ) + + query = query.limit(1) + return (await self.db.execute(query)).scalar_one_or_none() diff --git a/api/src/services/rf_project_report_line_service.py b/api/src/services/rf_project_report_line_service.py new file mode 100644 index 0000000..e29c48d --- /dev/null +++ b/api/src/services/rf_project_report_line_service.py @@ -0,0 +1,34 @@ + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.rf_project_report_line import RfProjectReportLine +from src.repository.rf_project_report_line_repository import RfProjectReportLineRepository +from src.db.models.app_user import AppUser + + + + +class RfProjectReportLineService: + def __init__(self, db: AsyncSession): + self.db = db + self.prl_repo = RfProjectReportLineRepository(db) + + async def get( + self, + user: AppUser, + line_id: int, + ) -> RfProjectReportLine | None: + return await self.prl_repo.get(line_id=line_id) + + async def get_list( + self, + user: AppUser, + line_ids: list[int], + ) -> list[RfProjectReportLine]: + return await self.prl_repo.get_list(line_ids=line_ids) + + async def get_ids( + self, + project_report_id: int, + ) -> list[int]: + return await self.prl_repo.get_ids(project_report_id=project_report_id) diff --git a/api/src/services/rf_project_report_service.py b/api/src/services/rf_project_report_service.py new file mode 100644 index 0000000..293bf03 --- /dev/null +++ b/api/src/services/rf_project_report_service.py @@ -0,0 +1,31 @@ + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.rf_project_report import RfProjectReport +from src.repository.rf_project_report_repository import RfProjectReportRepository +from src.db.models.rf_project_report_line import RfProjectReportLine +from src.repository.rf_project_report_line_repository import RfProjectReportLineRepository +from src.db.models.app_user import AppUser + + + + +class RfProjectReportService: + def __init__(self, db: AsyncSession): + self.db = db + self.pr_repo = RfProjectReportRepository(db) + + async def get( + self, + user: AppUser| None = None, + report_id: int | None = None, + year: int | None = None, + report_type: str | None = None, + project_id: int | None = None, + ) -> RfProjectReport | None: + return await self.pr_repo.get( + report_id=report_id, + year=year, + report_type=report_type, + project_id=project_id, + ) diff --git a/api/tests/sqls/fixture.sql b/api/tests/sqls/fixture.sql index c587bf6..8e6ac01 100644 --- a/api/tests/sqls/fixture.sql +++ b/api/tests/sqls/fixture.sql @@ -26,7 +26,8 @@ SELECT setval('v3.budget_line_id_seq', 1); INSERT INTO v3.form_phase (budget_form_id,sheet,phase_code,"role",column_keys,opens_at,closes_at) VALUES (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03'); -INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_type,staff_count,total_area,closed_at,is_active,updated_at,is_deleted,created_at,created_by,system_code,updated_by,vsp_type,notes,location_form,rent_contract_num,rent_end_date) VALUES - (1,2,'Тестовый всп 1','Тестовая 12','укукк','2026-05-07','ывс',2026,230,'2026-06-16',false,'2026-06-18 15:46:38.611903',false,'2026-06-04 14:36:59.737727',1,'1233',91,'','','встроенное помещение','',NULL), - (2,2,'Test12 -----','Тестовая 125','формат 15 ','2026-06-08',NULL,NULL,58,NULL,true,'2026-06-16 18:53:17.986651',false,'2026-06-09 09:04:33.29858',91,'123-1234',91,'субаренда','Работает ','аренда','12-45','2026-10-02'); -SELECT setval('v3.vsp_id_seq', 2); +INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_type,staff_count,total_area,closed_at,is_active,updated_at,is_deleted,created_at,created_by,system_code,updated_by,vsp_type,notes,rent_contract_num,rent_end_date) VALUES + (1,2,'Тестовый всп 1','Тестовая 12','укукк','2026-05-07','ывс',2026,230,'2026-06-16',false,'2026-06-18 15:46:38.611903',false,'2026-06-04 14:36:59.737727',1,'1233',91,'','','',NULL), + (2,2,'Test12 -----','Тестовая 125','формат 15 ','2026-06-08',NULL,NULL,58,NULL,true,'2026-06-16 18:53:17.986651',false,'2026-06-09 09:04:33.29858',91,'123-1234',91,'субаренда','Работает ','12-45','2026-10-02'), + (3,2,'3434','победы 12','формат','2026-06-01','субаренда', NULL,34,NULL,true,'2026-07-01 13:32:35.327097',false,'2026-06-29 15:53:50.236062',91,'3434',NULL,'2','примечание','567','2026-07-01'); +SELECT setval('v3.vsp_id_seq', 3);