From 68fd2a76460100a96c635db32aa2a37d737bbf75 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Mon, 27 Jul 2026 10:00:05 +0300 Subject: [PATCH] =?UTF-8?q?form-4-sql:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D0=BC=D0=BC=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=B2=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=20?= =?UTF-8?q?4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/alembic/versions/0010_form4.py | 84 ++ api/alembic/versions/sql/0010_form4.sql | 852 ++++++++++++++++++ api/src/api/v1/websocket.py | 58 +- api/src/db/models/__init__.py | 1 + api/src/db/models/form4_project.py | 54 ++ .../repository/form4_project_repository.py | 65 ++ api/src/repository/sheet_repository.py | 53 ++ api/src/services/sheet_service.py | 92 +- 8 files changed, 1235 insertions(+), 24 deletions(-) create mode 100644 api/alembic/versions/0010_form4.py create mode 100644 api/alembic/versions/sql/0010_form4.sql create mode 100644 api/src/db/models/form4_project.py create mode 100644 api/src/repository/form4_project_repository.py diff --git a/api/alembic/versions/0010_form4.py b/api/alembic/versions/0010_form4.py new file mode 100644 index 0000000..3ab795b --- /dev/null +++ b/api/alembic/versions/0010_form4.py @@ -0,0 +1,84 @@ +import os +import re +import time + +from alembic import op +import sqlalchemy as sa + + +revision = "0010" +down_revision = "0009" +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", "0010_form4.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/0010_form4.sql b/api/alembic/versions/sql/0010_form4.sql new file mode 100644 index 0000000..55821cf --- /dev/null +++ b/api/alembic/versions/sql/0010_form4.sql @@ -0,0 +1,852 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- migrate_form4_project.sql — выделение FORM_4 программ/проектов в отдельную +-- сущность v3.form4_project (перестают делить таблицу с FORM_3 v3.project). +-- ════════════════════════════════════════════════════════════════════════════ +-- Мотивация: v3.project исторически был FORM_4-таблицей (program/project + +-- parent_id), FORM_3-редизайн навесил сверху свои атрибуты (project_type, +-- vsp_format, address, area, staff, org_unit_id) и строгий chk_v3_project_name. +-- Два разных концепта под одним словом «project» → разреженные колонки, нет +-- защиты FK, констрейнт вынужден ветвиться по «виду» строки. Разводим. +-- +-- Новая таблица v3.form4_project — точный костяк без FORM_3-полей: +-- program (level='program', parent_id=NULL) +-- project (level='project', parent_id=program) +-- +-- Дискриминатор FORM_4 vs FORM_3: FORM_3-строки — те, что ссылаются из +-- v3.rf_project_report (add_project всегда создаёт отчёты для КАЖДОЙ строки, +-- включая программы). Всё остальное в v3.project — FORM_4. +-- +-- Порядок: после init.sql + всех migrate_form3*/migrate_to_identity (нужна +-- финальная форма v3.project с org_unit_id). Один прогон — copy защищён +-- проверкой на пустоту form4_project. +-- ════════════════════════════════════════════════════════════════════════════ + +-- ───── 1. Новая таблица ───────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS v3.form4_project ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + level VARCHAR CHECK (level IN ('program', 'project')), + section_code VARCHAR, + parent_id INTEGER REFERENCES v3.form4_project(id), + -- org_unit_id INTEGER REFERENCES v3.org_unit(id), + form_id INTEGER REFERENCES v3.budget_form(id), + -- project (level='project') обязан иметь родителя-программу; program — нет. + CONSTRAINT chk_form4_project_parent CHECK ( + (level = 'program' AND parent_id IS NULL) + OR (level = 'project') + ), + CONSTRAINT chk_form4_project_section_code CHECK ( + (level = 'program') = (section_code IS NOT NULL) + ) +); + +CREATE INDEX ix_form4_project_section_code + ON v3.form4_project(section_code) WHERE level = 'program'; +CREATE INDEX IF NOT EXISTS ix_v3_form4_project_parent ON v3.form4_project(parent_id); +CREATE INDEX IF NOT EXISTS ix_v3_form4_project_form ON v3.form4_project(form_id); + +-- ───── 2. Перенос FORM_4-строк из v3.project (с сохранением id) ────────────── +-- FK budget_line.project_id ссылается по id → id сохраняем 1:1. +-- Копируем обе роли (program + project) одним INSERT: self-FK parent_id внутри +-- одного оператора проверяется в конце, порядок строк не важен. +-- DO $migrate$ +-- BEGIN +-- IF NOT EXISTS (SELECT 1 FROM v3.form4_project) THEN +-- INSERT INTO v3.form4_project (id, name, level, parent_id, org_unit_id) +-- SELECT p.id, p.name, p.level, p.parent_id, (p.org_unit_id +-- FROM v3.project p +-- WHERE p.id NOT IN ( +-- SELECT project_id FROM v3.rf_project_report WHERE project_id IS NOT NULL +-- ); + +-- -- Синхронизируем sequence, чтобы новые INSERT не конфликтовали по id. +-- PERFORM setval('v3.form4_project_id_seq', +-- GREATEST((SELECT COALESCE(MAX(id), 0) FROM v3.form4_project), 1)); + +-- RAISE NOTICE 'form4_project: перенесено % строк из v3.project', +-- (SELECT count(*) FROM v3.form4_project); +-- ELSE +-- RAISE NOTICE 'form4_project уже наполнена — перенос пропущен'; +-- END IF; +-- END +-- $migrate$; + +-- ───── 3. Перецепка FK budget_line.project_id на новую таблицу ────────────── +ALTER TABLE v3.budget_line DROP CONSTRAINT IF EXISTS budget_line_project_id_fkey; +ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_project_id_fkey FOREIGN KEY (project_id) REFERENCES v3.form4_project(id); + +-- ───── 4. Удаление перенесённых строк из v3.project ───────────────────────── +-- После перецепки budget_line на v3.project больше не ссылается ничего FORM_4. +-- Остаются только FORM_3-строки (referenced by rf_project_report). +DELETE FROM v3.project p + WHERE p.id NOT IN ( + SELECT project_id FROM v3.rf_project_report WHERE project_id IS NOT NULL + ); + +-- ───── 5. Восстановление chk_v3_project_name в строгой форме ───────────────── +-- FORM_4-строки ушли — v3.project теперь чисто FORM_3, правило коротких имён +-- ВСП применимо ко всей таблице (как до form4_write.sql). NOT VALID сохраняем. +ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS chk_v3_project_name; +ALTER TABLE v3.project + ADD CONSTRAINT chk_v3_project_name + CHECK (name IS NULL OR ( + length(name) <= 30 AND + name !~ E'[+\\-/\\\\=&* ]' + )) NOT VALID; + + +-- ════════════════════════════════════════════════════════════════════════════ +-- v3.add_form4_program / v3.add_form4_project — создание программ и проектов +-- FORM_4 в выделенной таблице v3.form4_project (иерархия program → project). +-- ════════════════════════════════════════════════════════════════════════════ +-- FORM_4 хранит иерархию отдельно от FORM_3 (v3.project) — см. +-- migrate_form4_project.sql. Костяк: +-- program (level='program', parent_id=NULL) → depth=1 (GROUP) в v_form4 +-- project (level='project', parent_id=program) → depth=2 (ITEM) в v_form4 +-- Строки бюджета привязываются к проекту через budget_line.project_id. +-- +-- Никаких FORM_3-атрибутов / rf_project_report / snapshot фаз — это ветка +-- budget_form. p_org_unit_id опционален (read-path FORM_4 по нему не фильтрует, +-- заполняется для аудита/принадлежности). +-- +-- Порядок применения: после migrate_form4_project.sql (нужна таблица +-- v3.form4_project) и audit.sql (нужна v3.log_event). +-- ════════════════════════════════════════════════════════════════════════════ + +-- ───── 1. add_form4_program ───────────────────────────────────────────────── +DROP FUNCTION IF EXISTS v3.add_form4_program(VARCHAR, INT, VARCHAR, INT); + +CREATE FUNCTION v3.add_form4_program( + p_name VARCHAR, + p_form_id INT, + p_section_code VARCHAR DEFAULT NULL +) +RETURNS TABLE ( + project_id INT, + row_type VARCHAR, + depth INT, + sort_order BIGINT, + data JSONB +) AS $function$ +#variable_conflict use_column +DECLARE + v_pid INT; +BEGIN + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + IF p_form_id IS NULL THEN + RAISE EXCEPTION 'p_form_id обязателен'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM v3.budget_form bf + WHERE bf.form_type_code = 'FORM_4' AND bf.id = p_form_id + ) THEN + RAISE EXCEPTION 'Только FORM_4'; + END IF; + IF p_section_code IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM v3.expense_item + WHERE section_code = p_section_code AND depth = 0 + ) THEN + RAISE EXCEPTION 'section_code=% не существует в expense_item (depth=0)', p_section_code; + END IF; + + INSERT INTO v3.form4_project (name, level, parent_id, form_id, section_code) + VALUES (p_name, 'program', NULL, p_form_id, p_section_code) + RETURNING id INTO v_pid; + + PERFORM v3.log_event( + 'PROJECT_CREATE', 'PROJECT', + jsonb_strip_nulls(jsonb_build_object( + 'project_id', v_pid, + 'name', p_name, + 'level', 'program', + 'form_id', p_form_id, + 'form_type', 'FORM_4' + )), + p_form_id, NULL, NULL + ); + + RETURN QUERY + SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(p_form_id, p_section_code, NULL) v + WHERE (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = v_pid) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = p_section_code) + ORDER BY v.sort_order; +END; +$function$ +LANGUAGE plpgsql VOLATILE; + +-- ───── 2. add_form4_project ───────────────────────────────────────────────── +DROP FUNCTION IF EXISTS v3.add_form4_project(VARCHAR, INT); + +CREATE FUNCTION v3.add_form4_project( + p_name VARCHAR, + p_program_id INT +) +RETURNS TABLE ( + project_id INT, + row_type VARCHAR, + depth INT, + sort_order BIGINT, + data JSONB +) +AS $function$ +#variable_conflict use_column +DECLARE + v_pid INT; + v_section_code VARCHAR; + v_form_id INT; +BEGIN + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + IF p_program_id IS NULL THEN + RAISE EXCEPTION 'p_program_id обязателен (проект создаётся под программой)'; + END IF; + IF NOT EXISTS (SELECT 1 FROM v3.form4_project fp + WHERE fp.id = p_program_id AND fp.level = 'program') THEN + RAISE EXCEPTION 'program id=% не существует или не имеет level=program', p_program_id; + END IF; + + SELECT form_id INTO v_form_id FROM v3.form4_project WHERE id = p_program_id; + + INSERT INTO v3.form4_project (name, level, parent_id, form_id) + VALUES (p_name, 'project', p_program_id, v_form_id) + RETURNING id INTO v_pid; + + PERFORM v3.log_event( + 'PROJECT_CREATE', 'PROJECT', + jsonb_strip_nulls(jsonb_build_object( + 'project_id', v_pid, + 'name', p_name, + 'level', 'project', + 'parent_id', p_program_id, + 'form_id', v_form_id, + 'form_type', 'FORM_4' + )), + v_form_id, NULL, NULL + ); + + SELECT fp.section_code INTO v_section_code + FROM v3.form4_project fp + WHERE fp.id = p_program_id AND fp.level = 'program'; + + IF v_section_code IS NOT NULL THEN + RETURN QUERY + SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(v_form_id, v_section_code, NULL) v + WHERE (v.row_type = 'ITEM' AND (v.data->'header'->>'project_id')::INT = v_pid) + OR (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = p_program_id) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_section_code) + ORDER BY v.sort_order; + END IF; +END; +$function$ +LANGUAGE plpgsql VOLATILE; + + +-- ════════════════════════════════════════════════════════════════════════════ +-- v3.v_form4_sheet_jsonb — JSONB-выдача FORM_4 для АХР_Р / КВ_Р / Операц +-- ════════════════════════════════════════════════════════════════════════════ +-- Иерархия (4 уровня): +-- ROOT depth=0 — section_code (1.00.0/3.00.0/4.00.0/5.00.0) +-- PROGRAM depth=1 — v3.form4_project level=program +-- PROJECT depth=2 — v3.form4_project level=project (parent_id=program) +-- INPUT depth=3 — budget_line (R-код) +-- +-- Финансовые блоки (plan, seq_dfip, approved, booking, q1-q4, totals) +-- агрегируются на уровнях ROOT/PROGRAM/PROJECT через v3.jsonb_numeric_sum. +-- Текстовые поля (counterparty, comment, justification…) на агрегатных +-- уровнях NULL'ятся (jsonb_numeric_sum их игнорирует). +-- +-- p_form_id — id формы FORM_4 +-- p_sheet — 'AHR' / 'CAP' / 'OPER' +-- p_sections — массив включаемых блоков (как в v_form4_sheet_lines_jsonb) +-- ════════════════════════════════════════════════════════════════════════════ + +-- ───── 1. Хелпер: рекурсивная сумма численных листьев двух JSONB ────────── +CREATE OR REPLACE FUNCTION v3.jsonb_numeric_sum(a JSONB, b JSONB) +RETURNS JSONB LANGUAGE sql IMMUTABLE AS $function$ + SELECT CASE + WHEN a IS NULL THEN b + WHEN b IS NULL THEN a + WHEN jsonb_typeof(a) = 'number' AND jsonb_typeof(b) = 'number' THEN + to_jsonb((a)::text::numeric + (b)::text::numeric) + WHEN jsonb_typeof(a) = 'object' AND jsonb_typeof(b) = 'object' THEN ( + SELECT jsonb_object_agg(k, + v3.jsonb_numeric_sum(a -> k, b -> k) + ) + FROM ( + SELECT k FROM jsonb_object_keys(a) k + UNION + SELECT k FROM jsonb_object_keys(b) k + ) keys + ) + -- одна сторона число/объект, другая нет → берём числовую/объектную + WHEN jsonb_typeof(a) IN ('number','object') THEN a + WHEN jsonb_typeof(b) IN ('number','object') THEN b + ELSE NULL + END; +$function$; + +DROP AGGREGATE IF EXISTS v3.jsonb_sum(JSONB); +CREATE AGGREGATE v3.jsonb_sum(JSONB) ( + SFUNC = v3.jsonb_numeric_sum, + STYPE = JSONB +); + +-- ───── 2. Основная функция ───────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION v3.v_form4_sheet_jsonb( + p_form_id INT, + p_sheet VARCHAR, + p_sections TEXT[] DEFAULT NULL +) +RETURNS TABLE ( + row_type VARCHAR, + depth INT, + sort_order BIGINT, + data JSONB +) +AS $function$ + -- ═══ 1. Базовые INPUT-строки с расширенной иерархией ═══════════════════════ + WITH inp_raw AS ( + SELECT j.* + FROM v3.v_form4_sheet_lines_jsonb(p_form_id, p_sheet, p_sections) j + WHERE j.row_type = 'INPUT' + ), + inp AS ( + SELECT + i.*, + bl.project_id AS line_project_id, + + -- --- разрешение проекта --- + -- A. bl.project_id → project (level='project') + -- B. bl.project_id → program (level='program') — тогда проекта нет + CASE WHEN prj.id IS NOT NULL THEN prj.id END AS prj_id, + CASE WHEN prj.id IS NOT NULL THEN prj.name END AS prj_name, + + -- --- разрешение программы --- + -- A. через проект: prog_via_prj + -- B. напрямую: prog_direct + COALESCE(prog_via_prj.id, prog_direct.id) AS prog_id, + COALESCE(prog_via_prj.name, prog_direct.name) AS prog_name, + + -- --- section (depth=0) --- + sec.id AS sec_id, + sec.section_code AS sec_code, + sec.name AS sec_name + + FROM inp_raw i + JOIN v3.budget_line bl ON bl.id = i.line_id + + -- проект (level='project') + LEFT JOIN v3.form4_project prj + ON prj.id = bl.project_id AND prj.level = 'project' + + -- программа через проект + LEFT JOIN v3.form4_project prog_via_prj + ON prog_via_prj.id = prj.parent_id AND prog_via_prj.level = 'program' + + -- программа напрямую (bl.project_id → program) + LEFT JOIN v3.form4_project prog_direct + ON prog_direct.id = bl.project_id AND prog_direct.level = 'program' + + -- раздел depth=0 (через expense_item) + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + LEFT JOIN LATERAL ( + WITH RECURSIVE up AS ( + SELECT id, parent_id, name, section_code, depth + FROM v3.expense_item WHERE id = ei.id + UNION ALL + SELECT e.id, e.parent_id, e.name, e.section_code, e.depth + FROM v3.expense_item e JOIN up u ON u.parent_id = e.id + ) + SELECT id, name, section_code FROM up WHERE depth = 0 LIMIT 1 + ) sec ON TRUE + ), + + -- ═══ 2. Все программы (включая пустые) ═════════════════════════════════════ + all_programs AS ( + SELECT + fp.id AS prog_id, + fp.name AS prog_name, + fp.section_code, + sec.id AS sec_id, + sec.section_code AS sec_code, + sec.name AS sec_name + FROM v3.form4_project fp + LEFT JOIN LATERAL ( + SELECT id, section_code, name + FROM v3.expense_item + WHERE section_code = fp.section_code AND depth = 0 + LIMIT 1 + ) sec ON TRUE + WHERE fp.level = 'program' + AND (p_sections IS NULL OR fp.section_code = ANY(p_sections)) + AND (fp.section_code = p_sheet) AND fp.form_id = p_form_id + ), + + -- ═══ 3. Все проекты под программами (включая пустые) ═══════════════════════ + all_projects AS ( + SELECT + prj.id AS prj_id, + prj.name AS prj_name, + prj.parent_id AS prog_id, + ap.sec_id, ap.sec_code, ap.sec_name + FROM v3.form4_project prj + JOIN all_programs ap ON ap.prog_id = prj.parent_id + WHERE prj.level = 'project' AND (ap.section_code = p_sheet) + ), + + -- ═══ 4. Агрегаты с финансовыми данными (только из inp) ════════════════════ + + -- PROJECT-агрегаты + prj_agg AS ( + SELECT + sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, + prog_id, MAX(prog_name) AS prog_name, + prj_id, MAX(prj_name) AS prj_name, + v3.jsonb_sum(plan_data) AS plan_data, + v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data, + v3.jsonb_sum(approved_data) AS approved_data, + v3.jsonb_sum(contract_summary_data) AS contract_summary_data, + v3.jsonb_sum(allocation_data) AS allocation_data, + v3.jsonb_sum(reserve_data) AS reserve_data, + v3.jsonb_sum(collegial_data) AS collegial_data, + v3.jsonb_sum(ckk_data) AS ckk_data, + v3.jsonb_sum(contract_data) AS contract_data, + v3.jsonb_sum(booking_data) AS booking_data, + v3.jsonb_sum(q1_data) AS q1_data, + v3.jsonb_sum(q2_data) AS q2_data, + v3.jsonb_sum(q3_data) AS q3_data, + v3.jsonb_sum(q4_data) AS q4_data, + v3.jsonb_sum(totals_data) AS totals_data + FROM inp WHERE prj_id IS NOT NULL + GROUP BY sec_id, prog_id, prj_id + ), + + -- PROGRAM-агрегаты + prog_agg AS ( + SELECT + sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, + prog_id, MAX(prog_name) AS prog_name, + v3.jsonb_sum(plan_data) AS plan_data, + v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data, + v3.jsonb_sum(approved_data) AS approved_data, + v3.jsonb_sum(contract_summary_data) AS contract_summary_data, + v3.jsonb_sum(allocation_data) AS allocation_data, + v3.jsonb_sum(reserve_data) AS reserve_data, + v3.jsonb_sum(collegial_data) AS collegial_data, + v3.jsonb_sum(ckk_data) AS ckk_data, + v3.jsonb_sum(contract_data) AS contract_data, + v3.jsonb_sum(booking_data) AS booking_data, + v3.jsonb_sum(q1_data) AS q1_data, + v3.jsonb_sum(q2_data) AS q2_data, + v3.jsonb_sum(q3_data) AS q3_data, + v3.jsonb_sum(q4_data) AS q4_data, + v3.jsonb_sum(totals_data) AS totals_data + FROM inp WHERE prog_id IS NOT NULL + GROUP BY sec_id, prog_id + ), + + -- ROOT-агрегаты + root_agg AS ( + SELECT + sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, + v3.jsonb_sum(plan_data) AS plan_data, + v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data, + v3.jsonb_sum(approved_data) AS approved_data, + v3.jsonb_sum(contract_summary_data) AS contract_summary_data, + v3.jsonb_sum(allocation_data) AS allocation_data, + v3.jsonb_sum(reserve_data) AS reserve_data, + v3.jsonb_sum(collegial_data) AS collegial_data, + v3.jsonb_sum(ckk_data) AS ckk_data, + v3.jsonb_sum(contract_data) AS contract_data, + v3.jsonb_sum(booking_data) AS booking_data, + v3.jsonb_sum(q1_data) AS q1_data, + v3.jsonb_sum(q2_data) AS q2_data, + v3.jsonb_sum(q3_data) AS q3_data, + v3.jsonb_sum(q4_data) AS q4_data, + v3.jsonb_sum(totals_data) AS totals_data + FROM inp + GROUP BY sec_id + ), + + -- Нулевой JSONB-шаблон (для пустых программ/проектов) + zero AS ( + SELECT '{}'::jsonb AS z + ), + + -- ═══ 5. UNION всех уровней ═════════════════════════════════════════════════ + unioned AS ( + -- ROOT — только из агрегатов (не имеет смысла пустой ROOT без строк) + SELECT + 'ROOT'::VARCHAR AS row_type, 0 AS depth, NULL::INT AS line_id, + jsonb_build_object('section_code', sec_code, 'name', sec_name, 'expense_item_id', sec_id) AS header, + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, 0, 0, 0]::INT[] AS sort_path + FROM root_agg + + UNION ALL + + -- GROUP — из агрегатов (программы с бюджетными строками) + SELECT + 'GROUP', 1, NULL, + jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id), + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, prog_id, 0, 0]::INT[] + FROM prog_agg + + UNION ALL + + -- GROUP — пустые программы (нет ни одной budget_line) + SELECT + 'GROUP', 1, NULL, + jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id), + NULL::jsonb, NULL::jsonb, NULL::jsonb, -- plan, seq_dfip, approved + NULL::jsonb, NULL::jsonb, NULL::jsonb, -- contract_summary, allocation, reserve + NULL::jsonb, NULL::jsonb, -- collegial, ckk + NULL::jsonb, NULL::jsonb, -- contract, booking + NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, -- q1-q4, totals + ARRAY[sec_id, prog_id, 0, 0]::INT[] + FROM all_programs ap + WHERE NOT EXISTS ( + SELECT 1 FROM v3.budget_line bl + WHERE bl.budget_form_id = p_form_id + AND ( + bl.project_id = ap.prog_id -- прямая привязка к программе + OR bl.project_id IN (SELECT id FROM v3.form4_project pr -- через проект + WHERE pr.parent_id = ap.prog_id AND pr.level = 'project') + ) + ) + + UNION ALL + + -- ITEM — из агрегатов (проекты с бюджетными строками) + SELECT + 'ITEM', 2, NULL, + jsonb_build_object('section_code', sec_code, 'name', prj_name, + 'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id), + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, prog_id, prj_id, 0]::INT[] + FROM prj_agg + + UNION ALL + + -- ITEM — пустые проекты (нет budget_line) + SELECT + 'ITEM', 2, NULL, + jsonb_build_object('section_code', sec_code, 'name', prj_name, + 'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id), + NULL::jsonb, NULL::jsonb, NULL::jsonb, + NULL::jsonb, NULL::jsonb, NULL::jsonb, + NULL::jsonb, NULL::jsonb, + NULL::jsonb, NULL::jsonb, + NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, + ARRAY[sec_id, prog_id, prj_id, 0]::INT[] + FROM all_projects apr + WHERE NOT EXISTS ( + SELECT 1 FROM v3.budget_line bl + WHERE bl.budget_form_id = p_form_id + AND bl.project_id = apr.prj_id + ) + + UNION ALL + + -- INPUT — все строки бюджета + SELECT + 'INPUT', 3, line_id, + header || jsonb_build_object( + 'project_id', prj_id, 'project_name', prj_name, + 'program_id', prog_id, 'program_name', prog_name + ), + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, COALESCE(prog_id, 999999), COALESCE(prj_id, 999999), line_id]::INT[] + FROM inp + ) + + SELECT + u.row_type, u.depth, + ROW_NUMBER() OVER (ORDER BY u.sort_path) AS sort_order, + jsonb_build_object( + 'line_id', u.line_id, + 'header', u.header, + 'plan', u.plan_data, + 'seq_dfip', u.seq_dfip_data, + 'approved', u.approved_data, + 'contract_summary', u.contract_summary_data, + 'allocation', u.allocation_data, + 'reserve', u.reserve_data, + 'collegial', u.collegial_data, + 'ckk', u.ckk_data, + 'contract', u.contract_data, + 'booking', u.booking_data, + 'q1', u.q1_data, + 'q2', u.q2_data, + 'q3', u.q3_data, + 'q4', u.q4_data, + 'totals', u.totals_data + ) AS data + FROM unioned u + ORDER BY u.sort_path; +$function$ +LANGUAGE sql STABLE; + + + +-- ════════════════════════════════════════════════════════════════════════════ +-- v3.add_budget_line — создать новую INPUT-строку (budget_line) для FORM_1/2/4. +-- +-- Способы передать expense_item: +-- путь A — численный id: p_expense_item_id (берётся из header.expense_item_id) +-- путь B — резолв по коду: p_sheet + p_item_id [+ p_section_code для коллизий] +-- [+ p_direction для FORM_1] +-- +-- Авто-direction: +-- FORM_2 → 'Support' +-- FORM_4 → 'Development' +-- FORM_1/OPER → NULL +-- FORM_1/AHR|CAP → p_direction обязателен (Support|Development) +-- Если p_direction передан и противоречит авто-значению — ошибка. +-- +-- Дубликаты на одном expense_item разрешены (UNIQUE не накладывается). +-- vsp_id опционален — для FORM_2 фронт может не знать ВСП на момент +-- создания, дозаполнит позже через upd_form_cell(.., 'header.vsp_id', ..). +-- +-- FORM_3 не поддерживается — там строки живут в rf_project_report_line, +-- API будет в add_form3_line.sql (отдельная итерация). +-- +-- Возврат: вся иерархия (ROOT/GROUP/ITEM/SUB_ITEM/SECTION/LEAF) + новая +-- INPUT (отфильтрована по новому line_id). Новый id извлекается из +-- (data->>'line_id')::INT первой INPUT-строки. +-- ════════════════════════════════════════════════════════════════════════════ + +DROP FUNCTION IF EXISTS v3.add_budget_line(INT, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, INT, VARCHAR); +DROP FUNCTION IF EXISTS v3.add_budget_line(INT, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, INT, VARCHAR, VARCHAR, DATE); + +CREATE FUNCTION v3.add_budget_line( + p_form_id INT, + p_expense_item_id INT DEFAULT NULL, + p_sheet VARCHAR DEFAULT NULL, + p_item_id VARCHAR DEFAULT NULL, + p_section_code VARCHAR DEFAULT NULL, + p_direction VARCHAR DEFAULT NULL, + p_name VARCHAR DEFAULT NULL, + p_internal_order VARCHAR DEFAULT NULL, + p_vsp_id INT DEFAULT NULL, + p_project_id INT DEFAULT NULL, + p_justification VARCHAR DEFAULT NULL, + -- сателлитные листы (AHR_RENT/UTILITY/SECURITY) используют дополнительно: + p_contract_number VARCHAR DEFAULT NULL, + p_contract_end_date DATE DEFAULT NULL +) +RETURNS TABLE ( + row_type VARCHAR, + depth INT, + sort_order BIGINT, + data JSONB +) +AS $function$ +DECLARE + v_form_type VARCHAR; + v_eid INT; + v_sheet VARCHAR; + v_auto_dir VARCHAR; + v_final_dir VARCHAR; + v_new_id INT; + v_cnt INT; + v_sat_table TEXT; + v_sat_eid INT; + v_parent_line INT; +BEGIN + -- 1. form_id → form_type + SELECT form_type_code INTO v_form_type FROM v3.budget_form WHERE id = p_form_id; + IF v_form_type IS NULL THEN + RAISE EXCEPTION 'budget_form id=% не существует', p_form_id; + END IF; + IF v_form_type = 'FORM_3' THEN + RAISE EXCEPTION 'FORM_3 не использует budget_line — см. add_form3_line (TBD)'; + END IF; + + -- ═══ Сателлитные листы (AHR_RENT/UTILITY/SECURITY) ═══════════════════ + -- Фиксированный expense_item_id на лист — UI не передаёт R-код. + -- p_vsp_id обязателен. Опц.: p_contract_number, p_contract_end_date. + -- Внутри: находим/создаём родительскую budget_line с фиксированным ei, + -- затем INSERT в *_detail. Возврат — одна новая LEAF. + IF p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY') THEN + v_sat_table := CASE p_sheet + WHEN 'AHR_RENT' THEN 'rent_detail' + WHEN 'AHR_UTILITY' THEN 'utility_detail' + WHEN 'AHR_SECURITY' THEN 'security_detail' + END; + v_sat_eid := CASE p_sheet + WHEN 'AHR_RENT' THEN 293 -- R061031001 "Аренда_ОН" + WHEN 'AHR_UTILITY' THEN 24 -- 1.05.1. "Коммунальные услуги" + WHEN 'AHR_SECURITY' THEN 779 -- R061062001 "Пультовая охр.ОН" + END; + + IF p_vsp_id IS NULL THEN + RAISE EXCEPTION 'p_vsp_id обязателен для сателлитного листа %', p_sheet; + END IF; + IF NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN + RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id; + END IF; + + -- Находим/создаём родительскую budget_line под фиксированным ei + SELECT id INTO v_parent_line + FROM v3.budget_line + WHERE budget_form_id = p_form_id AND expense_item_id = v_sat_eid + LIMIT 1; + IF v_parent_line IS NULL THEN + INSERT INTO v3.budget_line (budget_form_id, expense_item_id, direction) + VALUES ( + p_form_id, v_sat_eid, + CASE v_form_type WHEN 'FORM_2' THEN 'Support' WHEN 'FORM_4' THEN 'Development' ELSE NULL END + ) RETURNING id INTO v_parent_line; + END IF; + + -- INSERT в сателлит + EXECUTE format( + 'INSERT INTO v3.%I (line_id, vsp_id, contract_number, contract_end_date) ' + || 'VALUES ($1, $2, $3, $4) RETURNING id', + v_sat_table + ) USING v_parent_line, p_vsp_id, p_contract_number, p_contract_end_date + INTO v_new_id; + + -- Возврат — одна новая INPUT-строка (отфильтрована по новому id сателлита) + RETURN QUERY + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form_view(p_form_id, p_sheet, NULL, NULL) v + WHERE v.row_type = 'INPUT' AND (v.data->>'id')::INT = v_new_id; + RETURN; + END IF; + + -- 2. Резолв expense_item_id + IF p_expense_item_id IS NOT NULL THEN + SELECT ei.id, ei.sheet INTO v_eid, v_sheet + FROM v3.expense_item ei WHERE ei.id = p_expense_item_id; + IF v_eid IS NULL THEN + RAISE EXCEPTION 'expense_item id=% не существует', p_expense_item_id; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM v3.expense_item_form_type + WHERE expense_item_id = v_eid AND form_type_code = v_form_type + ) THEN + RAISE EXCEPTION 'expense_item id=% не привязан к form_type %', v_eid, v_form_type; + END IF; + ELSE + IF p_sheet IS NULL OR p_item_id IS NULL THEN + RAISE EXCEPTION 'нужен либо p_expense_item_id, либо (p_sheet + p_item_id)'; + END IF; + + SELECT count(*) INTO v_cnt + FROM v3.expense_item ei + JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id + WHERE ei.item_id = p_item_id + AND ei.sheet = p_sheet + AND eift.form_type_code = v_form_type + AND (p_section_code IS NULL OR ei.section_code = p_section_code) + AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction); + + IF v_cnt = 0 THEN + RAISE EXCEPTION 'expense_item не найден: sheet=%, item_id=%, section=%, direction=%, form=%', + p_sheet, p_item_id, p_section_code, p_direction, v_form_type; + END IF; + IF v_cnt > 1 THEN + RAISE EXCEPTION 'expense_item не уникален (% совпадений): sheet=%, item_id=%, section=%, direction=%. Уточните p_section_code или передайте p_expense_item_id напрямую', + v_cnt, p_sheet, p_item_id, p_section_code, p_direction; + END IF; + + SELECT ei.id, ei.sheet INTO v_eid, v_sheet + FROM v3.expense_item ei + JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id + WHERE ei.item_id = p_item_id + AND ei.sheet = p_sheet + AND eift.form_type_code = v_form_type + AND (p_section_code IS NULL OR ei.section_code = p_section_code) + AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction); + END IF; + + -- 3. Авто-direction (для FORM_1/AHR|CAP — обязателен у юзера) + v_auto_dir := CASE + WHEN v_form_type = 'FORM_2' THEN 'Support' + WHEN v_form_type = 'FORM_4' THEN 'Development' + WHEN v_form_type = 'FORM_1' AND v_sheet = 'OPER' THEN NULL + ELSE NULL + END; + + IF v_form_type = 'FORM_1' AND v_sheet IN ('AHR','CAP') AND p_direction IS NULL THEN + RAISE EXCEPTION 'p_direction обязателен для FORM_1 / sheet=% (Support|Development)', v_sheet; + END IF; + + IF v_form_type IN ('FORM_2','FORM_4') AND p_direction IS NOT NULL AND p_direction <> v_auto_dir THEN + RAISE EXCEPTION 'p_direction=% несовместим с form_type=% (фиксирован=%)', + p_direction, v_form_type, v_auto_dir; + END IF; + + IF v_form_type = 'FORM_1' AND v_sheet = 'OPER' AND p_direction IS NOT NULL THEN + RAISE EXCEPTION 'p_direction должен быть NULL для FORM_1 / sheet=OPER (получено: %)', p_direction; + END IF; + + v_final_dir := COALESCE(p_direction, v_auto_dir); + + -- 4. Sanity на vsp/project + IF p_vsp_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN + RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id; + END IF; + IF p_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.form4_project WHERE id = p_project_id) THEN + RAISE EXCEPTION 'form4_project id=% не существует', p_project_id; + END IF; + + -- 5. INSERT + INSERT INTO v3.budget_line ( + budget_form_id, expense_item_id, name, internal_order, + vsp_id, project_id, direction, justification + ) VALUES ( + p_form_id, v_eid, p_name, p_internal_order, + p_vsp_id, p_project_id, v_final_dir, p_justification + ) RETURNING id INTO v_new_id; + + -- 5а. Аудит: ROW_CREATE + PERFORM v3.log_event( + 'ROW_CREATE', 'ROW_CREATE', + jsonb_build_object( + 'sheet', v_sheet, + 'line_id', v_new_id, + 'expense_item_id', v_eid, + 'direction', v_final_dir, + 'name', p_name, + 'vsp_id', p_vsp_id, + 'project_id', p_project_id + ), + p_form_id + ); + + -- 6. Возврат: только иерархия по пути нового ei + новая INPUT + DECLARE v_ei_path INT[]; + BEGIN + v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]); + RETURN QUERY + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form_view(p_form_id, v_sheet, NULL, v_final_dir) v + WHERE (v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM') + AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path)) + OR (v.row_type = 'INPUT' AND (v.data->>'line_id')::INT = v_new_id) + ORDER BY v.sort_order; + END; +END; +$function$ +LANGUAGE plpgsql VOLATILE; diff --git a/api/src/api/v1/websocket.py b/api/src/api/v1/websocket.py index 0f00d49..a4c16c8 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -14,6 +14,7 @@ from sqlalchemy.exc import IntegrityError from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status from sqlalchemy.ext.asyncio import AsyncSession +from src.repository.sheet_repository import SheetValidationError from src.services.rf_project_report_service import RfProjectReportService from src.services.rf_project_report_line_service import RfProjectReportLineService from src.repository.user_repository import UserRepository @@ -376,6 +377,18 @@ class FormEventProcess: sheet=sheet, direction=direction, ) + case "program_added": + return await self.__add_program( + event_data=event_data["data"], + form_id=form_id, + user=curr_user, + sheet=sheet, + ) + case "project_added": + return await self.__add_project( + event_data=event_data["data"], + user=curr_user, + ) case _: pass @@ -461,8 +474,43 @@ class FormEventProcess: final_result["new_line_id"] = el[3]["line_id"] return final_result return final_result + + async def __add_program( + self, + event_data: dict, + form_id: int, + sheet: str, + user: AppUser, + ) -> list[tuple]: + """ + data = { + name: str + } + """ + return await self.sheet_service.add_program( + form_id=form_id, + sheet=sheet, + name=event_data["name"], + user=user, + ) - + async def __add_project( + self, + event_data: dict, + user: AppUser, + ) -> list[tuple]: + """ + data = { + name: str, + program_id: int + } + """ + return await self.sheet_service.add_project( + name=event_data["name"], + program_id=event_data["program_id"], + user=user, + ) + async def __del_row( self, event_data: dict, @@ -824,6 +872,8 @@ async def process_websocket( if data.get("event") in [ "cell_updated", "row_added", + "program_added", + "project_added", "row_deleted", ]: await manager.broadcast_to_all( @@ -852,6 +902,12 @@ async def process_websocket( data=data, websocket=websocket, ) + except SheetValidationError as e: + data["error"] = e.message + await manager.send_back( + data=data, + websocket=websocket, + ) except Exception as e: tp = type(e) diff --git a/api/src/db/models/__init__.py b/api/src/db/models/__init__.py index d8e60fa..c5bc049 100644 --- a/api/src/db/models/__init__.py +++ b/api/src/db/models/__init__.py @@ -11,6 +11,7 @@ from src.db.models.contract_detail import ContractDetail from src.db.models.contract_summary import ContractSummary from src.db.models.expense_item import ExpenseItem from src.db.models.form3_phase import Form3Phase +from src.db.models.form4_project import Form4Project from src.db.models.form_limit import FormLimit from src.db.models.form_phase import FormPhase from src.db.models.form_type import FormType diff --git a/api/src/db/models/form4_project.py b/api/src/db/models/form4_project.py new file mode 100644 index 0000000..fe74414 --- /dev/null +++ b/api/src/db/models/form4_project.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import enum + +from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class Form4ProjectLevelEnum(str, enum.Enum): + PROGRAM = "program" + PROJECT = "project" + + +class Form4Project(Base): + __tablename__ = "form4_project" + __table_args__ = ( + CheckConstraint( + "level IN ('program', 'project')", name="chk_form4_project_level" + ), + CheckConstraint( + "(level = 'program' AND parent_id IS NULL) OR (level = 'project')", + name="chk_form4_project_parent", + ), + CheckConstraint( + "(level = 'program') = (section_code IS NOT NULL)", + name="chk_form4_project_section_code", + ), + Index( + "ix_form4_project_section_code", + "section_code", + postgresql_where=text("level = 'program'"), + ), + Index("ix_v3_form4_project_parent", "parent_id"), + Index("ix_v3_form4_project_form", "form_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String) + level: Mapped[Form4ProjectLevelEnum] = mapped_column(String) # type: ignore + section_code: Mapped[str | None] = mapped_column(String) + parent_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("v3.form4_project.id") + ) + form_id: Mapped[int] = mapped_column( + Integer, ForeignKey("v3.budget_form.id") + ) + + # parent = relationship("Form4Project", remote_side="Form4Project.id", back_populates="children") + # children: Mapped[list["Form4Project"]] = relationship("Form4Project", back_populates="parent") # type: ignore + # budget_form = relationship("BudgetForm") + # budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="form4_project") # type: ignore diff --git a/api/src/repository/form4_project_repository.py b/api/src/repository/form4_project_repository.py new file mode 100644 index 0000000..2236e8c --- /dev/null +++ b/api/src/repository/form4_project_repository.py @@ -0,0 +1,65 @@ +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.form4_project import Form4Project, Form4ProjectLevelEnum + + +class Form4ProjectRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get( + self, + project_id: int, + level: Form4ProjectLevelEnum | str | None = None, + ) -> Form4Project | None: + where = [Form4Project.id == project_id] + if level is not None: + where.append(Form4Project.level == level) + + return ( + (await self.db.execute(select(Form4Project).where(*where).limit(1))) + .scalars() + .first() + ) + + async def get_list( + self, + level: Form4ProjectLevelEnum | None = None, + form_id: int | None = None, + parent_id: int | None = None, + offset: int | None = None, + limit: int | None = None, + with_count: bool = False, + ) -> list[Form4Project] | tuple[int, list[Form4Project]]: + if with_count: + query = select(func.count().over().label("total_count"), Form4Project) + else: + query = select(Form4Project) + + where = [] + if level is not None: + where.append(Form4Project.level == level) + if form_id is not None: + where.append(Form4Project.form_id == form_id) + if parent_id is not None: + where.append(Form4Project.parent_id == parent_id) + + query = query.where(*where).order_by(Form4Project.id) + + if offset is not None: + query = query.offset(offset) + if limit is not None: + query = query.limit(limit) + + if with_count: + result = (await self.db.execute(query)).all() + if result: + return result[0][0], [res[1] for res in result] + else: + count_query = select(func.count(Form4Project.id)) + if query.whereclause is not None: + count_query = count_query.where(query.whereclause) + return (await self.db.execute(count_query)).scalar(), [] + else: + return (await self.db.execute(query)).scalars().all() diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index cad3323..e17fd5e 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -160,6 +160,59 @@ class SheetRepository: ) ).all() ] + + async def add_program( + self, + form_id: int, + sheet: str, + name: str, + ) -> list[tuple]: + return [ + tuple(r) for r in ( + await self.db.execute( + text( + """ + SELECT project_id, row_type, depth, sort_order, data + FROM v3.add_form4_program( + :p_name, + :p_form_id, + :p_sheet + ) + """ + ), + { + "p_form_id": form_id, + "p_name": name, + "p_sheet": sheet, + } + ) + ).all() + ] + + async def add_project( + self, + name: str, + program_id: int, + ) -> list[tuple]: + return [ + tuple(r) for r in ( + await self.db.execute( + text( + """ + SELECT project_id, row_type, depth, sort_order, data + FROM v3.add_form4_project( + :p_name, + :p_program_id + ) + """ + ), + { + "p_program_id": program_id, + "p_name": name, + } + ) + ).all() + ] async def add_line( self, diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py index 3c9ba78..a4a0fd0 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -1,9 +1,14 @@ from sqlalchemy.ext.asyncio import AsyncSession +from src.db.models.form4_project import Form4ProjectLevelEnum +from src.db.models.role import UserRoleEnum +from src.repository.budget_form_repository import BudgetFormRepository +from src.repository.form4_project_repository import Form4ProjectRepository +from src.repository.user_repository import UserRepository from src.db.models.app_user import AppUser from src.db.models.budget_form import BudgetForm -from src.repository.sheet_repository import SheetRepository +from src.repository.sheet_repository import SheetRepository, SheetValidationError @@ -11,6 +16,9 @@ class SheetService: def __init__(self, db: AsyncSession): self.db = db self.sheet_repo = SheetRepository(db) + self.bf_repo = BudgetFormRepository(db) + self.user_repo = UserRepository(db) + self.f4_project_repo = Form4ProjectRepository(db) async def get( self, @@ -26,28 +34,6 @@ class SheetService: direction=direction, sections=sections, ) - - async def update_cell( - self, - form_id: int, - sheet: str, - direction: str | None, - sections: list[str] | None, - line_id: int, - column: str, - value: str | int | float | None = None, - user: AppUser | None = None, - ) -> list[tuple]: - return await self.sheet_repo.update_cell( - form_id=form_id, - sheet=sheet, - direction=direction, - sections=sections, - line_id=line_id, - column=column, - value=value, - user_id=user.id if user else None, - ) async def update_cells( self, @@ -123,6 +109,66 @@ class SheetService: contract_end_date=contract_end_date, user_id=user.id if user else None, ) + + async def add_program( + self, + form_id: int, + sheet: str, + name: str, + user: AppUser | None = None, + ) -> tuple[int, list[tuple]]: + if user.role_id != UserRoleEnum.ADMIN: + user = await self.user_repo.get( + user_id=user.id, + load_orgs=True, + ) + form = await self.bf_repo.get( + budget_form_id=form_id, + org_unit=[ou.id for ou in user.org_units], + ) + if not form: + raise SheetValidationError("Форма не найдена") + + result = await self.sheet_repo.add_program( + form_id=form_id, + sheet=sheet, + name=name, + ) + if result: + return (result[0][0], [tuple(r[1:]) for r in result]) + return None + + async def add_project( + self, + name: str, + program_id: int, + user: AppUser | None = None, + ) -> tuple[int, list[tuple]]: + if user.role_id != UserRoleEnum.ADMIN: + program = await self.f4_project_repo.get( + project_id=program_id, + level=Form4ProjectLevelEnum.PROGRAM, + ) + if not program: + raise SheetValidationError("Программа не найдена") + + user = await self.user_repo.get( + user_id=user.id, + load_orgs=True, + ) + form = await self.bf_repo.get( + budget_form_id=program.form_id, + org_unit=[ou.id for ou in user.org_units], + ) + if not form: + raise SheetValidationError("Программа не найдена") + result = await self.sheet_repo.add_project( + program_id=program_id, + name=name, + ) + if result: + return (result[0][0], [tuple(r[1:]) for r in result]) + return None async def delete_line( self, -- 2.47.2