diff --git a/api/alembic/versions/0018_technical_number.py b/api/alembic/versions/0018_technical_number.py new file mode 100644 index 0000000..51563be --- /dev/null +++ b/api/alembic/versions/0018_technical_number.py @@ -0,0 +1,82 @@ +import os +import re + +from alembic import op + + +revision = "0018" +down_revision = "0017" +branch_labels = None +depends_on = None + + +_DOLLAR_TAG_RE = re.compile(r"\$\w+\$") + + +def _find_dollar_tag(line: str) -> str | None: + m = _DOLLAR_TAG_RE.search(line.strip()) + return m.group(0) if m else None + + +def _split_statements(sql: str) -> list[str]: + statements: list[str] = [] + current: list[str] = [] + in_dollar = False + dollar_tag: str | None = None + + for line in sql.split("\n"): + stripped = line.strip() + if stripped.startswith("--"): + continue + + if not in_dollar: + tag = _find_dollar_tag(stripped) + if tag and tag.endswith("$") and tag.startswith("$"): + dollar_tag = tag + in_dollar = True + current.append(line) + continue + + if in_dollar and dollar_tag and stripped.startswith(dollar_tag): + after = stripped[len(dollar_tag):].strip() + if after == ";" or after == "": + in_dollar = False + dollar_tag = None + if after == ";": + current.append(line) + statements.append("\n".join(current)) + current = [] + continue + + if not in_dollar and stripped.rstrip().endswith(";"): + current.append(line) + statements.append("\n".join(current)) + current = [] + continue + + current.append(line) + + remaining = "\n".join(current).strip() + if remaining: + statements.append(remaining) + + return statements + + +def upgrade() -> None: + ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0018_technical_number.sql") + with open(ddl_path) as f: + content = f.read() + + statements = _split_statements(content) + for stmt in statements: + stripped = stmt.strip().rstrip(";").strip() + if not stripped: + continue + if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")): + continue + op.execute(stripped) + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0018_technical_number.sql b/api/alembic/versions/sql/0018_technical_number.sql new file mode 100644 index 0000000..0f60708 --- /dev/null +++ b/api/alembic/versions/sql/0018_technical_number.sql @@ -0,0 +1,232 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- МИГРАЦИЯ: Автогенерация technical_number + запрет ручного редактирования +-- Формат: YYMM_<код РФ/ВСП 4 цифры><порядковый счётчик 2 цифры> +-- Пример: 2606_180801 (26 — год, 06 — месяц, 1808 — org_unit id, 01 — счётчик) +-- ════════════════════════════════════════════════════════════════════════════ + +-- Уникальность технического номера проекта +CREATE UNIQUE INDEX IF NOT EXISTS uq_v3_project_technical_number + ON v3.project (technical_number); + +-- add_project: генерируем technical_number автоматически при создании проекта +CREATE OR REPLACE FUNCTION v3.add_project(p_name character varying, p_year integer, p_org_unit_id integer, p_level character varying DEFAULT 'project'::character varying, p_parent_id integer DEFAULT NULL::integer, p_project_type character varying DEFAULT NULL::character varying, p_vsp_format character varying DEFAULT NULL::character varying, p_placement_type character varying DEFAULT NULL::character varying, p_object_address character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_system_code character varying DEFAULT NULL::character varying) + RETURNS TABLE(project_id integer, limit_report_id integer, current_expenses_report_id integer) + LANGUAGE plpgsql +AS $function$ +DECLARE + v_pid INT; + v_lim INT; + v_cur INT; + v_seq INT; + v_technical_number VARCHAR; +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 ou WHERE id = p_org_unit_id AND ou.is_active) 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; + + -- Порядковый счётчик проекта для данного РФ/ВСП (org_unit) + SELECT COALESCE(MAX(right(technical_number, 2)::int), 0) + 1 + INTO v_seq + FROM v3.project + WHERE org_unit_id = p_org_unit_id + AND technical_number IS NOT NULL; + + v_technical_number := to_char(now(), 'YYMM') || '_' + || lpad(p_org_unit_id::text, 4, '0') + || lpad(v_seq::text, 2, '0'); + + INSERT INTO v3.project ( + name, level, parent_id, org_unit_id, + project_type, vsp_format, placement_type, object_address, staff_count, total_area, + system_code, technical_number + ) 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, + p_system_code, v_technical_number + ) RETURNING id INTO v_pid; + + -- Первый год: 2 отчёта (LIMIT + CURRENT_EXPENSES) + snapshot фаз + сетка статей. + SELECT y.limit_report_id, y.current_expenses_report_id + INTO v_lim, v_cur + FROM v3.add_project_year(v_pid, p_year, false) y; + + -- Аудит: PROJECT_CREATE — все заполненные поля + id отчётов + PERFORM v3.log_event( + 'PROJECT_CREATE', 'PROJECT', + 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, + 'system_code', p_system_code, + 'technical_number', v_technical_number, + '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$ +; + +-- upd_project: убираем параметр technical_number (редактировать больше нельзя) +DROP FUNCTION IF EXISTS v3.upd_project(integer, varchar, varchar, varchar, varchar, varchar, varchar, varchar, integer, numeric, integer, date, date, date, date, varchar); + +CREATE OR REPLACE FUNCTION v3.upd_project( + p_project_id integer, + p_name character varying DEFAULT NULL, + p_status character varying DEFAULT NULL, + p_project_type character varying DEFAULT NULL, + p_vsp_format character varying DEFAULT NULL, + p_placement_type character varying DEFAULT NULL, + p_object_address character varying DEFAULT NULL, + p_staff_count integer DEFAULT NULL, + p_total_area numeric DEFAULT NULL, + p_org_unit_id integer DEFAULT NULL, + p_krf_decision_date date DEFAULT NULL, + p_fk_decision_date date DEFAULT NULL, + p_board_decision_date date DEFAULT NULL, + p_open_relocate_close_date date DEFAULT NULL, + p_funding_by_ko_decision character varying DEFAULT NULL +) + RETURNS void + LANGUAGE plpgsql +AS $function$ +DECLARE + v_old v3.project%ROWTYPE; + v_changes JSONB := '{}'::jsonb; +BEGIN + SELECT * INTO v_old FROM v3.project WHERE id = p_project_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'project id=% не существует', p_project_id; + END IF; + + IF p_name IS NOT NULL AND p_name IS DISTINCT FROM v_old.name THEN + UPDATE v3.project SET name = p_name WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('name', + jsonb_build_object('before', v3._diff_val(v_old.name), 'after', v3._diff_val(p_name))); + END IF; + + IF p_status IS NOT NULL AND p_status IS DISTINCT FROM v_old.status THEN + UPDATE v3.project SET status = p_status WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('status', + jsonb_build_object('before', v3._diff_val(v_old.status), 'after', v3._diff_val(p_status))); + END IF; + + IF p_project_type IS NOT NULL AND p_project_type IS DISTINCT FROM v_old.project_type THEN + UPDATE v3.project SET project_type = p_project_type WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('project_type', + jsonb_build_object('before', v3._diff_val(v_old.project_type), 'after', v3._diff_val(p_project_type))); + END IF; + + IF p_vsp_format IS NOT NULL AND p_vsp_format IS DISTINCT FROM v_old.vsp_format THEN + UPDATE v3.project SET vsp_format = p_vsp_format WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('vsp_format', + jsonb_build_object('before', v3._diff_val(v_old.vsp_format), 'after', v3._diff_val(p_vsp_format))); + END IF; + + IF p_placement_type IS NOT NULL AND p_placement_type IS DISTINCT FROM v_old.placement_type THEN + UPDATE v3.project SET placement_type = p_placement_type WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('placement_type', + jsonb_build_object('before', v3._diff_val(v_old.placement_type), 'after', v3._diff_val(p_placement_type))); + END IF; + + IF p_object_address IS NOT NULL AND p_object_address IS DISTINCT FROM v_old.object_address THEN + UPDATE v3.project SET object_address = p_object_address WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('object_address', + jsonb_build_object('before', v3._diff_val(v_old.object_address), 'after', v3._diff_val(p_object_address))); + END IF; + + IF p_staff_count IS NOT NULL AND p_staff_count IS DISTINCT FROM v_old.staff_count THEN + UPDATE v3.project SET staff_count = p_staff_count WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('staff_count', + jsonb_build_object('before', v3._diff_val(v_old.staff_count), 'after', v3._diff_val(p_staff_count))); + END IF; + + IF p_total_area IS NOT NULL AND p_total_area IS DISTINCT FROM v_old.total_area THEN + UPDATE v3.project SET total_area = p_total_area WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('total_area', + jsonb_build_object('before', v3._diff_val(v_old.total_area), 'after', v3._diff_val(p_total_area))); + END IF; + + IF p_org_unit_id IS NOT NULL AND p_org_unit_id IS DISTINCT FROM v_old.org_unit_id THEN + 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; + UPDATE v3.project SET org_unit_id = p_org_unit_id WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('org_unit_id', + jsonb_build_object('before', v3._diff_val(v_old.org_unit_id), 'after', v3._diff_val(p_org_unit_id))); + END IF; + + IF p_krf_decision_date IS NOT NULL AND p_krf_decision_date IS DISTINCT FROM v_old.krf_decision_date THEN + UPDATE v3.project SET krf_decision_date = p_krf_decision_date WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('krf_decision_date', + jsonb_build_object('before', v3._diff_val(v_old.krf_decision_date), 'after', v3._diff_val(p_krf_decision_date))); + END IF; + + IF p_fk_decision_date IS NOT NULL AND p_fk_decision_date IS DISTINCT FROM v_old.fk_decision_date THEN + UPDATE v3.project SET fk_decision_date = p_fk_decision_date WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('fk_decision_date', + jsonb_build_object('before', v3._diff_val(v_old.fk_decision_date), 'after', v3._diff_val(p_fk_decision_date))); + END IF; + + IF p_board_decision_date IS NOT NULL AND p_board_decision_date IS DISTINCT FROM v_old.board_decision_date THEN + UPDATE v3.project SET board_decision_date = p_board_decision_date WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('board_decision_date', + jsonb_build_object('before', v3._diff_val(v_old.board_decision_date), 'after', v3._diff_val(p_board_decision_date))); + END IF; + + IF p_open_relocate_close_date IS NOT NULL AND p_open_relocate_close_date IS DISTINCT FROM v_old.open_relocate_close_date THEN + UPDATE v3.project SET open_relocate_close_date = p_open_relocate_close_date WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('open_relocate_close_date', + jsonb_build_object('before', v3._diff_val(v_old.open_relocate_close_date), 'after', v3._diff_val(p_open_relocate_close_date))); + END IF; + + IF p_funding_by_ko_decision IS NOT NULL AND p_funding_by_ko_decision IS DISTINCT FROM v_old.funding_by_ko_decision THEN + UPDATE v3.project SET funding_by_ko_decision = p_funding_by_ko_decision WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('funding_by_ko_decision', + jsonb_build_object('before', v3._diff_val(v_old.funding_by_ko_decision), 'after', v3._diff_val(p_funding_by_ko_decision))); + END IF; + + IF v_changes <> '{}'::jsonb THEN + PERFORM v3.log_event( + 'PROJECT_UPDATE', 'PROJECT', + jsonb_build_object( + 'project_id', p_project_id, + 'entity_id', p_project_id, + 'changes', v_changes + ), null, null, COALESCE(p_org_unit_id, v_old.org_unit_id) + ); + END IF; +END; +$function$ +; + + +UPDATE v3.phase_template +SET column_keys = '{plan.q1,plan.q2,plan.q3,plan.q4,plan.year,plan.comment,contract_summary.total,contract_summary.reference,contract_summary.counterparty,contract_summary.deadline,contract_summary.comment,contract_summary.future_y1,contract_summary.future_y2,contract_summary.other_ssp,contract_summary.centralized_flag,allocation.contract_ref,allocation.allocation_purpose,seq_dfip.q1,seq_dfip.q2,seq_dfip.q3,seq_dfip.q4,seq_dfip.year,seq_dfip.justification,reserve.q1,reserve.q2,reserve.q3,reserve.q4,reserve.year,reserve.justification,approved.q1,approved.q2,approved.q3,approved.q4,approved.year,collegial.approved,collegial.protocol,collegial.note,ckk.ceiling,ckk.q1,ckk.q2,ckk.q3,ckk.q4,ckk.rf_schedule,ckk.deadline,ckk.proc_plan,ckk.proc_method,ckk.comment,contract.counterparty,contract.reference,contract.addenda,contract.subject,contract.currency,contract.ceiling,contract.q1,contract.q2,contract.q3,contract.q4,contract.rf_schedule,contract.vat_rate,contract.exchange_rate,contract.amount_foreign,contract.deadline,contract.scheme,contract.act,contract.comment,q1.adj_current,q1.adj_ssp,q1.adj_rf,q1.adj_reserve,q1.adj_comment,q1.corrected_plan,q1.pay_date,q1.pay_amount,q1.pay_ho,q1.pay_rf,q1.pay_comment,q1.pay_act,q1.booking,q1.actual_m1,q1.actual_m2,q1.actual_m3,q1.actual_quarter,q1.residual_after_booking,q1.residual_after_actual,q1.transfer_q2,q1.transfer_q3,q1.transfer_q4,q1.transfer_econ,q1.total,q2.target_change,q2.base_correction,q2.revision_inc,q2.revision_seq,q2.revision_comment,q2.new_plan,q2.adj_current,q2.adj_ssp,q2.adj_rf,q2.adj_reserve,q2.corrected_plan,q2.pay_date,q2.pay_amount,q2.pay_ho,q2.pay_rf,q2.pay_comment,q2.pay_act,q2.booking,q2.actual_m1,q2.actual_m2,q2.actual_m3,q2.actual_quarter,q2.residual_after_booking,q2.residual_after_actual,q2.transfer_q3,q2.transfer_q4,q2.transfer_econ,q2.total,q3.target_change,q3.base_correction,q3.revision_inc,q3.revision_seq,q3.revision_comment,q3.new_plan,q3.adj_current,q3.adj_ssp,q3.adj_rf,q3.adj_reserve,q3.corrected_plan,q3.pay_date,q3.pay_amount,q3.pay_ho,q3.pay_rf,q3.pay_comment,q3.pay_act,q3.booking,q3.actual_m1,q3.actual_m2,q3.actual_m3,q3.actual_quarter,q3.residual_after_booking,q3.residual_after_actual,q3.transfer_q4,q3.transfer_econ,q3.total,q4.target_change,q4.base_correction,q4.revision_inc,q4.revision_seq,q4.revision_comment,q4.new_plan,q4.adj_current,q4.adj_ssp,q4.adj_rf,q4.adj_reserve,q4.corrected_plan,q4.pay_date,q4.pay_amount,q4.pay_ho,q4.pay_rf,q4.pay_comment,q4.pay_act,q4.booking,q4.actual_m1,q4.actual_m2,q4.actual_m3,q4.actual_quarter,q4.actual_spod,q4.residual_after_booking,q4.residual_after_actual,q4.transfer_econ,q4.total,totals.fact_year}' +WHERE form_type = 'FORM_4'; + +ALTER TABLE v3.form_phase DROP CONSTRAINT chk_v4_form_phase_no_admin; \ No newline at end of file diff --git a/api/src/api/v1/form_phases.py b/api/src/api/v1/form_phases.py index 0ca725f..f43048e 100644 --- a/api/src/api/v1/form_phases.py +++ b/api/src/api/v1/form_phases.py @@ -207,7 +207,7 @@ async def get_form3_phases( ) if phases: project = await project_service.get_instance(user=current_user, project_id=project_id) - org_unit = await org_unit_service.get(user=current_user, org_unit_id=project.org_unit_id) + org_unit = await org_unit_service.get(org_unit_id=project.org_unit_id) result = [Form3PhaseResponse.model_validate(p) for p in phases] if org_unit.utc_offset: tz = timezone_from_offset(org_unit.utc_offset) diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 701309c..3de8c4a 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -3,7 +3,7 @@ import enum from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar from sqlalchemy.exc import MissingGreenlet -FormPhaseRole = Literal["DFIP", "EXECUTOR_RF"] +FormPhaseRole = Literal["DFIP", "EXECUTOR_RF", "ADMIN"] from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator @@ -384,7 +384,6 @@ class UpdProjectBody(BaseModel): pattern=r"^[^+\-\/\\=&*\s]{1,30}$", ) status: Optional[Literal["created", "agreed", "archived"]] = None - technical_number: Optional[str] = None project_type: Optional[ProjectTypeLiteral] = None vsp_format: Optional[VspFormatLiteral] = None placement_type: Optional[PlacementTypeLiteral] = None diff --git a/api/src/repository/project_repository.py b/api/src/repository/project_repository.py index dc46872..8b0a865 100644 --- a/api/src/repository/project_repository.py +++ b/api/src/repository/project_repository.py @@ -31,6 +31,8 @@ class ProjectRepository: "total_area": float(project.total_area) if project.total_area is not None else None, "org_unit_id": project.org_unit_id, "org_unit_name": org_unit_name, + "status": project.status, + "technical_number": project.technical_number, "created_at": project.created_at.isoformat() if project.created_at else None, "report_count": int(report_count), "years": years, @@ -443,7 +445,6 @@ class ProjectRepository: CAST(:project_id AS INT), CAST(:name AS VARCHAR), CAST(:status AS VARCHAR), - CAST(:technical_number AS VARCHAR), CAST(:project_type AS VARCHAR), CAST(:vsp_format AS VARCHAR), CAST(:placement_type AS VARCHAR), @@ -463,7 +464,6 @@ class ProjectRepository: "project_id": project_id, "name": data.get("name"), "status": data.get("status"), - "technical_number": data.get("technical_number"), "project_type": data.get("project_type"), "vsp_format": data.get("vsp_format"), "placement_type": data.get("placement_type"), diff --git a/api/tests/integration/test_projects_api_smoke.py b/api/tests/integration/test_projects_api_smoke.py index 7114a6d..3a0d356 100644 --- a/api/tests/integration/test_projects_api_smoke.py +++ b/api/tests/integration/test_projects_api_smoke.py @@ -1,4 +1,5 @@ from datetime import datetime +import re import uuid import pytest @@ -262,12 +263,17 @@ def _get_project_with_reports(client, auth_headers, admin_tokens, project_id): def test_upd_project_smoke(client, admin_tokens, auth_headers): project_id, name = _create_project(client, admin_tokens, auth_headers) + before = _get_project_with_reports(client, auth_headers, admin_tokens, project_id) + assert before is not None + assert before["technical_number"] + technical_number_before = before["technical_number"] + response = client.patch( f"/api/v1/project/{project_id}", json={ "name": f"{name}_U", "status": "agreed", - "technical_number": "2606_180801", + "technical_number": "9999_999999", "krf_decision_date": "2026-06-11", "fk_decision_date": "2026-06-18", "board_decision_date": "2026-07-02", @@ -283,7 +289,9 @@ def test_upd_project_smoke(client, admin_tokens, auth_headers): assert project is not None assert project["name"] == f"{name}_U" assert project["status"] == "agreed" - assert project["technical_number"] == "2606_180801" + # technical_number генерируется автоматически и не редактируется + assert project["technical_number"] == technical_number_before + assert re.fullmatch(r"\d{4}_\d{6}", project["technical_number"]) assert project["krf_decision_date"] == "2026-06-11" assert project["fk_decision_date"] == "2026-06-18" assert project["board_decision_date"] == "2026-07-02" @@ -293,6 +301,15 @@ def test_upd_project_smoke(client, admin_tokens, auth_headers): assert project["placement_type"] == "Собственность" +def test_create_project_auto_generates_technical_number(client, admin_tokens, auth_headers): + project_id, _ = _create_project(client, admin_tokens, auth_headers) + + project = _get_project_with_reports(client, auth_headers, admin_tokens, project_id) + assert project is not None + assert project["technical_number"] + assert re.fullmatch(r"\d{4}_\d{6}", project["technical_number"]) + + def test_upd_smeta_smoke(client, admin_tokens, auth_headers): project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026)