Merge pull request 'tech-num-fix: поправил формирование технического номера проекта, исправил шаблоны для формы 4, поправил получение стадий у проектов' (#104) from tech-num-fix into test

Reviewed-on: #104
This commit is contained in:
tsygankoviva 2026-08-19 17:34:21 +03:00
commit e4e0e64e42
6 changed files with 337 additions and 7 deletions

View File

@ -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

View File

@ -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;

View File

@ -207,7 +207,7 @@ async def get_form3_phases(
) )
if phases: if phases:
project = await project_service.get_instance(user=current_user, project_id=project_id) 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] result = [Form3PhaseResponse.model_validate(p) for p in phases]
if org_unit.utc_offset: if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset) tz = timezone_from_offset(org_unit.utc_offset)

View File

@ -3,7 +3,7 @@ import enum
from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar
from sqlalchemy.exc import MissingGreenlet 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 from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
@ -384,7 +384,6 @@ class UpdProjectBody(BaseModel):
pattern=r"^[^+\-\/\\=&*\s]{1,30}$", pattern=r"^[^+\-\/\\=&*\s]{1,30}$",
) )
status: Optional[Literal["created", "agreed", "archived"]] = None status: Optional[Literal["created", "agreed", "archived"]] = None
technical_number: Optional[str] = None
project_type: Optional[ProjectTypeLiteral] = None project_type: Optional[ProjectTypeLiteral] = None
vsp_format: Optional[VspFormatLiteral] = None vsp_format: Optional[VspFormatLiteral] = None
placement_type: Optional[PlacementTypeLiteral] = None placement_type: Optional[PlacementTypeLiteral] = None

View File

@ -31,6 +31,8 @@ class ProjectRepository:
"total_area": float(project.total_area) if project.total_area is not None else None, "total_area": float(project.total_area) if project.total_area is not None else None,
"org_unit_id": project.org_unit_id, "org_unit_id": project.org_unit_id,
"org_unit_name": org_unit_name, "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, "created_at": project.created_at.isoformat() if project.created_at else None,
"report_count": int(report_count), "report_count": int(report_count),
"years": years, "years": years,
@ -443,7 +445,6 @@ class ProjectRepository:
CAST(:project_id AS INT), CAST(:project_id AS INT),
CAST(:name AS VARCHAR), CAST(:name AS VARCHAR),
CAST(:status AS VARCHAR), CAST(:status AS VARCHAR),
CAST(:technical_number AS VARCHAR),
CAST(:project_type AS VARCHAR), CAST(:project_type AS VARCHAR),
CAST(:vsp_format AS VARCHAR), CAST(:vsp_format AS VARCHAR),
CAST(:placement_type AS VARCHAR), CAST(:placement_type AS VARCHAR),
@ -463,7 +464,6 @@ class ProjectRepository:
"project_id": project_id, "project_id": project_id,
"name": data.get("name"), "name": data.get("name"),
"status": data.get("status"), "status": data.get("status"),
"technical_number": data.get("technical_number"),
"project_type": data.get("project_type"), "project_type": data.get("project_type"),
"vsp_format": data.get("vsp_format"), "vsp_format": data.get("vsp_format"),
"placement_type": data.get("placement_type"), "placement_type": data.get("placement_type"),

View File

@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
import re
import uuid import uuid
import pytest 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): def test_upd_project_smoke(client, admin_tokens, auth_headers):
project_id, name = _create_project(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( response = client.patch(
f"/api/v1/project/{project_id}", f"/api/v1/project/{project_id}",
json={ json={
"name": f"{name}_U", "name": f"{name}_U",
"status": "agreed", "status": "agreed",
"technical_number": "2606_180801", "technical_number": "9999_999999",
"krf_decision_date": "2026-06-11", "krf_decision_date": "2026-06-11",
"fk_decision_date": "2026-06-18", "fk_decision_date": "2026-06-18",
"board_decision_date": "2026-07-02", "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 is not None
assert project["name"] == f"{name}_U" assert project["name"] == f"{name}_U"
assert project["status"] == "agreed" 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["krf_decision_date"] == "2026-06-11"
assert project["fk_decision_date"] == "2026-06-18" assert project["fk_decision_date"] == "2026-06-18"
assert project["board_decision_date"] == "2026-07-02" 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"] == "Собственность" 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): def test_upd_smeta_smoke(client, admin_tokens, auth_headers):
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026) project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026)