From d4c11243b9c6b8931edaff7a371a4d7a57e55e59 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Thu, 13 Aug 2026 13:45:56 +0300 Subject: [PATCH] =?UTF-8?q?projects-handlers:=20=D1=80=D1=83=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D1=81=D0=BE=20=D1=81=D0=BC=D0=B5=D1=82=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D1=85=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/alembic/versions/0016_project_fields.py | 82 ++ .../versions/sql/0016_project_fields.sql | 940 ++++++++++++++++++ api/src/api/v1/form_phases.py | 1 + api/src/api/v1/projects.py | 45 +- api/src/db/models/__init__.py | 2 + api/src/db/models/project.py | 19 +- api/src/db/models/rf_project_report.py | 11 +- api/src/db/models/rf_project_year.py | 22 + api/src/db/models/smeta.py | 29 + api/src/domain/schemas.py | 53 +- api/src/repository/project_repository.py | 235 ++++- .../rf_project_report_repository.py | 14 +- api/src/services/project_service.py | 48 +- api/src/services/rf_project_report_service.py | 2 + .../integration/test_form_phases_api_smoke.py | 8 + .../integration/test_projects_api_smoke.py | 91 +- api/tests/sqls/fixture.sql | 18 +- 17 files changed, 1574 insertions(+), 46 deletions(-) create mode 100644 api/alembic/versions/0016_project_fields.py create mode 100644 api/alembic/versions/sql/0016_project_fields.sql create mode 100644 api/src/db/models/rf_project_year.py create mode 100644 api/src/db/models/smeta.py diff --git a/api/alembic/versions/0016_project_fields.py b/api/alembic/versions/0016_project_fields.py new file mode 100644 index 0000000..df8d9f1 --- /dev/null +++ b/api/alembic/versions/0016_project_fields.py @@ -0,0 +1,82 @@ +import os +import re + +from alembic import op + + +revision = "0016" +down_revision = "0015" +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", "0016_project_fields.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/0016_project_fields.sql b/api/alembic/versions/sql/0016_project_fields.sql new file mode 100644 index 0000000..2139f48 --- /dev/null +++ b/api/alembic/versions/sql/0016_project_fields.sql @@ -0,0 +1,940 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- МИГРАЦИЯ: Поля project + реструктуризация rf_project_report +-- (year -> rf_project_year, атрибуты сметы -> smeta) +-- ════════════════════════════════════════════════════════════════════════════ + +-- Поля project +ALTER TABLE v3.project ADD COLUMN status VARCHAR DEFAULT 'created'; +ALTER TABLE v3.project ADD COLUMN technical_number VARCHAR; +ALTER TABLE v3.project ADD COLUMN krf_decision_date DATE; +ALTER TABLE v3.project ADD COLUMN fk_decision_date DATE; +ALTER TABLE v3.project ADD COLUMN board_decision_date DATE; +ALTER TABLE v3.project ADD COLUMN open_relocate_close_date DATE; +ALTER TABLE v3.project ADD COLUMN funding_by_ko_decision VARCHAR; + +ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS project_status_check; +ALTER TABLE v3.project ADD CONSTRAINT project_status_check + CHECK (status IS NULL OR status IN ('created', 'agreed', 'archived')); + +ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS project_funding_ko_check; +ALTER TABLE v3.project ADD CONSTRAINT project_funding_ko_check + CHECK (funding_by_ko_decision IS NULL OR funding_by_ko_decision IN ('prrs_budget', 'bank_reserve')); + +-- rf_project_year: год проекта как отдельная сущность +CREATE TABLE v3.rf_project_year ( + id SERIAL PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES v3.project(id), + year INTEGER NOT NULL, + CONSTRAINT uq_v3_rf_project_year UNIQUE (project_id, year) +); +CREATE INDEX ix_v3_rf_project_year_project ON v3.rf_project_year(project_id); + +-- Перенос существующих year из rf_project_report в rf_project_year +INSERT INTO v3.rf_project_year (project_id, year) +SELECT DISTINCT project_id, year FROM v3.rf_project_report; + +-- Связь rf_project_report с rf_project_year +ALTER TABLE v3.rf_project_report ADD COLUMN rf_project_year_id INTEGER; + +UPDATE v3.rf_project_report rpr +SET rf_project_year_id = rpy.id +FROM v3.rf_project_year rpy +WHERE rpy.project_id = rpr.project_id AND rpy.year = rpr.year; + +ALTER TABLE v3.rf_project_report ALTER COLUMN rf_project_year_id SET NOT NULL; +ALTER TABLE v3.rf_project_report + ADD CONSTRAINT fk_v3_rf_project_report_year + FOREIGN KEY (rf_project_year_id) REFERENCES v3.rf_project_year(id); + +-- Убираем year из rf_project_report +ALTER TABLE v3.rf_project_report DROP CONSTRAINT IF EXISTS uq_v3_rf_project_report; +DROP INDEX IF EXISTS v3.ix_v3_rf_project_report_proj_year; +ALTER TABLE v3.rf_project_report DROP COLUMN year; + +-- Восстанавливаем уникальность и индекс на новой связке +ALTER TABLE v3.rf_project_report + ADD CONSTRAINT uq_v3_rf_project_report UNIQUE (project_id, rf_project_year_id, report_type); +CREATE INDEX ix_v3_rf_project_report_proj_year ON v3.rf_project_report(project_id, rf_project_year_id); + +-- smeta: атрибуты сметы на год проекта +CREATE TABLE v3.smeta ( + id SERIAL PRIMARY KEY, + rf_project_year_id INTEGER NOT NULL REFERENCES v3.rf_project_year(id), + is_in_plan BOOLEAN, + is_in_plan_q2 BOOLEAN, + is_in_plan_q3 BOOLEAN, + is_in_plan_q4 BOOLEAN, + development_block VARCHAR, + reserve_to_prrs_ahr NUMERIC, + reserve_to_prrs_kv NUMERIC, + CONSTRAINT smeta_development_block_check + CHECK (development_block IS NULL OR development_block IN ('out_of_base_plan', 'carryover', 'additional_funding')) +); + + +-- Funcs +CREATE OR REPLACE FUNCTION v3.add_form3_phase(p_rf_project_report_id integer, p_phase_code character varying, p_role character varying, p_column_keys text[], p_opens_at timestamp without time zone, p_closes_at timestamp without time zone) + RETURNS v3.form3_phase + LANGUAGE plpgsql +AS $function$ + DECLARE + v_phase v3.form3_phase; + v_org_unit_id INT; + v_org_unit_title VARCHAR; + v_year INT; + v_task_name TEXT; + v_phase_id TEXT; + v_utc_offset VARCHAR(3); + v_report_type VARCHAR; + v_project_id INT; + BEGIN + SELECT p.org_unit_id, ou.title, rpry.year, ou.utc_offset, rpr.report_type, rpr.project_id + INTO v_org_unit_id, v_org_unit_title, v_year, v_utc_offset, v_report_type, v_project_id + FROM v3.rf_project_report rpr + JOIN v3.rf_project_year rpry ON rpr.rf_project_year_id = rpry.id + LEFT JOIN v3.project p ON p.id = rpr.project_id + LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id + WHERE rpr.id = p_rf_project_report_id; + + INSERT INTO v3.form3_phase + (rf_project_report_id, phase_code, role, column_keys, opens_at, closes_at) + VALUES ( + p_rf_project_report_id, + p_phase_code, + p_role, + p_column_keys, + CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END, + CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END + + ) + RETURNING * INTO v_phase; + + + v_task_name := trim(concat( + 'FORM_3', + CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END, + CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END + )); + v_phase_id := format('%s:%s', p_rf_project_report_id, p_phase_code); + + PERFORM v3.log_event( + 'ACCESS_WINDOW_CHANGE', 'ACCESS', + jsonb_build_object( + 'entity_type', 'project', + 'entity_id', v_project_id, + 'core_entity_type', 'form3_phase', + 'core_entity_id', v_phase_id, + 'action', 'create', + 'phase_id', v_phase_id, + 'task_name', NULLIF(v_task_name, ''), + 'report_id', p_rf_project_report_id, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'year', v_year, + 'project_id', v_project_id, + 'report_type', v_report_type, + 'phase_code', p_phase_code, + 'role', v_phase.role, + 'column_keys', v_phase.column_keys, + 'opens_at', v_phase.opens_at, + 'closes_at', v_phase.closes_at + ), + null, null, v_org_unit_id + ); + + RETURN v_phase; + END; + $function$ +; + + +-- DROP FUNCTION v3.add_project_year(int4, int4, bool); + +CREATE OR REPLACE FUNCTION v3.add_project_year(p_project_id integer, p_year integer, p_emit_audit boolean DEFAULT true) + RETURNS TABLE(limit_report_id integer, current_expenses_report_id integer) + LANGUAGE plpgsql +AS $function$ +DECLARE + v_ryid INT; + v_lim INT; + v_cur INT; +BEGIN + IF p_year IS NULL THEN + RAISE EXCEPTION 'p_year обязателен'; + END IF; + IF NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_project_id) THEN + RAISE EXCEPTION 'project id=% не существует', p_project_id; + END IF; + IF EXISTS (SELECT 1 FROM v3.rf_project_year + WHERE project_id = p_project_id AND year = p_year) THEN + RAISE EXCEPTION 'год % у проекта id=% уже заведён', p_year, p_project_id; + END IF; + + INSERT INTO v3.rf_project_year (project_id, year) + VALUES (p_project_id, p_year) + RETURNING id INTO v_ryid; + + INSERT INTO v3.smeta (rf_project_year_id) + VALUES (v_ryid); + + INSERT INTO v3.rf_project_report (project_id, rf_project_year_id, report_type) + VALUES (p_project_id, v_ryid, 'LIMIT') RETURNING id INTO v_lim; + INSERT INTO v3.rf_project_report (project_id, rf_project_year_id, report_type) + VALUES (p_project_id, v_ryid, 'CURRENT_EXPENSES') RETURNING id INTO v_cur; + + PERFORM v3.copy_template_to_report(v_lim); + PERFORM v3.copy_template_to_report(v_cur); + + PERFORM v3.seed_form3_report_lines(v_lim); + PERFORM v3.seed_form3_report_lines(v_cur); + + IF p_emit_audit THEN + PERFORM v3.log_event( + 'PROJECT_YEAR_ADD', 'PROJECT', + jsonb_build_object( + 'project_id', p_project_id, + 'year', p_year, + 'limit_report_id', v_lim, + 'current_expenses_report_id', v_cur + ) + ); + END IF; + + RETURN QUERY SELECT v_lim, v_cur; +END; +$function$ +; +-- DROP FUNCTION v3.del_form3_phase(int4, varchar); + +CREATE OR REPLACE FUNCTION v3.del_form3_phase(p_rf_project_report_id integer, p_phase_code character varying) + RETURNS boolean + LANGUAGE plpgsql +AS $function$ + DECLARE + v_old v3.form3_phase; + v_org_unit_id INT; + v_org_unit_title VARCHAR; + v_year INT; + v_task_name TEXT; + v_phase_id TEXT; + v_report_type VARCHAR; + v_project_id INT; + BEGIN + DELETE FROM v3.form3_phase + WHERE rf_project_report_id = p_rf_project_report_id + AND phase_code = p_phase_code + RETURNING * INTO v_old; + + IF NOT FOUND THEN + RETURN FALSE; + END IF; + + SELECT p.org_unit_id, ou.title, rpry.year, rpr.report_type, rpr.project_id + INTO v_org_unit_id, v_org_unit_title, v_year, v_report_type, v_project_id + FROM v3.rf_project_report rpr + JOIN v3.rf_project_year rpry ON rpr.rf_project_year_id = rpry.id + LEFT JOIN v3.project p ON p.id = rpr.project_id + LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id + WHERE rpr.id = p_rf_project_report_id; + v_task_name := trim(concat( + 'FORM_3', + CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END, + CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END + )); + v_phase_id := format('%s:%s', p_rf_project_report_id, p_phase_code); + + PERFORM v3.log_event( + 'ACCESS_WINDOW_CHANGE', 'ACCESS', + jsonb_build_object( + 'entity_type', 'project', + 'entity_id', v_project_id, + 'core_entity_type', 'form3_phase', + 'core_entity_id', v_phase_id, + 'action', 'delete', + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_name', NULLIF(v_task_name, ''), + 'project_id', v_project_id, + 'year', v_year, + 'report_type', v_report_type, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'phase_code', p_phase_code, + 'role', v_old.role, + 'column_keys', v_old.column_keys, + 'opens_at', v_old.opens_at, + 'closes_at', v_old.closes_at + ), + null, null, v_org_unit_id + ); + RETURN TRUE; + END; + $function$ +; +-- DROP FUNCTION v3.upd_form3_phase(int4, varchar, varchar, _text, timestamp, timestamp); + +CREATE OR REPLACE FUNCTION v3.upd_form3_phase(p_rf_project_report_id integer, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone) + RETURNS v3.form3_phase + LANGUAGE plpgsql +AS $function$ + DECLARE + v_old v3.form3_phase; + v_new v3.form3_phase; + v_is_extend BOOLEAN := FALSE; + v_has_non_extend_change BOOLEAN := FALSE; + v_has_window_change BOOLEAN := FALSE; + v_changes JSONB := '{}'::jsonb; + v_org_unit_id INT; + v_org_unit_title VARCHAR; + v_form_type_code VARCHAR; + v_year INT; + v_task_name TEXT; + v_phase_id TEXT; + v_utc_offset VARCHAR(3); + v_opens_at timestamptz; + v_closes_at timestamptz; + v_report_type VARCHAR; + v_project_id INT; + BEGIN + SELECT * INTO v_old + FROM v3.form3_phase + WHERE rf_project_report_id = p_rf_project_report_id + AND phase_code = p_phase_code; + + IF NOT FOUND THEN + RAISE EXCEPTION 'form3_phase не найден (rf_project_report_id=%, phase=%)', + p_rf_project_report_id, p_phase_code; + END IF; + + SELECT p.org_unit_id, ou.title, rpry.year, ou.utc_offset, rpr.report_type, rpr.project_id + INTO v_org_unit_id, v_org_unit_title, v_year, v_utc_offset, v_report_type, v_project_id + FROM v3.rf_project_report rpr + JOIN v3.rf_project_year rpry ON rpr.rf_project_year_id = rpry.id + LEFT JOIN v3.project p ON p.id = rpr.project_id + LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id + WHERE rpr.id = p_rf_project_report_id; + v_task_name := trim(concat( + 'FORM_3', + CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END, + CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END + )); + + IF p_opens_at is not null THEN + v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz; + END IF; + IF p_closes_at is not null THEN + v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz; + END IF; + + UPDATE v3.form3_phase + SET role = COALESCE(p_role, role), + column_keys = COALESCE(p_column_keys, column_keys), + opens_at = COALESCE(v_opens_at, opens_at), + closes_at = COALESCE(v_closes_at, closes_at) + WHERE rf_project_report_id = p_rf_project_report_id + AND phase_code = p_phase_code + RETURNING * INTO v_new; + + + v_phase_id := format('%s:%s', p_rf_project_report_id, p_phase_code); + + v_is_extend := v_new.closes_at > v_old.closes_at; + v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role + OR v_old.column_keys IS DISTINCT FROM v_new.column_keys + OR v_old.opens_at IS DISTINCT FROM v_new.opens_at; + v_has_window_change := v_has_non_extend_change + OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend); + + IF v_old.role IS DISTINCT FROM v_new.role THEN + v_changes := v_changes || jsonb_build_object( + 'role', jsonb_build_object('before', v_old.role, 'after', v_new.role) + ); + END IF; + IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN + v_changes := v_changes || jsonb_build_object( + 'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys) + ); + END IF; + IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN + v_changes := v_changes || jsonb_build_object( + 'opens_at', jsonb_build_object( + 'before', v_old.opens_at, + 'after', v_new.opens_at + ) + ); + END IF; + IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN + v_changes := v_changes || jsonb_build_object( + 'closes_at', jsonb_build_object( + 'before', v_old.closes_at, + 'after', v_new.closes_at + ) + ); + END IF; + + IF v_is_extend THEN + PERFORM v3.log_event( + 'ACCESS_EXTEND', 'ACCESS', + jsonb_build_object( + 'entity_type', 'project', + 'entity_id', v_project_id, + 'core_entity_type', 'form3_phase', + 'core_entity_id', v_phase_id, + 'action', 'update', + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_name', NULLIF(v_task_name, ''), + 'report_id', p_rf_project_report_id, + 'report_type', v_report_type, + 'project_id', v_project_id, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'year', v_year, + 'phase_code', p_phase_code, + 'closes_at_before', v_old.closes_at, + 'closes_at_after', v_new.closes_at + ), + null, null, v_org_unit_id + ); + END IF; + + IF v_has_window_change THEN + PERFORM v3.log_event( + 'ACCESS_WINDOW_CHANGE', 'ACCESS', + jsonb_build_object( + 'entity_type', 'project', + 'entity_id', v_project_id, + 'core_entity_type', 'form3_phase', + 'core_entity_id', v_phase_id, + 'action', 'update', + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_id', p_rf_project_report_id, + 'task_name', NULLIF(v_task_name, ''), + 'project_id', v_project_id, + 'report_type', v_report_type, + 'year', v_year, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'phase_code', p_phase_code, + 'changes', v_changes + ), + null, null, v_org_unit_id + ); + END IF; + + RETURN v_new; + END; + $function$ +; +-- DROP FUNCTION v3.v_form3_project_jsonb(int4, varchar, _text); + +CREATE OR REPLACE FUNCTION v3.v_form3_project_jsonb(p_project_id integer, p_report_type character varying, p_sections text[] DEFAULT NULL::text[]) + RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE sql + STABLE +AS $function$ + WITH reports AS ( + SELECT r.id AS report_id, rpry.year + FROM v3.rf_project_report r + JOIN v3.rf_project_year rpry ON rpry.id = r.rf_project_year_id + WHERE r.project_id = p_project_id + AND r.report_type = p_report_type + ), + -- Разворачиваем каждый год в строки его листа + per_year AS ( + SELECT + v.row_type, + v.depth, + (v.data->'header'->>'expense_item_id')::INT AS eiid, + v.data->'header' AS header, + v.sort_order AS y_sort, + rp.year AS year, + v.data AS ydata + FROM reports rp + CROSS JOIN LATERAL v3.v_form3_report_jsonb(rp.report_id, p_sections) v + ), + -- Сшиваем по идентичности строки (row_type + статья): сетка одинакова во всех годах + agg AS ( + SELECT + py.row_type, + py.depth, + py.eiid, + MIN(py.y_sort) AS sort_order, + (array_agg(py.header ORDER BY py.year))[1] AS header, + jsonb_object_agg(py.year::text, py.ydata ORDER BY py.year) AS years, + SUM((py.ydata->'totals'->>'total_actual')::numeric) AS project_total + FROM per_year py + GROUP BY py.row_type, py.depth, py.eiid + ) + SELECT + a.row_type, + a.depth, + ROW_NUMBER() OVER (ORDER BY a.sort_order) AS sort_order, + jsonb_build_object( + 'header', a.header, + 'years', a.years, + 'project_total', a.project_total + ) AS data + FROM agg a + ORDER BY a.sort_order; +$function$ +; +-- DROP FUNCTION v3.v_form3_rf_rollup_sections(int4, int4, _text); + +CREATE OR REPLACE FUNCTION v3.v_form3_rf_rollup_sections(p_branch_id integer, p_year integer, p_sections text[] DEFAULT NULL::text[]) + RETURNS TABLE(row_type character varying, depth integer, col_section_code character varying, col_item_id character varying, col_num_group_id character varying, col_name character varying, col_q1_adj_by_items numeric, col_q1_adj_increase numeric, col_q1_total_corr numeric, col_q1_m1 numeric, col_q1_m2 numeric, col_q1_m3 numeric, col_q1_quarter_actual numeric, col_q1_economy numeric, col_q2_adj_by_items numeric, col_q2_adj_increase numeric, col_q2_total_corr numeric, col_q2_m1 numeric, col_q2_m2 numeric, col_q2_m3 numeric, col_q2_quarter_actual numeric, col_q2_economy numeric, col_q3_adj_by_items numeric, col_q3_adj_increase numeric, col_q3_total_corr numeric, col_q3_m1 numeric, col_q3_m2 numeric, col_q3_m3 numeric, col_q3_quarter_actual numeric, col_q3_economy numeric, col_q4_adj_by_items numeric, col_q4_adj_increase numeric, col_q4_total_corr numeric, col_q4_m1 numeric, col_q4_m2 numeric, col_q4_m3 numeric, col_q4_spod numeric, col_q4_quarter_actual numeric, col_q4_economy numeric, col_year_total_corr numeric, col_year_total_actual numeric, col_year_economy numeric, _sort_path integer[]) + LANGUAGE plpgsql + STABLE +AS $function$ +DECLARE + s_q1 BOOLEAN := p_sections IS NULL OR 'q1' = ANY(p_sections); + s_q2 BOOLEAN := p_sections IS NULL OR 'q2' = ANY(p_sections); + s_q3 BOOLEAN := p_sections IS NULL OR 'q3' = ANY(p_sections); + s_q4 BOOLEAN := p_sections IS NULL OR 'q4' = ANY(p_sections); + s_year BOOLEAN := p_sections IS NULL OR 'year' = ANY(p_sections); +BEGIN + RETURN QUERY + WITH + tw AS ( + SELECT t.* FROM v3.mv_expense_item_tree t + JOIN v3.expense_item_form_type eft + ON eft.expense_item_id = t.id AND eft.form_type_code = 'FORM_3' + ), + -- Агрегаты per (expense_item_id, quarter) — sum по обоим трекам всех проектов РФ × год + agg_q AS ( + SELECT l.expense_item_id AS eid, q.quarter, + SUM(q.adj_by_items) AS abi, SUM(q.adj_increase) AS ai, + SUM(q.actual_m1) AS m1, SUM(q.actual_m2) AS m2, SUM(q.actual_m3) AS m3, + SUM(q.actual_spod) AS spod + FROM v3.rf_project_report r + JOIN v3.rf_project_year rpry ON rpry.id = r.rf_project_year_id + JOIN v3.project p ON p.id = r.project_id + JOIN v3.rf_project_report_line l ON l.rf_project_report_id = r.id + JOIN v3.rf_project_report_quarter q ON q.rf_project_report_line_id = l.id + WHERE p.org_unit_id = p_branch_id AND rpry.year = p_year + GROUP BY l.expense_item_id, q.quarter + ), + tw_aq1 AS (SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai, + SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3 + FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter=1 + GROUP BY tw.id), + tw_aq2 AS (SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai, + SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3 + FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter=2 + GROUP BY tw.id), + tw_aq3 AS (SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai, + SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3 + FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter=3 + GROUP BY tw.id), + tw_aq4 AS (SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai, + SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3, + SUM(a.spod) AS spod + FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter=4 + GROUP BY tw.id) + + SELECT + CASE t.depth WHEN 0 THEN 'ROOT' WHEN 1 THEN 'GROUP' + WHEN 2 THEN 'ITEM' WHEN 3 THEN 'SUB_ITEM' END::VARCHAR, + t.depth, + CASE WHEN t.depth < 3 THEN t.item_id ELSE t.parent_item_id END, + CASE WHEN t.depth = 3 THEN t.item_id ELSE NULL END, + t.num_group_id, t.name, + -- Q1 + CASE WHEN s_q1 THEN a1.abi END, + CASE WHEN s_q1 THEN a1.ai END, + CASE WHEN s_q1 THEN COALESCE(a1.abi,0)+COALESCE(a1.ai,0) END, + CASE WHEN s_q1 THEN a1.m1 END, + CASE WHEN s_q1 THEN a1.m2 END, + CASE WHEN s_q1 THEN a1.m3 END, + CASE WHEN s_q1 THEN COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0) END, + CASE WHEN s_q1 THEN ROUND((COALESCE(a1.abi,0)+COALESCE(a1.ai,0)) + -(COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0)), 1) END, + -- Q2 + CASE WHEN s_q2 THEN a2.abi END, + CASE WHEN s_q2 THEN a2.ai END, + CASE WHEN s_q2 THEN COALESCE(a2.abi,0)+COALESCE(a2.ai,0) END, + CASE WHEN s_q2 THEN a2.m1 END, + CASE WHEN s_q2 THEN a2.m2 END, + CASE WHEN s_q2 THEN a2.m3 END, + CASE WHEN s_q2 THEN COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0) END, + CASE WHEN s_q2 THEN ROUND((COALESCE(a2.abi,0)+COALESCE(a2.ai,0)) + -(COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0)), 1) END, + -- Q3 + CASE WHEN s_q3 THEN a3.abi END, + CASE WHEN s_q3 THEN a3.ai END, + CASE WHEN s_q3 THEN COALESCE(a3.abi,0)+COALESCE(a3.ai,0) END, + CASE WHEN s_q3 THEN a3.m1 END, + CASE WHEN s_q3 THEN a3.m2 END, + CASE WHEN s_q3 THEN a3.m3 END, + CASE WHEN s_q3 THEN COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0) END, + CASE WHEN s_q3 THEN ROUND((COALESCE(a3.abi,0)+COALESCE(a3.ai,0)) + -(COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0)), 1) END, + -- Q4 + CASE WHEN s_q4 THEN a4.abi END, + CASE WHEN s_q4 THEN a4.ai END, + CASE WHEN s_q4 THEN COALESCE(a4.abi,0)+COALESCE(a4.ai,0) END, + CASE WHEN s_q4 THEN a4.m1 END, + CASE WHEN s_q4 THEN a4.m2 END, + CASE WHEN s_q4 THEN a4.m3 END, + CASE WHEN s_q4 THEN a4.spod END, + CASE WHEN s_q4 THEN COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0) END, + CASE WHEN s_q4 THEN ROUND((COALESCE(a4.abi,0)+COALESCE(a4.ai,0)) + -(COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0)), 1) END, + -- year + CASE WHEN s_year THEN COALESCE(a1.abi,0)+COALESCE(a1.ai,0)+COALESCE(a2.abi,0)+COALESCE(a2.ai,0)+ + COALESCE(a3.abi,0)+COALESCE(a3.ai,0)+COALESCE(a4.abi,0)+COALESCE(a4.ai,0) END, + CASE WHEN s_year THEN COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0)+ + COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0)+ + COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0)+ + COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0) END, + CASE WHEN s_year THEN + ROUND( + (COALESCE(a1.abi,0)+COALESCE(a1.ai,0)+COALESCE(a2.abi,0)+COALESCE(a2.ai,0)+ + COALESCE(a3.abi,0)+COALESCE(a3.ai,0)+COALESCE(a4.abi,0)+COALESCE(a4.ai,0)) + -(COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0)+ + COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0)+ + COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0)+ + COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0)), + 1) + END, + t.path + FROM tw t + LEFT JOIN tw_aq1 a1 ON a1.id = t.id + LEFT JOIN tw_aq2 a2 ON a2.id = t.id + LEFT JOIN tw_aq3 a3 ON a3.id = t.id + LEFT JOIN tw_aq4 a4 ON a4.id = t.id + ORDER BY t.path; +END; +$function$ +; + + +CREATE OR REPLACE FUNCTION v3.copy_template_to_report(p_report_id integer) + RETURNS integer + LANGUAGE plpgsql +AS $function$ +DECLARE + v_report_type VARCHAR; + v_inserted INTEGER; + v_org_unit INTEGER; + v_utc_offset VARCHAR(3); + v_year INTEGER; +BEGIN + SELECT report_type, org_unit_id, "year" INTO v_report_type, v_org_unit, v_year + FROM v3.rf_project_report AS rpr + INNER JOIN v3.project AS p ON p.id = rpr.project_id + INNER JOIN v3.rf_project_year AS rpy ON rpy.id = rpr.rf_project_year_id + WHERE rpr.id = p_report_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id; + END IF; + + SELECT utc_offset INTO v_utc_offset + FROM v3.org_unit + WHERE id = v_org_unit; + + 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 + INTERVAL '1 year' * v_year)::text || v_utc_offset)::timestamptz, + ((pt.closes_at + INTERVAL '1 year' * v_year)::text || v_utc_offset)::timestamptz + 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$ +; + +-- DROP FUNCTION v3.del_project(int4); + +CREATE OR REPLACE FUNCTION v3.del_project(p_project_id integer) + RETURNS integer + LANGUAGE plpgsql +AS $function$ +DECLARE + v_old RECORD; + v_actor_id INTEGER; + v_role VARCHAR; +BEGIN + SELECT p.name, p.level, p.org_unit_id + INTO v_old + FROM v3.project p WHERE p.id = p_project_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'project #% не существует', p_project_id; + END IF; + + IF NOT v3.can_delete_project(p_project_id) THEN + RAISE EXCEPTION 'project #% невозможно удалить', p_project_id; + END IF; + + DELETE FROM v3.form3_phase f3p WHERE f3p.rf_project_report_id IN ( + SELECT id FROM v3.rf_project_report rpr WHERE project_id = p_project_id + ); + DELETE FROM v3.rf_project_report_quarter + WHERE rf_project_report_line_id IN ( + SELECT l.id FROM v3.rf_project_report_line l + JOIN v3.rf_project_report r ON r.id = l.rf_project_report_id + WHERE r.project_id = p_project_id); + DELETE FROM v3.rf_project_report_line + WHERE rf_project_report_id IN ( + SELECT id FROM v3.rf_project_report WHERE project_id = p_project_id); + DELETE FROM v3.rf_project_report WHERE project_id = p_project_id; + DELETE FROM v3.smeta WHERE rf_project_year_id IN ( + SELECT id FROM v3.rf_project_year WHERE project_id = p_project_id + ); + DELETE FROM v3.rf_project_year WHERE project_id = p_project_id; + DELETE FROM v3.project WHERE id = p_project_id; + + PERFORM v3.log_event( + 'PROJECT_DELETE', 'PROJECT', + jsonb_build_object( + 'project_id', p_project_id, + 'name', v_old.name, + 'level', v_old.level, + 'org_unit_id', v_old.org_unit_id), null, null, v_old.org_unit_id); + + RETURN p_project_id; +END; +$function$ +; + + +-- DROP FUNCTION v3.upd_project(int4, varchar, varchar, varchar, varchar, varchar, varchar, varchar, int4, numeric, int4, 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_technical_number 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_technical_number IS NOT NULL AND p_technical_number IS DISTINCT FROM v_old.technical_number THEN + UPDATE v3.project SET technical_number = p_technical_number WHERE id = p_project_id; + v_changes := v_changes || jsonb_build_object('technical_number', + jsonb_build_object('before', v3._diff_val(v_old.technical_number), 'after', v3._diff_val(p_technical_number))); + 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$ +; + + +-- DROP FUNCTION v3.upd_smeta(int4, bool, bool, bool, bool, varchar, numeric, numeric); + +CREATE OR REPLACE FUNCTION v3.upd_smeta( + p_rf_project_year_id integer, + p_is_in_plan boolean DEFAULT NULL, + p_is_in_plan_q2 boolean DEFAULT NULL, + p_is_in_plan_q3 boolean DEFAULT NULL, + p_is_in_plan_q4 boolean DEFAULT NULL, + p_development_block character varying DEFAULT NULL, + p_reserve_to_prrs_ahr numeric DEFAULT NULL, + p_reserve_to_prrs_kv numeric DEFAULT NULL +) + RETURNS void + LANGUAGE plpgsql +AS $function$ +DECLARE + v_old v3.smeta%ROWTYPE; + v_changes JSONB := '{}'::jsonb; + v_project_id INT; + v_org_unit_id INT; +BEGIN + IF NOT EXISTS (SELECT 1 FROM v3.rf_project_year WHERE id = p_rf_project_year_id) THEN + RAISE EXCEPTION 'rf_project_year id=% не существует', p_rf_project_year_id; + END IF; + + INSERT INTO v3.smeta (rf_project_year_id) + SELECT p_rf_project_year_id + WHERE NOT EXISTS (SELECT 1 FROM v3.smeta WHERE rf_project_year_id = p_rf_project_year_id); + + SELECT * INTO v_old FROM v3.smeta WHERE rf_project_year_id = p_rf_project_year_id; + + IF p_is_in_plan IS NOT NULL AND p_is_in_plan IS DISTINCT FROM v_old.is_in_plan THEN + UPDATE v3.smeta SET is_in_plan = p_is_in_plan WHERE id = v_old.id; + v_changes := v_changes || jsonb_build_object('is_in_plan', + jsonb_build_object('before', v3._diff_val(v_old.is_in_plan), 'after', v3._diff_val(p_is_in_plan))); + END IF; + + IF p_is_in_plan_q2 IS NOT NULL AND p_is_in_plan_q2 IS DISTINCT FROM v_old.is_in_plan_q2 THEN + UPDATE v3.smeta SET is_in_plan_q2 = p_is_in_plan_q2 WHERE id = v_old.id; + v_changes := v_changes || jsonb_build_object('is_in_plan_q2', + jsonb_build_object('before', v3._diff_val(v_old.is_in_plan_q2), 'after', v3._diff_val(p_is_in_plan_q2))); + END IF; + + IF p_is_in_plan_q3 IS NOT NULL AND p_is_in_plan_q3 IS DISTINCT FROM v_old.is_in_plan_q3 THEN + UPDATE v3.smeta SET is_in_plan_q3 = p_is_in_plan_q3 WHERE id = v_old.id; + v_changes := v_changes || jsonb_build_object('is_in_plan_q3', + jsonb_build_object('before', v3._diff_val(v_old.is_in_plan_q3), 'after', v3._diff_val(p_is_in_plan_q3))); + END IF; + + IF p_is_in_plan_q4 IS NOT NULL AND p_is_in_plan_q4 IS DISTINCT FROM v_old.is_in_plan_q4 THEN + UPDATE v3.smeta SET is_in_plan_q4 = p_is_in_plan_q4 WHERE id = v_old.id; + v_changes := v_changes || jsonb_build_object('is_in_plan_q4', + jsonb_build_object('before', v3._diff_val(v_old.is_in_plan_q4), 'after', v3._diff_val(p_is_in_plan_q4))); + END IF; + + IF p_development_block IS NOT NULL AND p_development_block IS DISTINCT FROM v_old.development_block THEN + UPDATE v3.smeta SET development_block = p_development_block WHERE id = v_old.id; + v_changes := v_changes || jsonb_build_object('development_block', + jsonb_build_object('before', v3._diff_val(v_old.development_block), 'after', v3._diff_val(p_development_block))); + END IF; + + IF p_reserve_to_prrs_ahr IS NOT NULL AND p_reserve_to_prrs_ahr IS DISTINCT FROM v_old.reserve_to_prrs_ahr THEN + UPDATE v3.smeta SET reserve_to_prrs_ahr = p_reserve_to_prrs_ahr WHERE id = v_old.id; + v_changes := v_changes || jsonb_build_object('reserve_to_prrs_ahr', + jsonb_build_object('before', v3._diff_val(v_old.reserve_to_prrs_ahr), 'after', v3._diff_val(p_reserve_to_prrs_ahr))); + END IF; + + IF p_reserve_to_prrs_kv IS NOT NULL AND p_reserve_to_prrs_kv IS DISTINCT FROM v_old.reserve_to_prrs_kv THEN + UPDATE v3.smeta SET reserve_to_prrs_kv = p_reserve_to_prrs_kv WHERE id = v_old.id; + v_changes := v_changes || jsonb_build_object('reserve_to_prrs_kv', + jsonb_build_object('before', v3._diff_val(v_old.reserve_to_prrs_kv), 'after', v3._diff_val(p_reserve_to_prrs_kv))); + END IF; + + SELECT rpy.project_id, p.org_unit_id + INTO v_project_id, v_org_unit_id + FROM v3.rf_project_year rpy + JOIN v3.project p ON p.id = rpy.project_id + WHERE rpy.id = p_rf_project_year_id; + + IF v_changes <> '{}'::jsonb THEN + PERFORM v3.log_event( + 'SMETA_UPDATE', 'PROJECT', + jsonb_build_object( + 'rf_project_year_id', p_rf_project_year_id, + 'project_id', v_project_id, + 'changes', v_changes + ), null, null, v_org_unit_id + ); + END IF; +END; +$function$ +; diff --git a/api/src/api/v1/form_phases.py b/api/src/api/v1/form_phases.py index c01d488..82f8837 100644 --- a/api/src/api/v1/form_phases.py +++ b/api/src/api/v1/form_phases.py @@ -198,6 +198,7 @@ async def get_form3_phases( project_id=project_id, year=year, report_type=report_type, + load_year=True, ) phases = await f3p_service.get_list( diff --git a/api/src/api/v1/projects.py b/api/src/api/v1/projects.py index 08a57ca..93bef9f 100644 --- a/api/src/api/v1/projects.py +++ b/api/src/api/v1/projects.py @@ -18,6 +18,7 @@ from src.domain.schemas import ( ResponseBase, SheetResponse, UpdProjectBody, + UpdSmetaBody, ) from src.services.project_service import ProjectService @@ -67,6 +68,27 @@ async def get_projects( return BaseListResponse(result=projects, count=count) +@router.get("/projects/with-reports") +async def get_projects_with_reports( + offset: int = 0, + limit: int = 100, + year: Optional[int] = None, + branch_id: Optional[int] = None, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseListResponse[dict]: + project_service = ProjectService(db) + count, projects = await project_service.get_list_with_reports( + user=current_user, + with_count=True, + year=year, + branch_id=branch_id, + offset=offset, + limit=limit, + ) + return BaseListResponse(result=projects, count=count) + + @router.get("/projects/{project_id}") async def get_project( project_id: int, @@ -243,13 +265,28 @@ async def upd_project( current_user: AppUser = Depends(get_current_active_user_with_set_db), ) -> dict: project_service = ProjectService(db) - result = await project_service.upd_project( + return await project_service.upd_project( project_id=project_id, - column=body.column, - value=body.value, + data=body.model_dump(exclude_unset=True), + user=current_user, + ) + + +@router.patch("/projects/{project_id}/smeta/{year}") +async def upd_smeta( + project_id: int, + year: int, + body: UpdSmetaBody, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> dict: + project_service = ProjectService(db) + return await project_service.upd_smeta( + project_id=project_id, + year=year, + data=body.model_dump(exclude_unset=True), user=current_user, ) - return result @router.delete("/projects/{project_id}") diff --git a/api/src/db/models/__init__.py b/api/src/db/models/__init__.py index a0e643d..85c2fb7 100644 --- a/api/src/db/models/__init__.py +++ b/api/src/db/models/__init__.py @@ -25,9 +25,11 @@ from src.db.models.reserve import Reserve from src.db.models.rf_project_report import RfProjectReport from src.db.models.rf_project_report_line import RfProjectReportLine from src.db.models.rf_project_report_quarter import RfProjectReportQuarter +from src.db.models.rf_project_year import RfProjectYear from src.db.models.role import Role, UserRoleEnum from src.db.models.security_detail import SecurityDetail from src.db.models.sequestration import Sequestration +from src.db.models.smeta import Smeta from src.db.models.user_org import UserOrg from src.db.models.utility_detail import UtilityDetail from src.db.models.vsp import Vsp diff --git a/api/src/db/models/project.py b/api/src/db/models/project.py index 36785af..9345a64 100644 --- a/api/src/db/models/project.py +++ b/api/src/db/models/project.py @@ -1,8 +1,8 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime -from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, Numeric, String, func, text +from sqlalchemy import CheckConstraint, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, func, text from sqlalchemy.orm import Mapped, mapped_column, relationship from src.db.base import Base @@ -28,6 +28,14 @@ class Project(Base): text("name IS NOT NULL AND name <> ''"), name="chk_v3_project_name", ), + CheckConstraint( + "status IS NULL OR status IN ('created', 'agreed', 'archived')", + name="project_status_check", + ), + CheckConstraint( + "funding_by_ko_decision IS NULL OR funding_by_ko_decision IN ('prrs_budget', 'bank_reserve')", + name="project_funding_ko_check", + ), Index("ix_v3_project_ssp", "org_unit_id"), {"schema": "v3"}, ) @@ -44,6 +52,13 @@ class Project(Base): total_area: Mapped[float | None] = mapped_column(Numeric) org_unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.org_unit.id")) created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now()) + status: Mapped[str | None] = mapped_column(String, server_default=text("'created'")) + technical_number: Mapped[str | None] = mapped_column(String) + krf_decision_date: Mapped[date | None] = mapped_column(Date) + fk_decision_date: Mapped[date | None] = mapped_column(Date) + board_decision_date: Mapped[date | None] = mapped_column(Date) + open_relocate_close_date: Mapped[date | None] = mapped_column(Date) + funding_by_ko_decision: Mapped[str | None] = mapped_column(String) # parent = relationship("Project", remote_side="Project.id", back_populates="children") # children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore diff --git a/api/src/db/models/rf_project_report.py b/api/src/db/models/rf_project_report.py index e4d1f2c..ffb97f1 100644 --- a/api/src/db/models/rf_project_report.py +++ b/api/src/db/models/rf_project_report.py @@ -11,14 +11,14 @@ from src.db.base import Base class RfProjectReport(Base): __tablename__ = "rf_project_report" __table_args__ = ( - UniqueConstraint("project_id", "year", "report_type", name="uq_v3_rf_project_report"), - Index("ix_v3_rf_project_report_proj_year", "project_id", "year"), + UniqueConstraint("project_id", "rf_project_year_id", "report_type", name="uq_v3_rf_project_report"), + Index("ix_v3_rf_project_report_proj_year", "project_id", "rf_project_year_id"), {"schema": "v3"}, ) id: Mapped[int] = mapped_column(Integer, primary_key=True) project_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.project.id")) - year: Mapped[int] = mapped_column(Integer) + rf_project_year_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.rf_project_year.id")) report_type: Mapped[str] = mapped_column(String, CheckConstraint("report_type IN ('LIMIT','CURRENT_EXPENSES')")) created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id")) created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now()) @@ -26,6 +26,11 @@ class RfProjectReport(Base): updated_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now()) project: Mapped["Project"] = relationship("Project", back_populates="rf_project_reports") # type: ignore + rf_project_year: Mapped["RfProjectYear"] = relationship("RfProjectYear") # type: ignore + + @property + def year(self): + return self.rf_project_year.year # creator: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[created_by]) # updater: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[updated_by]) # lines: Mapped[list[RfProjectReportLine]] = relationship("RfProjectReportLine", back_populates="report") diff --git a/api/src/db/models/rf_project_year.py b/api/src/db/models/rf_project_year.py new file mode 100644 index 0000000..c61c9fc --- /dev/null +++ b/api/src/db/models/rf_project_year.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class RfProjectYear(Base): + __tablename__ = "rf_project_year" + __table_args__ = ( + UniqueConstraint("project_id", "year", name="uq_v3_rf_project_year"), + Index("ix_v3_rf_project_year_project", "project_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.project.id")) + year: Mapped[int] = mapped_column(Integer) + + project: Mapped["Project"] = relationship("Project") # type: ignore + smetas: Mapped[list["Smeta"]] = relationship("Smeta", back_populates="rf_project_year") # type: ignore diff --git a/api/src/db/models/smeta.py b/api/src/db/models/smeta.py new file mode 100644 index 0000000..6b20a3a --- /dev/null +++ b/api/src/db/models/smeta.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from sqlalchemy import Boolean, CheckConstraint, ForeignKey, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class Smeta(Base): + __tablename__ = "smeta" + __table_args__ = ( + CheckConstraint( + "development_block IS NULL OR development_block IN ('out_of_base_plan', 'carryover', 'additional_funding')", + name="smeta_development_block_check", + ), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + rf_project_year_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.rf_project_year.id")) + is_in_plan: Mapped[bool | None] = mapped_column(Boolean) + is_in_plan_q2: Mapped[bool | None] = mapped_column(Boolean) + is_in_plan_q3: Mapped[bool | None] = mapped_column(Boolean) + is_in_plan_q4: Mapped[bool | None] = mapped_column(Boolean) + development_block: Mapped[str | None] = mapped_column(String) + reserve_to_prrs_ahr: Mapped[float | None] = mapped_column(Numeric) + reserve_to_prrs_kv: Mapped[float | None] = mapped_column(Numeric) + + rf_project_year: Mapped["RfProjectYear"] = relationship("RfProjectYear", back_populates="smetas") # type: ignore diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index a6b4ee3..136e1bf 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -37,6 +37,28 @@ PlacementTypeLiteral = Literal[ "Субаренда", ] +PLACEMENT_TYPE_TO_DB: dict[str, str] = { + "Собственность": "own", + "Аренда": "rent", + "Субаренда": "other", +} + +PLACEMENT_TYPE_FROM_DB: dict[str, str] = { + db_value: label for label, db_value in PLACEMENT_TYPE_TO_DB.items() +} + + +def placement_type_to_db(value: str | None) -> str | None: + if value is None: + return None + return PLACEMENT_TYPE_TO_DB.get(value, value) + + +def placement_type_from_db(value: str | None) -> str | None: + if value is None: + return None + return PLACEMENT_TYPE_FROM_DB.get(value, value) + # def serialize_missing_greenlet(cls, obj): # result = {} @@ -356,8 +378,35 @@ class AddForm3LineBody(BaseModel): class UpdProjectBody(BaseModel): - column: str - value: Any = None + name: Optional[str] = Field( + None, + max_length=30, + 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 + object_address: Optional[str] = None + staff_count: Optional[int] = None + total_area: Optional[float] = None + org_unit_id: Optional[int] = None + krf_decision_date: Optional[date] = None + fk_decision_date: Optional[date] = None + board_decision_date: Optional[date] = None + open_relocate_close_date: Optional[date] = None + funding_by_ko_decision: Optional[Literal["prrs_budget", "bank_reserve"]] = None + + +class UpdSmetaBody(BaseModel): + is_in_plan: Optional[bool] = None + is_in_plan_q2: Optional[bool] = None + is_in_plan_q3: Optional[bool] = None + is_in_plan_q4: Optional[bool] = None + development_block: Optional[Literal["out_of_base_plan", "carryover", "additional_funding"]] = None + reserve_to_prrs_ahr: Optional[float] = None + reserve_to_prrs_kv: Optional[float] = None class AddProjectBody(BaseModel): diff --git a/api/src/repository/project_repository.py b/api/src/repository/project_repository.py index 4703fcc..059fd08 100644 --- a/api/src/repository/project_repository.py +++ b/api/src/repository/project_repository.py @@ -7,6 +7,9 @@ from src.db.models.org_unit import OrgUnit from src.db.models.project import Project from src.db.models.rf_project_report import RfProjectReport from src.db.models.rf_project_report_line import RfProjectReportLine +from src.db.models.rf_project_year import RfProjectYear +from src.db.models.smeta import Smeta +from src.domain.schemas import placement_type_from_db class ProjectRepository: @@ -22,7 +25,7 @@ class ProjectRepository: "parent_id": project.parent_id, "project_type": project.project_type, "vsp_format": project.vsp_format, - "placement_type": project.placement_type, + "placement_type": placement_type_from_db(project.placement_type), "object_address": project.object_address, "staff_count": project.staff_count, "total_area": float(project.total_area) if project.total_area is not None else None, @@ -38,9 +41,12 @@ class ProjectRepository: if year is not None: query = query.where( exists( - select(1).where( + select(1) + .select_from(RfProjectReport) + .join(RfProjectYear, RfProjectYear.id == RfProjectReport.rf_project_year_id) + .where( RfProjectReport.project_id == Project.id, - RfProjectReport.year == year, + RfProjectYear.year == year, ) ) ) @@ -69,7 +75,9 @@ class ProjectRepository: ) report_years_subq = ( - select(RfProjectReport.year) + select(RfProjectYear.year) + .select_from(RfProjectReport) + .join(RfProjectYear, RfProjectYear.id == RfProjectReport.rf_project_year_id) .where(RfProjectReport.project_id == Project.id) .distinct() .scalar_subquery() @@ -101,6 +109,88 @@ class ProjectRepository: total = int((await self.db.execute(count_query)).scalar() or 0) return total, payload + @staticmethod + def _serialize_project_with_reports(project: Project, years: list[tuple[RfProjectYear, Smeta | None]]) -> dict: + return { + "id": project.id, + "name": project.name, + "level": project.level, + "parent_id": project.parent_id, + "project_type": project.project_type, + "vsp_format": project.vsp_format, + "placement_type": placement_type_from_db(project.placement_type), + "object_address": project.object_address, + "staff_count": project.staff_count, + "total_area": float(project.total_area) if project.total_area is not None else None, + "org_unit_id": project.org_unit_id, + "status": project.status, + "technical_number": project.technical_number, + "krf_decision_date": project.krf_decision_date.isoformat() if project.krf_decision_date else None, + "fk_decision_date": project.fk_decision_date.isoformat() if project.fk_decision_date else None, + "board_decision_date": project.board_decision_date.isoformat() if project.board_decision_date else None, + "open_relocate_close_date": project.open_relocate_close_date.isoformat() if project.open_relocate_close_date else None, + "funding_by_ko_decision": project.funding_by_ko_decision, + "created_at": project.created_at.isoformat() if project.created_at else None, + "sub_rows": [ + { + "id": rpy.id, + "year": rpy.year, + "project": f"Смета_{rpy.year}", + "is_in_plan": smeta.is_in_plan if smeta else None, + "is_in_plan_q2": smeta.is_in_plan_q2 if smeta else None, + "is_in_plan_q3": smeta.is_in_plan_q3 if smeta else None, + "is_in_plan_q4": smeta.is_in_plan_q4 if smeta else None, + "development_block": smeta.development_block if smeta else None, + "reserve_to_prrs_ahr": float(smeta.reserve_to_prrs_ahr) if smeta and smeta.reserve_to_prrs_ahr is not None else None, + "reserve_to_prrs_kv": float(smeta.reserve_to_prrs_kv) if smeta and smeta.reserve_to_prrs_kv is not None else None, + } + for rpy, smeta in years + ], + } + + async def get_with_reports( + self, + year: int | None = None, + branch_id: int | None = None, + offset: int | None = None, + limit: int | None = None, + with_count: bool = False, + org_unit_ids: list[int] | None = None, + ) -> list[dict] | tuple[int, list[dict]]: + project_query = select(Project).order_by(Project.id) + project_query = self._apply_project_filters(project_query, year, branch_id, org_unit_ids) + if offset is not None: + project_query = project_query.offset(offset) + if limit is not None: + project_query = project_query.limit(limit) + + projects = (await self.db.execute(project_query)).scalars().all() + + years_by_project: dict[int, list[tuple[RfProjectYear, Smeta | None]]] = {} + project_ids = [p.id for p in projects] + if project_ids: + years_query = ( + select(RfProjectYear, Smeta) + .outerjoin(Smeta, Smeta.rf_project_year_id == RfProjectYear.id) + .where(RfProjectYear.project_id.in_(project_ids)) + .order_by(RfProjectYear.year) + ) + for rpy, smeta in (await self.db.execute(years_query)).all(): + years_by_project.setdefault(rpy.project_id, []).append((rpy, smeta)) + + payload = [ + self._serialize_project_with_reports(p, years_by_project.get(p.id, [])) + for p in projects + ] + + if not with_count: + return payload + + count_query = select(func.count(Project.id)) + count_query = self._apply_project_filters(count_query, year, branch_id, org_unit_ids) + total = int((await self.db.execute(count_query)).scalar() or 0) + return total, payload + async def get( self, project_id: int, @@ -112,7 +202,9 @@ class ProjectRepository: .scalar_subquery() ) report_years_subq = ( - select(RfProjectReport.year) + select(RfProjectYear.year) + .select_from(RfProjectReport) + .join(RfProjectYear, RfProjectYear.id == RfProjectReport.rf_project_year_id) .where(RfProjectReport.project_id == Project.id) .distinct() .scalar_subquery() @@ -162,7 +254,7 @@ class ProjectRepository: query = ( select( RfProjectReport.id, - RfProjectReport.year, + RfProjectYear.year, RfProjectReport.report_type, RfProjectReport.created_by, RfProjectReport.created_at, @@ -170,8 +262,9 @@ class ProjectRepository: RfProjectReport.updated_at, line_count_subq.label("line_count"), ) + .join(RfProjectYear, RfProjectYear.id == RfProjectReport.rf_project_year_id) .where(RfProjectReport.project_id == project_id) - .order_by(RfProjectReport.year, RfProjectReport.report_type) + .order_by(RfProjectYear.year, RfProjectReport.report_type) ) rows = (await self.db.execute(query)).all() reports: list[dict] = [] @@ -200,9 +293,10 @@ class ProjectRepository: query = ( select(RfProjectReport.id) .join(Project, Project.id == RfProjectReport.project_id) + .join(RfProjectYear, RfProjectYear.id == RfProjectReport.rf_project_year_id) .where( Project.id == project_id, - RfProjectReport.year == year, + RfProjectYear.year == year, RfProjectReport.report_type == report_type, ) ) @@ -326,23 +420,120 @@ class ProjectRepository: result = (await self.db.execute(query, {"project_id": project_id})).scalar_one() return result - async def upd_project(self, project_id: int, column: str, value): - query = text( - """ - SELECT v3.upd_project(CAST(:project_id AS INT), CAST(:column AS TEXT), CAST(:value AS JSONB)) - """ + async def upd_project(self, project_id: int, data: dict) -> None: + await self.db.execute( + text( + """ + SELECT v3.upd_project( + 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), + CAST(:object_address AS VARCHAR), + CAST(:staff_count AS INT), + CAST(:total_area AS NUMERIC), + CAST(:org_unit_id AS INT), + CAST(:krf_decision_date AS DATE), + CAST(:fk_decision_date AS DATE), + CAST(:board_decision_date AS DATE), + CAST(:open_relocate_close_date AS DATE), + CAST(:funding_by_ko_decision AS VARCHAR) + ) + """ + ), + { + "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"), + "object_address": data.get("object_address"), + "staff_count": data.get("staff_count"), + "total_area": data.get("total_area"), + "org_unit_id": data.get("org_unit_id"), + "krf_decision_date": data.get("krf_decision_date"), + "fk_decision_date": data.get("fk_decision_date"), + "board_decision_date": data.get("board_decision_date"), + "open_relocate_close_date": data.get("open_relocate_close_date"), + "funding_by_ko_decision": data.get("funding_by_ko_decision"), + }, ) - result = ( + await self.db.flush() + + async def resolve_rf_project_year_id( + self, + project_id: int, + year: int, + org_unit_ids: list[int] | None = None, + ) -> int | None: + query = ( + select(RfProjectYear.id) + .join(Project, Project.id == RfProjectYear.project_id) + .where(Project.id == project_id, RfProjectYear.year == year) + ) + if org_unit_ids is not None: + if len(org_unit_ids) == 0: + return None + query = query.where(Project.org_unit_id.in_(org_unit_ids)) + return (await self.db.execute(query)).scalar_one_or_none() + + async def upd_smeta(self, rf_project_year_id: int, data: dict) -> None: + await self.db.execute( + text( + """ + SELECT v3.upd_smeta( + CAST(:rf_project_year_id AS INT), + CAST(:is_in_plan AS BOOLEAN), + CAST(:is_in_plan_q2 AS BOOLEAN), + CAST(:is_in_plan_q3 AS BOOLEAN), + CAST(:is_in_plan_q4 AS BOOLEAN), + CAST(:development_block AS VARCHAR), + CAST(:reserve_to_prrs_ahr AS NUMERIC), + CAST(:reserve_to_prrs_kv AS NUMERIC) + ) + """ + ), + { + "rf_project_year_id": rf_project_year_id, + "is_in_plan": data.get("is_in_plan"), + "is_in_plan_q2": data.get("is_in_plan_q2"), + "is_in_plan_q3": data.get("is_in_plan_q3"), + "is_in_plan_q4": data.get("is_in_plan_q4"), + "development_block": data.get("development_block"), + "reserve_to_prrs_ahr": data.get("reserve_to_prrs_ahr"), + "reserve_to_prrs_kv": data.get("reserve_to_prrs_kv"), + }, + ) + await self.db.flush() + + @staticmethod + def _serialize_smeta(smeta: Smeta | None) -> dict | None: + if smeta is None: + return None + return { + "id": smeta.id, + "rf_project_year_id": smeta.rf_project_year_id, + "is_in_plan": smeta.is_in_plan, + "is_in_plan_q2": smeta.is_in_plan_q2, + "is_in_plan_q3": smeta.is_in_plan_q3, + "is_in_plan_q4": smeta.is_in_plan_q4, + "development_block": smeta.development_block, + "reserve_to_prrs_ahr": float(smeta.reserve_to_prrs_ahr) if smeta.reserve_to_prrs_ahr is not None else None, + "reserve_to_prrs_kv": float(smeta.reserve_to_prrs_kv) if smeta.reserve_to_prrs_kv is not None else None, + } + + async def get_smeta(self, rf_project_year_id: int) -> dict | None: + smeta = ( await self.db.execute( - query, - { - "project_id": project_id, - "column": column, - "value": json.dumps(value), - }, + select(Smeta).where(Smeta.rf_project_year_id == rf_project_year_id) ) - ).scalar_one() - return result + ).scalar_one_or_none() + return self._serialize_smeta(smeta) async def add_project( self, diff --git a/api/src/repository/rf_project_report_repository.py b/api/src/repository/rf_project_report_repository.py index 90a1fea..ae10554 100644 --- a/api/src/repository/rf_project_report_repository.py +++ b/api/src/repository/rf_project_report_repository.py @@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload from src.db.models.rf_project_report import RfProjectReport +from src.db.models.rf_project_year import RfProjectYear @@ -25,8 +26,10 @@ class RfProjectReportRepository: if report_id is not None: query = query.where(RfProjectReport.id == report_id) else: - query = query.where( - RfProjectReport.year == year, + query = query.join( + RfProjectYear, RfProjectYear.id == RfProjectReport.rf_project_year_id + ).where( + RfProjectYear.year == year, RfProjectReport.report_type == report_type, RfProjectReport.project_id == project_id ) @@ -42,14 +45,19 @@ class RfProjectReportRepository: year: int | None = None, report_type: str | None = None, project_id: int | None = None, + load_year: bool = False ) -> list[RfProjectReport]: query = select( RfProjectReport ) if report_id is not None: query = query.where(RfProjectReport.id == report_id) + if load_year or year: + query = query.options( + joinedload(RfProjectReport.rf_project_year) + ) if year is not None: - query = query.where(RfProjectReport.year == year) + query = query.where(RfProjectYear.year == year) if report_type is not None: query = query.where(RfProjectReport.report_type == report_type) if project_id is not None: diff --git a/api/src/services/project_service.py b/api/src/services/project_service.py index bbf6455..8c1fc38 100644 --- a/api/src/services/project_service.py +++ b/api/src/services/project_service.py @@ -4,6 +4,7 @@ from src.db.models.project import Project from src.db.models.app_user import AppUser from src.db.models.role import UserRoleEnum from src.core.errors import AccessDeniedException, ValidationException +from src.domain.schemas import placement_type_to_db from src.repository.project_repository import ProjectRepository from src.repository.user_repository import UserRepository @@ -34,6 +35,25 @@ class ProjectService: org_unit_ids=org_unit_ids, ) + async def get_list_with_reports( + self, + user: AppUser, + year: int | None = None, + branch_id: int | None = None, + offset: int | None = None, + limit: int | None = None, + with_count: bool = False, + ) -> list[dict] | tuple[int, list[dict]]: + org_unit_ids = await self._allowed_org_unit_ids(user) + return await self.project_repo.get_with_reports( + year=year, + branch_id=branch_id, + offset=offset, + limit=limit, + with_count=with_count, + org_unit_ids=org_unit_ids, + ) + async def get(self, project_id: int, user: AppUser) -> dict | None: org_unit_ids = await self._allowed_org_unit_ids(user) project = await self.project_repo.get(project_id=project_id, org_unit_ids=org_unit_ids) @@ -156,14 +176,34 @@ class ProjectService: async def upd_project( self, project_id: int, - column: str, - value, + data: dict, user: AppUser, ): project = await self.get(project_id, user) if not project: raise ValidationException("Проект не найден") - return await self.project_repo.upd_project(project_id, column, value) + if "placement_type" in data: + data["placement_type"] = placement_type_to_db(data["placement_type"]) + await self.project_repo.upd_project(project_id, data) + return await self.get(project_id, user) + + async def upd_smeta( + self, + project_id: int, + year: int, + data: dict, + user: AppUser, + ): + org_unit_ids = await self._allowed_org_unit_ids(user) + rf_project_year_id = await self.project_repo.resolve_rf_project_year_id( + project_id=project_id, + year=year, + org_unit_ids=org_unit_ids, + ) + if rf_project_year_id is None: + raise ValidationException("Смета не найдена") + await self.project_repo.upd_smeta(rf_project_year_id, data) + return await self.project_repo.get_smeta(rf_project_year_id) async def add_project( self, @@ -187,7 +227,7 @@ class ProjectService: parent_id=parent_id, project_type=project_type, vsp_format=vsp_format, - placement_type=placement_type, + placement_type=placement_type_to_db(placement_type), object_address=object_address, staff_count=staff_count, total_area=total_area, diff --git a/api/src/services/rf_project_report_service.py b/api/src/services/rf_project_report_service.py index 9debf94..7465e8b 100644 --- a/api/src/services/rf_project_report_service.py +++ b/api/src/services/rf_project_report_service.py @@ -36,10 +36,12 @@ class RfProjectReportService: year: int | None = None, report_type: str | None = None, project_id: int | None = None, + load_year: bool = False, ) -> list[RfProjectReport]: return await self.pr_repo.get_list( report_id=report_id, year=year, report_type=report_type, project_id=project_id, + load_year=load_year, ) diff --git a/api/tests/integration/test_form_phases_api_smoke.py b/api/tests/integration/test_form_phases_api_smoke.py index 51a31c4..c8ac5ad 100644 --- a/api/tests/integration/test_form_phases_api_smoke.py +++ b/api/tests/integration/test_form_phases_api_smoke.py @@ -184,6 +184,14 @@ def test_form3_phases_list_filter_smoke(client, admin_tokens): assert "count" in payload assert payload["count"] <= count + response = client.get( + "/api/v1/stages/project/2?&year=2026", headers=_auth_headers(admin_tokens) + ) + assert response.status_code == 200 + payload = response.json() + assert "count" in payload + assert payload["count"] <= count + def test_form3_phases_create_forbidden(client, isp_tokens): response = client.post( diff --git a/api/tests/integration/test_projects_api_smoke.py b/api/tests/integration/test_projects_api_smoke.py index 86e058d..3dc92e6 100644 --- a/api/tests/integration/test_projects_api_smoke.py +++ b/api/tests/integration/test_projects_api_smoke.py @@ -79,7 +79,7 @@ def test_projects_write_smoke(client, admin_tokens, auth_headers): upd_project_response = client.patch( f"/api/v1/project/{project_id}", - json={"column": "name", "value": f"{name}_U"}, + json={"name": f"{name}_U"}, headers=auth_headers(admin_tokens), ) assert upd_project_response.status_code == 200 @@ -248,3 +248,92 @@ def test_delete_seed_project_smoke(client, admin_tokens, auth_headers): payload = response.json() assert payload["success"] is True assert payload["message"] == "Проект удалён" + + +def _get_project_with_reports(client, auth_headers, admin_tokens, project_id): + response = client.get( + "/api/v1/projects/with-reports?limit=1000", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200 + return next((p for p in response.json()["result"] if p["id"] == project_id), None) + + +def test_upd_project_smoke(client, admin_tokens, auth_headers): + project_id, name = _create_project(client, admin_tokens, auth_headers) + + response = client.patch( + f"/api/v1/project/{project_id}", + json={ + "name": f"{name}_U", + "status": "agreed", + "technical_number": "2606_180801", + "krf_decision_date": "2026-06-11", + "fk_decision_date": "2026-06-18", + "board_decision_date": "2026-07-02", + "open_relocate_close_date": "2026-07-15", + "funding_by_ko_decision": "prrs_budget", + "placement_type": "Собственность", + }, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200 + + project = _get_project_with_reports(client, auth_headers, admin_tokens, project_id) + assert project is not None + assert project["name"] == f"{name}_U" + assert project["status"] == "agreed" + assert project["technical_number"] == "2606_180801" + assert project["krf_decision_date"] == "2026-06-11" + assert project["fk_decision_date"] == "2026-06-18" + assert project["board_decision_date"] == "2026-07-02" + assert project["open_relocate_close_date"] == "2026-07-15" + assert project["funding_by_ko_decision"] == "prrs_budget" + # placement_type сконвертирован в БД-код и обратно в строковое представление + assert project["placement_type"] == "Собственность" + + +def test_upd_smeta_smoke(client, admin_tokens, auth_headers): + project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026) + + response = client.patch( + f"/api/v1/projects/{project_id}/smeta/2026", + json={ + "is_in_plan": True, + "is_in_plan_q2": True, + "is_in_plan_q3": False, + "is_in_plan_q4": False, + "development_block": "out_of_base_plan", + "reserve_to_prrs_ahr": 1500.5, + "reserve_to_prrs_kv": 2300, + }, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["is_in_plan"] is True + assert payload["is_in_plan_q2"] is True + assert payload["is_in_plan_q3"] is False + assert payload["is_in_plan_q4"] is False + assert payload["development_block"] == "out_of_base_plan" + assert payload["reserve_to_prrs_ahr"] == 1500.5 + assert payload["reserve_to_prrs_kv"] == 2300 + + project = _get_project_with_reports(client, auth_headers, admin_tokens, project_id) + assert project is not None + sub_row = next((sr for sr in project["sub_rows"] if sr["year"] == 2026), None) + assert sub_row is not None + assert sub_row["is_in_plan"] is True + assert sub_row["development_block"] == "out_of_base_plan" + + +def test_upd_smeta_not_found(client, admin_tokens, auth_headers): + project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026) + response = client.patch( + f"/api/v1/projects/{project_id}/smeta/2099", + json={"is_in_plan": True}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 400 + payload = response.json() + assert payload.get("message") == "Смета не найдена" diff --git a/api/tests/sqls/fixture.sql b/api/tests/sqls/fixture.sql index 4f85b3c..1d55800 100644 --- a/api/tests/sqls/fixture.sql +++ b/api/tests/sqls/fixture.sql @@ -34,13 +34,21 @@ INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_t (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',1,'3434',NULL,'2','примечание','567','2027-07-01'); SELECT setval('v3.vsp_id_seq', 3); -INSERT INTO v3.project ("id","name","level",parent_id,project_type,vsp_format,placement_type,object_address,staff_count,total_area,org_unit_id) VALUES - (2,'PT6d112310_U','project',NULL,NULL,NULL,NULL,NULL,NULL,NULL,1); +INSERT INTO v3.project ("id","name","level",parent_id,project_type,vsp_format,placement_type,object_address,staff_count,total_area,org_unit_id,status,technical_number,krf_decision_date,fk_decision_date,board_decision_date,open_relocate_close_date,funding_by_ko_decision) VALUES + (2,'PT6d112310_U','project',NULL,NULL,NULL,NULL,NULL,NULL,NULL,1,'created','2606_180801','2026-06-11','2026-06-18','2026-07-02','2026-07-15','prrs_budget'); SELECT setval('v3.project_id_seq', 3); -INSERT INTO v3.rf_project_report (id,project_id,"year",report_type,created_by,created_at,updated_by,updated_at) VALUES - (1,2,2026,'LIMIT',NULL,'2026-05-12 12:43:56.20138',NULL,'2026-05-12 12:43:56.20138'), - (2,2,2026,'CURRENT_EXPENSES',NULL,'2026-05-12 12:43:56.20138',NULL,'2026-05-12 12:43:56.20138'); +INSERT INTO v3.rf_project_year ("id",project_id,"year") VALUES + (1,2,2026); +SELECT setval('v3.rf_project_year_id_seq', 1); + +INSERT INTO v3.smeta ("id",rf_project_year_id,is_in_plan,is_in_plan_q2,is_in_plan_q3,is_in_plan_q4,development_block,reserve_to_prrs_ahr,reserve_to_prrs_kv) VALUES + (1,1,true,false,false,false,'out_of_base_plan',1500.00,2300.00); +SELECT setval('v3.smeta_id_seq', 1); + +INSERT INTO v3.rf_project_report (id,project_id,rf_project_year_id,report_type,created_by,created_at,updated_by,updated_at) VALUES + (1,2,1,'LIMIT',NULL,'2026-05-12 12:43:56.20138',NULL,'2026-05-12 12:43:56.20138'), + (2,2,1,'CURRENT_EXPENSES',NULL,'2026-05-12 12:43:56.20138',NULL,'2026-05-12 12:43:56.20138'); SELECT setval('v3.rf_project_report_id_seq', 3); INSERT INTO v3.form3_phase (rf_project_report_id,phase_code,"role",column_keys,opens_at,closes_at) VALUES -- 2.47.2