From 209d36e832134f2d58070447f3250370a13098f5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 22:23:28 +0400 Subject: [PATCH] =?UTF-8?q?AURORA-1984:=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5,=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B8=20?= =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20=D0=B1=D0=B5=D0=B7=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D1=8B=20=D0=B2=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../sql/0059_form4_standalone_project.sql | 192 +++++++++++++++++- .../test_form4_standalone_project.py | 67 +++++- 2 files changed, 253 insertions(+), 6 deletions(-) diff --git a/api/alembic/versions/sql/0059_form4_standalone_project.sql b/api/alembic/versions/sql/0059_form4_standalone_project.sql index e5ec0ff..722e85f 100644 --- a/api/alembic/versions/sql/0059_form4_standalone_project.sql +++ b/api/alembic/versions/sql/0059_form4_standalone_project.sql @@ -17,8 +17,196 @@ ALTER TABLE v3.form4_project ADD CONSTRAINT chk_form4_project_section_code ALTER TABLE v3.form4_project ADD CONSTRAINT chk_form4_project_standalone_form CHECK (parent_id IS NOT NULL OR form_id IS NOT NULL); --- ═══ 2. Функции add/upd/del_form4_project (Task 2) ═══════════════════════════ --- >>> здесь будут add_form4_project / upd_form4_project / del_form4_project <<< +-- ═══ 2. Функции add/upd/del_form4_project ════════════════════════════════════ +-- Права: создание — v3.can_edit; переименование и удаление — как раньше (R-6). +DROP FUNCTION v3.add_form4_project(varchar, int4); + +CREATE FUNCTION v3.add_form4_project( + p_name varchar, + p_program_id int DEFAULT NULL, + p_form_id int DEFAULT NULL, + p_section_code varchar DEFAULT NULL +) + RETURNS TABLE(project_id integer, row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE plpgsql +AS $function$ +#variable_conflict use_column +DECLARE + v_pid INT; + v_section_code VARCHAR; + v_form_id INT; + v_user_id INT; + v_decision JSONB; + v_msg TEXT; +BEGIN + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + IF NOT ((p_program_id IS NOT NULL AND p_form_id IS NULL AND p_section_code IS NULL) + OR (p_program_id IS NULL AND p_form_id IS NOT NULL AND p_section_code IS NOT NULL)) THEN + RAISE EXCEPTION 'Укажите либо программу, либо форму и раздел'; + END IF; + + IF p_program_id IS NOT NULL THEN + 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 fp.form_id, fp.section_code INTO v_form_id, v_section_code + FROM v3.form4_project fp + WHERE fp.id = p_program_id AND fp.level = 'program'; + ELSE + 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 NOT IN ('AHR', 'CAP', 'OPER') THEN + RAISE EXCEPTION 'Раздел % недоступен для проекта', p_section_code; + END IF; + v_form_id := p_form_id; + v_section_code := p_section_code; + END IF; + + v_user_id := v3.current_user_id(); + v_decision := v3.can_edit(v_form_id, v_section_code, NULL, NULL, v_user_id); + + IF NOT (v_decision->>'ok')::boolean THEN + IF v_decision->>'code' = 'role_not_allowed' THEN + RAISE EXCEPTION 'role_not_allowed: Невозможно вставить строку'; + + ELSIF v_decision->>'code' = 'org_not_assigned' THEN + RAISE EXCEPTION 'org_not_assigned: Невозможно вставить строку'; + + ELSIF v_decision->>'code' = 'window_closed' THEN + IF v_decision ? 'opens_at' THEN + v_msg := format('window_closed: opens %s', v_decision->>'opens_at'); + ELSIF v_decision->>'closes_at' IS NOT NULL THEN + v_msg := format('window_closed: closed %s', v_decision->>'closes_at'); + ELSE + v_msg := 'window_closed: no phase'; + END IF; + RAISE EXCEPTION '%', v_msg; + + ELSE + RAISE EXCEPTION '%', v_decision->>'code'; + END IF; + END IF; + + IF p_program_id IS NOT NULL THEN + 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; + ELSE + INSERT INTO v3.form4_project (name, level, parent_id, form_id, section_code) + VALUES (p_name, 'project', NULL, v_form_id, v_section_code) + RETURNING id INTO v_pid; + END IF; + + 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, + 'section_code', v_section_code, + 'form_type', 'FORM_4' + )), + v_form_id, NULL, NULL + ); + + 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$ +; + +CREATE OR REPLACE FUNCTION v3.upd_form4_project(p_project_id INT, p_name VARCHAR) +RETURNS TABLE (project_id INT, row_type VARCHAR, depth INT, sort_order BIGINT, data JSONB) +AS $function$ +#variable_conflict use_column +DECLARE + v_project v3.form4_project%ROWTYPE; + v_program v3.form4_project%ROWTYPE; + v_section_code VARCHAR; +BEGIN + SELECT * INTO v_project FROM v3.form4_project fp + WHERE fp.id = p_project_id AND fp.level = 'project'; + IF NOT FOUND THEN + RAISE EXCEPTION 'Проект id=% не найден', p_project_id; + END IF; + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + + SELECT * INTO v_program FROM v3.form4_project fp WHERE fp.id = v_project.parent_id; + v_section_code := COALESCE(v_program.section_code, v_project.section_code); + UPDATE v3.form4_project SET name = trim(p_name) WHERE id = p_project_id; + PERFORM v3.log_event( + 'PROJECT_UPDATE', 'PROJECT', + jsonb_build_object('project_id', p_project_id, 'name', trim(p_name), + 'level', 'project', 'parent_id', v_project.parent_id, + 'form_id', v_project.form_id, 'form_type', 'FORM_4'), + v_project.form_id, NULL, NULL + ); + + RETURN QUERY + SELECT p_project_id, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(v_project.form_id, v_section_code, NULL) v + WHERE (v.row_type = 'ITEM' AND (v.data->'header'->>'project_id')::INT = p_project_id) + OR (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = v_project.parent_id) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_section_code) + ORDER BY v.sort_order; +END; +$function$ +LANGUAGE plpgsql VOLATILE; + +CREATE OR REPLACE FUNCTION v3.del_form4_project(p_project_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_project v3.form4_project%ROWTYPE; + v_program v3.form4_project%ROWTYPE; + v_section_code VARCHAR; +BEGIN + SELECT * INTO v_project FROM v3.form4_project fp + WHERE fp.id = p_project_id AND fp.level = 'project'; + IF NOT FOUND THEN + RAISE EXCEPTION 'Проект id=% не найден', p_project_id; + END IF; + IF EXISTS (SELECT 1 FROM v3.budget_line bl WHERE bl.project_id = p_project_id) THEN + RAISE EXCEPTION 'Нельзя удалить проект, содержащий строки бюджета'; + END IF; + + SELECT * INTO v_program FROM v3.form4_project fp WHERE fp.id = v_project.parent_id; + v_section_code := COALESCE(v_program.section_code, v_project.section_code); + DELETE FROM v3.form4_project WHERE id = p_project_id; + PERFORM v3.log_event( + 'PROJECT_DELETE', 'PROJECT', + jsonb_build_object('project_id', p_project_id, 'name', v_project.name, + 'level', 'project', 'parent_id', v_project.parent_id, + 'form_id', v_project.form_id, 'form_type', 'FORM_4'), + v_project.form_id, NULL, NULL + ); + + RETURN QUERY + SELECT p_project_id, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(v_project.form_id, v_section_code, NULL) v + WHERE (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = v_project.parent_id) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_section_code) + ORDER BY v.sort_order; +END; +$function$ +LANGUAGE plpgsql VOLATILE; -- ═══ 3. Лист ═════════════════════════════════════════════════════════════════ -- Тело — копия sql/0053_form2_ahr_source_sheet.sql, изменены: all_projects (+самостоятельные), diff --git a/api/tests/integration/test_form4_standalone_project.py b/api/tests/integration/test_form4_standalone_project.py index 845bf61..f247c25 100644 --- a/api/tests/integration/test_form4_standalone_project.py +++ b/api/tests/integration/test_form4_standalone_project.py @@ -14,12 +14,15 @@ def f4(client, db_session, admin_tokens, auth_headers): return F4(client, db_session, auth_headers(admin_tokens)) -def standalone(f4, name="solo", section="CAP"): +def add(f4, name="solo", section="CAP"): return f4.sql( - "INSERT INTO v3.form4_project (name, level, form_id, section_code) " - "VALUES (:n, 'project', :f, :s) RETURNING id", + "SELECT project_id, row_type FROM v3.add_form4_project(:n, NULL, :f, :s)", n=name, f=f4.form_id, s=section, - )[0][0] + ) + + +def standalone(f4, name="solo", section="CAP"): + return add(f4, name, section)[0][0] def outline(rows): @@ -78,6 +81,7 @@ def test_cell_patch_response(f4, client): assert resp.status_code == 200, resp.text by_type = {r["row_type"]: r["data"] for r in _loads(resp.text)["result"]} assert {"INPUT", "ITEM", "ROOT"} <= set(by_type) and "GROUP" not in by_type + assert Decimal(str(by_type["ITEM"]["plan"]["q1"])) == 20 # значение дошло до ITEM (T1.1) fresh = next(r for r in f4.sheet() if r["row_type"] == "ITEM") assert strip_nulls(by_type["ITEM"]["plan"]) == fresh["data"]["plan"] # GET без null (AURORA-1848) @@ -110,3 +114,58 @@ def test_sheet_without_standalone_equals_0053(f4): old = [tuple(r) for r in f4.sql(q.format("pg_temp.v_form4_sheet_old"), f=f4.form_id)] new = [tuple(r) for r in f4.sql(q.format("v3.v_form4_sheet_jsonb"), f=f4.form_id)] assert new == old + + +def test_add_in_empty_section_returns_item_only(f4): + assert [r[1] for r in add(f4)] == ["ITEM"] + + +def test_add_with_root_returns_item_and_root(f4): + f4.line(f4.project(f4.program())) + assert sorted(r[1] for r in add(f4)) == ["ITEM", "ROOT"] + + +def test_add_rejects_blank_name(f4): + with pytest.raises(Exception, match="p_name обязателен"): + add(f4, name=" ") + + +def test_add_rejects_no_target(f4): + with pytest.raises(Exception, match="Укажите либо программу, либо форму и раздел"): + f4.sql("SELECT * FROM v3.add_form4_project('x')") + + +def test_add_rejects_both_targets(f4): + prog = f4.program() + with pytest.raises(Exception, match="Укажите либо программу, либо форму и раздел"): + f4.sql("SELECT * FROM v3.add_form4_project('x', :p, :f, 'CAP')", p=prog, f=f4.form_id) + + +def test_add_rejects_foreign_section(f4): + with pytest.raises(Exception, match="Раздел SMETA недоступен для проекта"): + add(f4, section="SMETA") + + +def test_two_argument_call_still_creates_in_program(f4): + prog = f4.program(); prj = f4.project(prog) + item = next(r for r in f4.sheet() if r["row_type"] == "ITEM") + assert (item["data"]["header"]["program_id"], item["data"]["header"]["project_id"]) == (prog, prj) + + +def test_rename_standalone(f4): + solo = standalone(f4) + rows = f4.sql("SELECT row_type, data::text FROM v3.upd_form4_project(:p, ' Новое ')", p=solo) + assert [r[0] for r in rows] == ["ITEM"] + assert _loads(rows[0][1])["header"]["name"] == "Новое" + + +def test_delete_empty_standalone_in_empty_section_returns_nothing(f4): + solo = standalone(f4) + assert f4.sql("SELECT * FROM v3.del_form4_project(:p)", p=solo) == [] + assert outline(f4.sheet()) == [] + + +def test_delete_standalone_with_line_rejected(f4): + solo = standalone(f4); f4.line(solo) + with pytest.raises(Exception, match="Нельзя удалить проект, содержащий строки бюджета"): + f4.sql("SELECT * FROM v3.del_form4_project(:p)", p=solo)