AURORA-1984: проекты ПД без программы (+1979, 1980, 1989) #306

Merged
aep merged 13 commits from feat/305-aurora-1984-pd-standalone-projects into test 2026-11-11 18:12:24 +03:00
2 changed files with 253 additions and 6 deletions
Showing only changes of commit 209d36e832 - Show all commits

View File

@ -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 (+самостоятельные),

View File

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