AURORA-1984: проекты ПД без программы (+1979, 1980, 1989) #306
15
api/alembic/versions/0059_form4_standalone_project.py
Normal file
15
api/alembic/versions/0059_form4_standalone_project.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from sql_migration import run_sql_file_migration
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0059"
|
||||||
|
down_revision = "0058"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
run_sql_file_migration("0059_form4_standalone_project.sql")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
833
api/alembic/versions/sql/0059_form4_standalone_project.sql
Normal file
833
api/alembic/versions/sql/0059_form4_standalone_project.sql
Normal file
@ -0,0 +1,833 @@
|
|||||||
|
-- AURORA-1984: проект без программы (самостоятельный проект формы 4).
|
||||||
|
-- Весь файл: ограничения модели, v3.add_form4_project / upd_form4_project / del_form4_project
|
||||||
|
-- и лист v3.v_form4_sheet_jsonb.
|
||||||
|
-- Самостоятельный проект: level='project', parent_id IS NULL, section_code и form_id заданы.
|
||||||
|
|
||||||
|
-- ═══ 1. Ограничения ══════════════════════════════════════════════════════════
|
||||||
|
DO $do$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM v3.form4_project WHERE parent_id IS NULL AND form_id IS NULL) THEN
|
||||||
|
RAISE EXCEPTION 'form4_project: есть узлы без родителя и без form_id (AURORA-1984)';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$do$;
|
||||||
|
|
||||||
|
ALTER TABLE v3.form4_project DROP CONSTRAINT chk_form4_project_section_code;
|
||||||
|
ALTER TABLE v3.form4_project ADD CONSTRAINT chk_form4_project_section_code
|
||||||
|
CHECK (((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL));
|
||||||
|
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 ════════════════════════════════════
|
||||||
|
-- Права: создание — 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 (+самостоятельные),
|
||||||
|
-- соединение prj_agg (IS NOT DISTINCT FROM по prog_id), sort_path ITEM и INPUT.
|
||||||
|
CREATE OR REPLACE FUNCTION v3.v_form4_sheet_jsonb(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL::text[])
|
||||||
|
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SET jit TO 'off' -- оценка плана выше порога JIT, компиляция дороже выполнения (AURORA-1846)
|
||||||
|
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
|
||||||
|
),
|
||||||
|
tp_agg AS (
|
||||||
|
SELECT 1 AS tp_id,
|
||||||
|
jsonb_build_object(
|
||||||
|
'q1', 0, 'q2', 0,
|
||||||
|
'q3', 0, 'q4', 0,
|
||||||
|
'year', 0, 'comment', null
|
||||||
|
) AS tp_plan,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'q1', 0, 'q2', 0,
|
||||||
|
'q3', 0, 'q4', 0,
|
||||||
|
'year', 0,
|
||||||
|
'justification', null
|
||||||
|
) AS tp_seq_dfip,
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'q1', 0, 'q2', 0,
|
||||||
|
'q3', 0, 'q4', 0,
|
||||||
|
'year', 0
|
||||||
|
) AS tp_approved,
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'total', null,
|
||||||
|
'reference', null,
|
||||||
|
'counterparty', null,
|
||||||
|
'deadline', null,
|
||||||
|
'comment', null,
|
||||||
|
'future_y1', null,
|
||||||
|
'future_y2', null,
|
||||||
|
'other_ssp', null,
|
||||||
|
'centralized_flag', null
|
||||||
|
) AS tp_contract_summary,
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'contract_ref', null,
|
||||||
|
'allocation_purpose', null
|
||||||
|
) AS tp_allocation,
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'q1', 0, 'q2', 0,
|
||||||
|
'q3', 0, 'q4', 0,
|
||||||
|
'year', 0,
|
||||||
|
'justification', null
|
||||||
|
) AS tp_reserve,
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'approved', null,
|
||||||
|
'protocol', null,
|
||||||
|
'note', null
|
||||||
|
) AS tp_collegial,
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'ceiling', null,
|
||||||
|
'q1', null,
|
||||||
|
'q2', null,
|
||||||
|
'q3', null,
|
||||||
|
'q4', null,
|
||||||
|
'rf_schedule', null,
|
||||||
|
'delivery_deadline', null,
|
||||||
|
'proc_plan', null,
|
||||||
|
'proc_method', null,
|
||||||
|
'comment', null
|
||||||
|
) AS tp_ckk,
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'act', null,
|
||||||
|
'date', null,
|
||||||
|
'scheme', null,
|
||||||
|
'addenda', null,
|
||||||
|
'ceiling', null,
|
||||||
|
'comment', null,
|
||||||
|
'subject', null,
|
||||||
|
'currency', null,
|
||||||
|
'deadline', null,
|
||||||
|
'vat_rate', null,
|
||||||
|
'reference', null,
|
||||||
|
'q1', null,
|
||||||
|
'q2', null,
|
||||||
|
'q3', null,
|
||||||
|
'q4', null,
|
||||||
|
'rf_schedule', null,
|
||||||
|
'counterparty', null,
|
||||||
|
'exchange_rate', null,
|
||||||
|
'amount_foreign', null
|
||||||
|
) AS tp_contract,
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'y2026', jsonb_build_object('q1', null, 'q2', null, 'q3', null, 'q4', null),
|
||||||
|
'y2027', jsonb_build_object('q1', null, 'q2', null, 'q3', null, 'q4', null)
|
||||||
|
) AS tp_booking,
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'total', 0,
|
||||||
|
'adj_rf', null,
|
||||||
|
'pay_ho', null,
|
||||||
|
'pay_rf', null,
|
||||||
|
'adj_ssp', null,
|
||||||
|
'booking', null,
|
||||||
|
'pay_act', null,
|
||||||
|
'pay_date', null,
|
||||||
|
'actual_m1', null,
|
||||||
|
'actual_m2', null,
|
||||||
|
'actual_m3', null,
|
||||||
|
'pay_amount', null,
|
||||||
|
'adj_comment', null,
|
||||||
|
'adj_current', null,
|
||||||
|
'adj_reserve', null,
|
||||||
|
'pay_comment', null,
|
||||||
|
'transfer_q2', null,
|
||||||
|
'transfer_q3', null,
|
||||||
|
'transfer_q4', null,
|
||||||
|
'transfer_econ', null,
|
||||||
|
'actual_quarter', 0,
|
||||||
|
'corrected_plan', 0,
|
||||||
|
'residual_after_actual', 0,
|
||||||
|
'residual_after_booking', 0
|
||||||
|
) AS tp_q1,
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'target_change', null,
|
||||||
|
'base_correction', null,
|
||||||
|
'base_correction_comment', null,
|
||||||
|
'revision_inc', null,
|
||||||
|
'revision_seq', null,
|
||||||
|
'revision_comment', null,
|
||||||
|
'new_plan', 0,
|
||||||
|
'adj_current', null,
|
||||||
|
'adj_ssp', null,
|
||||||
|
'adj_rf', null,
|
||||||
|
'adj_reserve', null,
|
||||||
|
'adj_comment', null,
|
||||||
|
'corrected_plan', 0,
|
||||||
|
'pay_date', null,
|
||||||
|
'pay_amount', null,
|
||||||
|
'pay_ho', null,
|
||||||
|
'pay_rf', null,
|
||||||
|
'pay_comment', null,
|
||||||
|
'pay_act', null,
|
||||||
|
'booking', null,
|
||||||
|
'actual_m1', null,
|
||||||
|
'actual_m2', null,
|
||||||
|
'actual_m3', null,
|
||||||
|
'actual_quarter', 0,
|
||||||
|
'residual_after_booking', 0,
|
||||||
|
'residual_after_actual', 0,
|
||||||
|
'transfer_q3', null,
|
||||||
|
'transfer_q4', null,
|
||||||
|
'transfer_econ', null,
|
||||||
|
'total', 0
|
||||||
|
) AS tp_q2,
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'target_change', null,
|
||||||
|
'base_correction', null,
|
||||||
|
'base_correction_comment', null,
|
||||||
|
'revision_inc', null,
|
||||||
|
'revision_seq', null,
|
||||||
|
'revision_comment', null,
|
||||||
|
'new_plan', 0,
|
||||||
|
'adj_current', null,
|
||||||
|
'adj_ssp', null,
|
||||||
|
'adj_rf', null,
|
||||||
|
'adj_reserve', null,
|
||||||
|
'adj_comment', null,
|
||||||
|
'corrected_plan', 0,
|
||||||
|
'pay_date', null,
|
||||||
|
'pay_amount', null,
|
||||||
|
'pay_ho', null,
|
||||||
|
'pay_rf', null,
|
||||||
|
'pay_comment', null,
|
||||||
|
'pay_act', null,
|
||||||
|
'booking', null,
|
||||||
|
'actual_m1', null,
|
||||||
|
'actual_m2', null,
|
||||||
|
'actual_m3', null,
|
||||||
|
'actual_quarter', 0,
|
||||||
|
'residual_after_booking', 0,
|
||||||
|
'residual_after_actual', 0,
|
||||||
|
'transfer_q4', null,
|
||||||
|
'transfer_econ', null,
|
||||||
|
'total', 0
|
||||||
|
) AS tp_q3,
|
||||||
|
|
||||||
|
jsonb_build_object(
|
||||||
|
'target_change', null,
|
||||||
|
'base_correction', null,
|
||||||
|
'base_correction_comment', null,
|
||||||
|
'revision_inc', null,
|
||||||
|
'revision_seq', null,
|
||||||
|
'revision_comment', null,
|
||||||
|
'new_plan', 0,
|
||||||
|
'adj_current', null,
|
||||||
|
'adj_ssp', null,
|
||||||
|
'adj_rf', null,
|
||||||
|
'adj_reserve', null,
|
||||||
|
'adj_comment', null,
|
||||||
|
'corrected_plan', 0,
|
||||||
|
'pay_date', null,
|
||||||
|
'pay_amount', null,
|
||||||
|
'pay_ho', null,
|
||||||
|
'pay_rf', null,
|
||||||
|
'pay_comment', null,
|
||||||
|
'pay_act', null,
|
||||||
|
'booking', null,
|
||||||
|
'actual_m1', null,
|
||||||
|
'actual_m2', null,
|
||||||
|
'actual_m3', null,
|
||||||
|
'actual_spod', null,
|
||||||
|
'actual_quarter', 0,
|
||||||
|
'residual_after_booking', 0,
|
||||||
|
'residual_after_actual', 0,
|
||||||
|
'transfer_econ', null,
|
||||||
|
'total', 0
|
||||||
|
) AS tp_q4,
|
||||||
|
jsonb_build_object(
|
||||||
|
'fact_year', 0
|
||||||
|
) AS tp_totals
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
),
|
||||||
|
-- ═══ 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)
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- самостоятельные проекты (AURORA-1984): без программы, раздел хранит сам проект
|
||||||
|
SELECT
|
||||||
|
prj.id AS prj_id,
|
||||||
|
prj.name AS prj_name,
|
||||||
|
NULL::INT AS prog_id,
|
||||||
|
sec.id AS sec_id,
|
||||||
|
sec.section_code AS sec_code,
|
||||||
|
sec.name AS sec_name
|
||||||
|
FROM v3.form4_project prj
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT id, section_code, name
|
||||||
|
FROM v3.expense_item
|
||||||
|
WHERE section_code = prj.section_code AND depth = 0
|
||||||
|
LIMIT 1
|
||||||
|
) sec ON TRUE
|
||||||
|
WHERE prj.level = 'project' AND prj.parent_id IS NULL
|
||||||
|
AND prj.section_code = p_sheet AND prj.form_id = p_form_id
|
||||||
|
AND (p_sections IS NULL OR prj.section_code = ANY(p_sections))
|
||||||
|
),
|
||||||
|
|
||||||
|
-- ═══ 4. Агрегаты с финансовыми данными (только из inp) ════════════════════
|
||||||
|
-- Замена рекурсивного агрегата v3.jsonb_sum (AURORA-1846): листья jsonb
|
||||||
|
-- разворачиваются в строки, суммируются GROUPING SETS по трём уровням
|
||||||
|
-- (раздел / программа / проект) и собираются обратно в jsonb.
|
||||||
|
-- Правила те же, что у v3.jsonb_numeric_sum: числа складываются, нечисловые
|
||||||
|
-- игнорируются, если по пути есть число; текст/null сохраняются, если путь
|
||||||
|
-- встретился в группе один раз; иначе JSON null; NULL-секция даёт NULL.
|
||||||
|
|
||||||
|
-- Имена групп (без jsonb)
|
||||||
|
prj_names 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
|
||||||
|
FROM inp WHERE prj_id IS NOT NULL
|
||||||
|
GROUP BY sec_id, prog_id, prj_id
|
||||||
|
),
|
||||||
|
prog_names AS (
|
||||||
|
SELECT
|
||||||
|
sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name,
|
||||||
|
prog_id, MAX(prog_name) AS prog_name
|
||||||
|
FROM inp WHERE prog_id IS NOT NULL
|
||||||
|
GROUP BY sec_id, prog_id
|
||||||
|
),
|
||||||
|
root_names AS (
|
||||||
|
SELECT
|
||||||
|
sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name
|
||||||
|
FROM inp
|
||||||
|
GROUP BY sec_id
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Листья jsonb: (колонка, ключ 1-го уровня, ключ 2-го уровня или NULL, значение)
|
||||||
|
leaves AS (
|
||||||
|
SELECT
|
||||||
|
i.sec_id, i.prog_id, i.prj_id,
|
||||||
|
c.col, e1.key AS k1, e2.key AS k2,
|
||||||
|
COALESCE(e2.value, e1.value) AS val
|
||||||
|
FROM inp i
|
||||||
|
CROSS JOIN LATERAL (VALUES
|
||||||
|
('plan', i.plan_data),
|
||||||
|
('seq_dfip', i.seq_dfip_data),
|
||||||
|
('approved', i.approved_data),
|
||||||
|
('contract_summary', i.contract_summary_data),
|
||||||
|
('allocation', i.allocation_data),
|
||||||
|
('reserve', i.reserve_data),
|
||||||
|
('collegial', i.collegial_data),
|
||||||
|
('ckk', i.ckk_data),
|
||||||
|
('contract', i.contract_data),
|
||||||
|
('booking', i.booking_data),
|
||||||
|
('q1', i.q1_data),
|
||||||
|
('q2', i.q2_data),
|
||||||
|
('q3', i.q3_data),
|
||||||
|
('q4', i.q4_data),
|
||||||
|
('totals', i.totals_data)
|
||||||
|
) AS c(col, v)
|
||||||
|
CROSS JOIN LATERAL jsonb_each(
|
||||||
|
CASE WHEN jsonb_typeof(c.v) = 'object' THEN c.v END
|
||||||
|
) AS e1
|
||||||
|
LEFT JOIN LATERAL jsonb_each(
|
||||||
|
CASE WHEN jsonb_typeof(e1.value) = 'object' THEN e1.value END
|
||||||
|
) AS e2 ON TRUE
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Суммы листьев сразу по трём уровням (lvl: 3 раздел, 1 программа, 0 проект)
|
||||||
|
leaf_agg AS (
|
||||||
|
SELECT
|
||||||
|
GROUPING(prog_id, prj_id) AS lvl,
|
||||||
|
sec_id, prog_id, prj_id, col, k1, k2,
|
||||||
|
COUNT(*) AS n,
|
||||||
|
COUNT(*) FILTER (WHERE jsonb_typeof(val) = 'number') AS n_num,
|
||||||
|
SUM((val #>> '{}')::numeric) FILTER (WHERE jsonb_typeof(val) = 'number') AS s,
|
||||||
|
(array_agg(val))[1] AS one
|
||||||
|
FROM leaves
|
||||||
|
GROUP BY GROUPING SETS (
|
||||||
|
(sec_id, col, k1, k2),
|
||||||
|
(sec_id, prog_id, col, k1, k2),
|
||||||
|
(sec_id, prog_id, prj_id, col, k1, k2)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
leaf_val AS (
|
||||||
|
SELECT
|
||||||
|
lvl, sec_id, prog_id, prj_id, col, k1, k2,
|
||||||
|
CASE
|
||||||
|
WHEN n_num > 0 THEN to_jsonb(s)
|
||||||
|
WHEN n = 1 THEN one
|
||||||
|
ELSE 'null'::jsonb
|
||||||
|
END AS v
|
||||||
|
FROM leaf_agg
|
||||||
|
WHERE NOT (lvl = 1 AND prog_id IS NULL)
|
||||||
|
AND NOT (lvl = 0 AND prj_id IS NULL)
|
||||||
|
),
|
||||||
|
-- Сборка второго уровня вложенности (booking.y2026.q1 → объект по ключу k1)
|
||||||
|
k1_val AS (
|
||||||
|
SELECT
|
||||||
|
lvl, sec_id, prog_id, prj_id, col, k1,
|
||||||
|
CASE
|
||||||
|
WHEN bool_or(k2 IS NOT NULL) THEN jsonb_object_agg(k2, v) FILTER (WHERE k2 IS NOT NULL)
|
||||||
|
ELSE (array_agg(v))[1]
|
||||||
|
END AS v
|
||||||
|
FROM leaf_val
|
||||||
|
GROUP BY lvl, sec_id, prog_id, prj_id, col, k1
|
||||||
|
),
|
||||||
|
-- Сборка по колонкам: одна строка на группу
|
||||||
|
lvl_data AS (
|
||||||
|
SELECT
|
||||||
|
lvl, sec_id, prog_id, prj_id,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'plan') AS plan_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'seq_dfip') AS seq_dfip_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'approved') AS approved_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'contract_summary') AS contract_summary_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'allocation') AS allocation_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'reserve') AS reserve_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'collegial') AS collegial_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'ckk') AS ckk_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'contract') AS contract_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'booking') AS booking_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'q1') AS q1_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'q2') AS q2_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'q3') AS q3_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'q4') AS q4_data,
|
||||||
|
jsonb_object_agg(k1, v) FILTER (WHERE col = 'totals') AS totals_data
|
||||||
|
FROM k1_val
|
||||||
|
GROUP BY lvl, sec_id, prog_id, prj_id
|
||||||
|
),
|
||||||
|
|
||||||
|
prj_agg AS (
|
||||||
|
SELECT n.*, d.plan_data, d.seq_dfip_data, d.approved_data, d.contract_summary_data, d.allocation_data, d.reserve_data, d.collegial_data, d.ckk_data, d.contract_data, d.booking_data, d.q1_data, d.q2_data, d.q3_data, d.q4_data, d.totals_data
|
||||||
|
FROM prj_names n
|
||||||
|
LEFT JOIN lvl_data d
|
||||||
|
ON d.lvl = 0 AND d.sec_id = n.sec_id AND d.prog_id IS NOT DISTINCT FROM n.prog_id AND d.prj_id = n.prj_id
|
||||||
|
),
|
||||||
|
prog_agg AS (
|
||||||
|
SELECT n.*, d.plan_data, d.seq_dfip_data, d.approved_data, d.contract_summary_data, d.allocation_data, d.reserve_data, d.collegial_data, d.ckk_data, d.contract_data, d.booking_data, d.q1_data, d.q2_data, d.q3_data, d.q4_data, d.totals_data
|
||||||
|
FROM prog_names n
|
||||||
|
LEFT JOIN lvl_data d
|
||||||
|
ON d.lvl = 1 AND d.sec_id = n.sec_id AND d.prog_id = n.prog_id
|
||||||
|
),
|
||||||
|
root_agg AS (
|
||||||
|
SELECT n.*, d.plan_data, d.seq_dfip_data, d.approved_data, d.contract_summary_data, d.allocation_data, d.reserve_data, d.collegial_data, d.ckk_data, d.contract_data, d.booking_data, d.q1_data, d.q2_data, d.q3_data, d.q4_data, d.totals_data
|
||||||
|
FROM root_names n
|
||||||
|
LEFT JOIN lvl_data d
|
||||||
|
ON d.lvl = 3 AND d.sec_id = n.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),
|
||||||
|
tp_plan, tp_seq_dfip, tp_approved, -- plan, seq_dfip, approved
|
||||||
|
tp_contract_summary, tp_allocation, tp_reserve, -- contract_summary, allocation, reserve
|
||||||
|
tp_collegial, tp_ckk, -- collegial, ckk
|
||||||
|
tp_contract, tp_booking, -- contract, booking
|
||||||
|
tp_q1, tp_q2, tp_q3, tp_q4, tp_totals, -- q1-q4, totals
|
||||||
|
ARRAY[sec_id, prog_id, 0, 0]::INT[]
|
||||||
|
FROM all_programs ap
|
||||||
|
JOIN tp_agg ON tp_agg.tp_id = 1
|
||||||
|
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, COALESCE(prog_id, prj_id), CASE WHEN prog_id IS NULL THEN 0 ELSE prj_id END, 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),
|
||||||
|
tp_plan, tp_seq_dfip, tp_approved, -- plan, seq_dfip, approved
|
||||||
|
tp_contract_summary, tp_allocation, tp_reserve, -- contract_summary, allocation, reserve
|
||||||
|
tp_collegial, tp_ckk, -- collegial, ckk
|
||||||
|
tp_contract, tp_booking, -- contract, booking
|
||||||
|
tp_q1, tp_q2, tp_q3, tp_q4, tp_totals, -- q1-q4, totals
|
||||||
|
ARRAY[sec_id, COALESCE(prog_id, prj_id), CASE WHEN prog_id IS NULL THEN 0 ELSE prj_id END, 0]::INT[]
|
||||||
|
FROM all_projects apr
|
||||||
|
JOIN tp_agg ON tp_agg.tp_id = 1
|
||||||
|
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, prj_id, 999999), CASE WHEN prj_id IS NULL AND prog_id IS NULL THEN 999999 WHEN prj_id IS NULL OR prog_id IS NULL THEN 0 ELSE prj_id END, 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(
|
||||||
|
'sort_path', u.sort_path,
|
||||||
|
'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$
|
||||||
|
;
|
||||||
@ -482,7 +482,9 @@ class FormEventProcess:
|
|||||||
case "project_added":
|
case "project_added":
|
||||||
return await self.__add_project(
|
return await self.__add_project(
|
||||||
event_data=event_data["data"],
|
event_data=event_data["data"],
|
||||||
|
form_id=form_id,
|
||||||
user=curr_user,
|
user=curr_user,
|
||||||
|
sheet=sheet,
|
||||||
)
|
)
|
||||||
case "program_updated":
|
case "program_updated":
|
||||||
return await self.__update_form4_program(
|
return await self.__update_form4_program(
|
||||||
@ -610,17 +612,21 @@ class FormEventProcess:
|
|||||||
async def __add_project(
|
async def __add_project(
|
||||||
self,
|
self,
|
||||||
event_data: dict,
|
event_data: dict,
|
||||||
|
form_id: int,
|
||||||
|
sheet: str,
|
||||||
user: AppUser,
|
user: AppUser,
|
||||||
) -> list[tuple]:
|
) -> list[tuple]:
|
||||||
"""
|
"""
|
||||||
data = {
|
data = {
|
||||||
name: str,
|
name: str,
|
||||||
program_id: int
|
program_id?: int
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
return await self.sheet_service.add_project(
|
return await self.sheet_service.add_project(
|
||||||
name=event_data["name"],
|
name=event_data["name"],
|
||||||
program_id=event_data["program_id"],
|
program_id=event_data.get("program_id"),
|
||||||
|
form_id=form_id,
|
||||||
|
sheet=sheet,
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -24,9 +24,13 @@ class Form4Project(Base):
|
|||||||
name="chk_form4_project_parent",
|
name="chk_form4_project_parent",
|
||||||
),
|
),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"(level = 'program') = (section_code IS NOT NULL)",
|
"((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL)",
|
||||||
name="chk_form4_project_section_code",
|
name="chk_form4_project_section_code",
|
||||||
),
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"parent_id IS NOT NULL OR form_id IS NOT NULL",
|
||||||
|
name="chk_form4_project_standalone_form",
|
||||||
|
),
|
||||||
Index(
|
Index(
|
||||||
"ix_form4_project_section_code",
|
"ix_form4_project_section_code",
|
||||||
"section_code",
|
"section_code",
|
||||||
|
|||||||
@ -208,7 +208,9 @@ class SheetRepository:
|
|||||||
async def add_project(
|
async def add_project(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
program_id: int,
|
program_id: int | None = None,
|
||||||
|
form_id: int | None = None,
|
||||||
|
section_code: str | None = None,
|
||||||
) -> list[tuple]:
|
) -> list[tuple]:
|
||||||
return [
|
return [
|
||||||
tuple(r) for r in (
|
tuple(r) for r in (
|
||||||
@ -218,13 +220,17 @@ class SheetRepository:
|
|||||||
SELECT project_id, row_type, depth, sort_order, data
|
SELECT project_id, row_type, depth, sort_order, data
|
||||||
FROM v3.add_form4_project(
|
FROM v3.add_form4_project(
|
||||||
:p_name,
|
:p_name,
|
||||||
:p_program_id
|
:p_program_id,
|
||||||
|
:p_form_id,
|
||||||
|
:p_section_code
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"p_program_id": program_id,
|
|
||||||
"p_name": name,
|
"p_name": name,
|
||||||
|
"p_program_id": program_id,
|
||||||
|
"p_form_id": form_id,
|
||||||
|
"p_section_code": section_code,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|||||||
@ -232,6 +232,24 @@ class Form1DepthRowNormalizer:
|
|||||||
normalized[f"{prefix}{key}"] = value
|
normalized[f"{prefix}{key}"] = value
|
||||||
|
|
||||||
|
|
||||||
|
class Form4DepthRowNormalizer(Form1DepthRowNormalizer):
|
||||||
|
"""AURORA-1979: подпись «программа»/«проект» вместо кода в колонке header.section_code.
|
||||||
|
|
||||||
|
Нормализатор не получает row_type, поэтому узел определяется по line_id и id в header.
|
||||||
|
Исходные данные не меняются: раскраска читает header.section_code из исходной строки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
normalized = super().normalize(data)
|
||||||
|
header = data.get("header")
|
||||||
|
if data.get("line_id") is None and isinstance(header, dict):
|
||||||
|
if header.get("project_id") is not None:
|
||||||
|
normalized["header.section_code"] = "проект"
|
||||||
|
elif header.get("program_id") is not None:
|
||||||
|
normalized["header.section_code"] = "программа"
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
class SmetaRowNormalizer:
|
class SmetaRowNormalizer:
|
||||||
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
|
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -28,6 +28,7 @@ from src.services.export_service.export_normalizers import (
|
|||||||
ExcelValueSerializer,
|
ExcelValueSerializer,
|
||||||
Form1DepthRowNormalizer,
|
Form1DepthRowNormalizer,
|
||||||
Form2DepthRowNormalizer,
|
Form2DepthRowNormalizer,
|
||||||
|
Form4DepthRowNormalizer,
|
||||||
Form3DepthRowNormalizer,
|
Form3DepthRowNormalizer,
|
||||||
SmetaRowNormalizer,
|
SmetaRowNormalizer,
|
||||||
)
|
)
|
||||||
@ -125,6 +126,7 @@ class ExportService:
|
|||||||
self.form1_row_normalizer = Form1DepthRowNormalizer(self.value_serializer)
|
self.form1_row_normalizer = Form1DepthRowNormalizer(self.value_serializer)
|
||||||
self.form2_row_normalizer = Form2DepthRowNormalizer(self.value_serializer)
|
self.form2_row_normalizer = Form2DepthRowNormalizer(self.value_serializer)
|
||||||
self.form3_row_normalizer = Form3DepthRowNormalizer(self.value_serializer)
|
self.form3_row_normalizer = Form3DepthRowNormalizer(self.value_serializer)
|
||||||
|
self.form4_row_normalizer = Form4DepthRowNormalizer(self.value_serializer)
|
||||||
self.smeta_row_normalizer = SmetaRowNormalizer()
|
self.smeta_row_normalizer = SmetaRowNormalizer()
|
||||||
self.sheet_writers: dict[str, TabularSheetWriter] = {
|
self.sheet_writers: dict[str, TabularSheetWriter] = {
|
||||||
"__default__": DepthSheetWriter(
|
"__default__": DepthSheetWriter(
|
||||||
@ -972,19 +974,19 @@ class ExportService:
|
|||||||
FORM4_OPER_DEPTH_COLUMNS,
|
FORM4_OPER_DEPTH_COLUMNS,
|
||||||
FORM4_OPER_COLORS,
|
FORM4_OPER_COLORS,
|
||||||
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
||||||
self.form1_row_normalizer,
|
self.form4_row_normalizer,
|
||||||
),
|
),
|
||||||
"AHR": (
|
"AHR": (
|
||||||
FORM4_DEPTH_COLUMNS,
|
FORM4_DEPTH_COLUMNS,
|
||||||
FORM4_COLORS["AHR"],
|
FORM4_COLORS["AHR"],
|
||||||
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
||||||
self.form1_row_normalizer,
|
self.form4_row_normalizer,
|
||||||
),
|
),
|
||||||
"CAP": (
|
"CAP": (
|
||||||
FORM4_DEPTH_COLUMNS,
|
FORM4_DEPTH_COLUMNS,
|
||||||
FORM4_COLORS["CAP"],
|
FORM4_COLORS["CAP"],
|
||||||
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
||||||
self.form1_row_normalizer,
|
self.form4_row_normalizer,
|
||||||
),
|
),
|
||||||
"STRUCTURE": (
|
"STRUCTURE": (
|
||||||
FORM4_STRUCTURE_DEPTH_COLUMNS,
|
FORM4_STRUCTURE_DEPTH_COLUMNS,
|
||||||
|
|||||||
@ -22,6 +22,19 @@ RowFormatResolver = Callable[[tuple, str, int], dict[str, Any] | None]
|
|||||||
ColumnFormatResolver = Callable[[str, int], dict[str, Any] | None]
|
ColumnFormatResolver = Callable[[str, int], dict[str, Any] | None]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_standalone_project(row: tuple) -> bool:
|
||||||
|
"""AURORA-1984 (миграция 0059, design D3): самостоятельный проект формы 4 —
|
||||||
|
ITEM с header.project_id и без header.program_id. Он ровня программе (GROUP)."""
|
||||||
|
if not row or row[0] != "ITEM" or len(row) <= 3 or not isinstance(row[3], dict):
|
||||||
|
return False
|
||||||
|
header = row[3].get("header")
|
||||||
|
return (
|
||||||
|
isinstance(header, dict)
|
||||||
|
and header.get("project_id") is not None
|
||||||
|
and header.get("program_id") is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _hierarchical_rows(rows: list[tuple]):
|
def _hierarchical_rows(rows: list[tuple]):
|
||||||
"""Use the same row-type hierarchy as the realtime table, skipping META."""
|
"""Use the same row-type hierarchy as the realtime table, skipping META."""
|
||||||
levels = {"ROOT": 0, "GROUP": 1, "ITEM": 2, "SUB_ITEM": 3, "INPUT": 4}
|
levels = {"ROOT": 0, "GROUP": 1, "ITEM": 2, "SUB_ITEM": 3, "INPUT": 4}
|
||||||
@ -30,7 +43,7 @@ def _hierarchical_rows(rows: list[tuple]):
|
|||||||
item_type = row[0] if row else None
|
item_type = row[0] if row else None
|
||||||
if item_type == "META":
|
if item_type == "META":
|
||||||
continue
|
continue
|
||||||
level = levels.get(item_type, 999)
|
level = levels["GROUP"] if _is_standalone_project(row) else levels.get(item_type, 999)
|
||||||
while stack and stack[-1] >= level:
|
while stack and stack[-1] >= level:
|
||||||
stack.pop()
|
stack.pop()
|
||||||
yield row, len(stack)
|
yield row, len(stack)
|
||||||
|
|||||||
@ -158,31 +158,54 @@ class SheetService:
|
|||||||
async def add_project(
|
async def add_project(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
program_id: int,
|
program_id: int | None = None,
|
||||||
|
form_id: int | None = None,
|
||||||
|
sheet: str | None = None,
|
||||||
user: AppUser | None = None,
|
user: AppUser | None = None,
|
||||||
) -> tuple[int, list[tuple]]:
|
) -> tuple[int, list[tuple]] | None:
|
||||||
if user.role_id != UserRoleEnum.ADMIN:
|
if program_id is None:
|
||||||
program = await self.f4_project_repo.get(
|
if user.role_id != UserRoleEnum.ADMIN:
|
||||||
project_id=program_id,
|
user = await self.user_repo.get(
|
||||||
level=Form4ProjectLevelEnum.PROGRAM,
|
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_project(
|
||||||
|
name=name,
|
||||||
|
program_id=None,
|
||||||
|
form_id=form_id,
|
||||||
|
section_code=sheet,
|
||||||
)
|
)
|
||||||
if not program:
|
else:
|
||||||
raise SheetValidationError("Программа не найдена")
|
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 = await self.user_repo.get(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
load_orgs=True,
|
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(
|
||||||
|
name=name,
|
||||||
|
program_id=program_id,
|
||||||
|
form_id=None,
|
||||||
|
section_code=None,
|
||||||
)
|
)
|
||||||
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:
|
if result:
|
||||||
return (result[0][0], [tuple(r[1:]) for r in result])
|
return (result[0][0], [tuple(r[1:]) for r in result])
|
||||||
return None
|
return None
|
||||||
|
|||||||
192
api/tests/integration/test_form4_standalone_project.py
Normal file
192
api/tests/integration/test_form4_standalone_project.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
"""AURORA-1984: самостоятельный проект формы 4 (без программы) на листе CAP."""
|
||||||
|
import re
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sheet_contract import strip_nulls
|
||||||
|
from src.repository.sheet_repository import SheetRepository
|
||||||
|
from test_form4_sheet_sums import F4, SHEET, _loads
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def f4(client, db_session, admin_tokens, auth_headers):
|
||||||
|
return F4(client, db_session, auth_headers(admin_tokens))
|
||||||
|
|
||||||
|
|
||||||
|
def add(f4, name="solo", section="CAP"):
|
||||||
|
return f4.sql(
|
||||||
|
"SELECT project_id, row_type FROM v3.add_form4_project(:n, NULL, :f, :s)",
|
||||||
|
n=name, f=f4.form_id, s=section,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def standalone(f4, name="solo", section="CAP"):
|
||||||
|
return add(f4, name, section)[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def outline(rows):
|
||||||
|
return [(r["row_type"], r["data"]["header"]["name"]) for r in rows if r["row_type"] != "META"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_order_by_creation(f4):
|
||||||
|
f4.program("А"); standalone(f4, "Б"); f4.program("В")
|
||||||
|
assert outline(f4.sheet()) == [("GROUP", "А"), ("ITEM", "Б"), ("GROUP", "В")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sums_counted_once_and_no_null_group(f4):
|
||||||
|
prog = f4.program(); prj = f4.project(prog); solo = standalone(f4)
|
||||||
|
f4.cells(f4.line(prj), {"plan.q1": 100})
|
||||||
|
f4.cells(f4.line(solo), {"plan.q1": 50})
|
||||||
|
rows = f4.sheet()
|
||||||
|
root = next(r for r in rows if r["row_type"] == "ROOT")
|
||||||
|
item = next(r for r in rows if r["row_type"] == "ITEM" and r["data"]["header"]["project_id"] == solo)
|
||||||
|
assert Decimal(str(root["data"]["plan"]["q1"])) == 150
|
||||||
|
assert Decimal(str(item["data"]["plan"]["q1"])) == 50
|
||||||
|
assert item["data"]["header"].get("program_id") is None
|
||||||
|
assert all(r["data"]["header"].get("program_id") is not None for r in rows if r["row_type"] == "GROUP")
|
||||||
|
|
||||||
|
|
||||||
|
def test_expense_goes_right_after_its_project(f4):
|
||||||
|
a = f4.program("А"); solo = standalone(f4, "Б"); f4.program("В")
|
||||||
|
f4.line(f4.project(a, "А1")); line = f4.line(solo)
|
||||||
|
rows = [r for r in f4.sheet() if r["row_type"] != "META"]
|
||||||
|
idx = next(i for i, r in enumerate(rows) if r["data"].get("line_id") == line)
|
||||||
|
assert rows[idx - 1]["data"]["header"]["project_id"] == solo
|
||||||
|
assert rows[idx + 1]["data"]["header"]["name"] == "В"
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_line_stays_after_standalone(f4):
|
||||||
|
solo = standalone(f4)
|
||||||
|
f4.line(solo); hist = f4.line(None)
|
||||||
|
rows = [r for r in f4.sheet() if r["row_type"] != "META"]
|
||||||
|
assert rows[-1]["data"].get("line_id") == hist
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_section_does_not_show_standalone(f4):
|
||||||
|
solo = standalone(f4, section="CAP")
|
||||||
|
count = f4.sql(
|
||||||
|
"SELECT count(*) FROM v3.v_form4_sheet_jsonb(:f, 'AHR', NULL) "
|
||||||
|
"WHERE data->'header'->>'project_id' = :p", f=f4.form_id, p=str(solo),
|
||||||
|
)[0][0]
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cell_patch_response(f4, client):
|
||||||
|
solo = standalone(f4); line = f4.line(solo)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/v1/form/{f4.form_id}/sheet/{SHEET}/cell",
|
||||||
|
json={"line_id": line, "column": "plan.q1", "value": 20}, headers=f4.h,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_expense_recalculates(f4):
|
||||||
|
solo = standalone(f4)
|
||||||
|
keep, gone = f4.line(solo), f4.line(solo)
|
||||||
|
f4.cells(keep, {"plan.q1": 10})
|
||||||
|
f4.cells(gone, {"plan.q1": 5})
|
||||||
|
rows = f4.sql("SELECT row_type, data::text FROM v3.del_budget_line(:l, NULL, 'CAP')", l=gone)
|
||||||
|
by_type = {r[0]: _loads(r[1]) for r in rows}
|
||||||
|
assert "GROUP" not in by_type
|
||||||
|
assert Decimal(str(by_type["ITEM"]["plan"]["q1"])) == 10
|
||||||
|
assert Decimal(str(by_type["ROOT"]["plan"]["q1"])) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_sheet_without_standalone_equals_0053(f4):
|
||||||
|
source = Path("alembic/versions/sql/0053_form2_ahr_source_sheet.sql").read_text(encoding="utf-8")
|
||||||
|
body = re.search(r"CREATE OR REPLACE FUNCTION v3\.v_form4_sheet_jsonb.*?\$function\$\s*;", source, re.S).group(0)
|
||||||
|
ddl = body.replace("v3.v_form4_sheet_jsonb", "pg_temp.v_form4_sheet_old", 1)
|
||||||
|
async def _create():
|
||||||
|
conn = await f4.db.connection()
|
||||||
|
await conn.exec_driver_sql(ddl)
|
||||||
|
f4.client.portal.call(_create)
|
||||||
|
prog = f4.program(); prj = f4.project(prog); f4.program(); f4.project(prog)
|
||||||
|
f4.cells(f4.line(prj), {"plan.q1": 7})
|
||||||
|
f4.line(prog) # расход прямо на программе
|
||||||
|
f4.line(None) # исторический расход без проекта
|
||||||
|
q = "SELECT row_type, depth, sort_order, data::text FROM {}(:f, 'CAP', NULL) ORDER BY sort_order"
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_standalone_with_root_returns_item_and_root(f4):
|
||||||
|
solo = standalone(f4); f4.line(solo)
|
||||||
|
rows = f4.sql("SELECT row_type, data::text FROM v3.upd_form4_project(:p, 'Новое')", p=solo)
|
||||||
|
assert sorted(r[0] for r in rows) == ["ITEM", "ROOT"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_empty_standalone_with_root_returns_root_only(f4):
|
||||||
|
first = standalone(f4, "А"); f4.line(first)
|
||||||
|
second = standalone(f4, "Б")
|
||||||
|
rows = f4.sql("SELECT row_type FROM v3.del_form4_project(:p)", p=second)
|
||||||
|
assert [r[0] for r in rows] == ["ROOT"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_creates_in_section(f4):
|
||||||
|
async def _run():
|
||||||
|
return await SheetRepository(f4.db).add_project(name="Р", form_id=f4.form_id, section_code="CAP")
|
||||||
|
rows = f4.client.portal.call(_run)
|
||||||
|
assert [r[1] for r in rows] == ["ITEM"]
|
||||||
@ -1,5 +1,9 @@
|
|||||||
|
import copy
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.services.export_service.export_normalizers import (
|
from src.services.export_service.export_normalizers import (
|
||||||
ExcelValueSerializer,
|
ExcelValueSerializer,
|
||||||
|
Form4DepthRowNormalizer,
|
||||||
Form1RowNormalizer,
|
Form1RowNormalizer,
|
||||||
Form3DepthRowNormalizer,
|
Form3DepthRowNormalizer,
|
||||||
SmetaRowNormalizer,
|
SmetaRowNormalizer,
|
||||||
@ -78,3 +82,17 @@ def test_form3_depth_normalizer_flattens_nested_data():
|
|||||||
assert normalized["totals.total_actual"] == 40
|
assert normalized["totals.total_actual"] == 40
|
||||||
assert normalized["totals.economy"] == 2.1
|
assert normalized["totals.economy"] == 2.1
|
||||||
assert "line_id" not in normalized
|
assert "line_id" not in normalized
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("data, expected", [
|
||||||
|
({"line_id": None, "header": {"section_code": "CAP", "program_id": 1}}, "программа"),
|
||||||
|
({"line_id": None, "header": {"section_code": "CAP", "program_id": None, "project_id": 2}}, "проект"),
|
||||||
|
({"line_id": None, "header": {"section_code": "CAP", "program_id": 1, "project_id": 2}}, "проект"),
|
||||||
|
({"line_id": None, "header": {"section_code": "CAP"}}, "CAP"),
|
||||||
|
({"line_id": 9, "header": {"section_code": "3.01.", "program_id": 1, "project_id": 2}}, "3.01."),
|
||||||
|
])
|
||||||
|
def test_form4_depth_normalizer_type_label(data, expected):
|
||||||
|
original = copy.deepcopy(data)
|
||||||
|
normalized = Form4DepthRowNormalizer(ExcelValueSerializer()).normalize(data)
|
||||||
|
assert normalized["header.section_code"] == expected
|
||||||
|
assert data == original
|
||||||
|
|||||||
@ -16,6 +16,7 @@ from src.services.export_service.export_writers import (
|
|||||||
TabularSheetWriter,
|
TabularSheetWriter,
|
||||||
XlsxDepthSheetWriter,
|
XlsxDepthSheetWriter,
|
||||||
XlsxTabularSheetWriter,
|
XlsxTabularSheetWriter,
|
||||||
|
_hierarchical_rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Эталонные порядки колонок листа "Смета" экрана каждой формы (AURORA-1903).
|
# Эталонные порядки колонок листа "Смета" экрана каждой формы (AURORA-1903).
|
||||||
@ -231,3 +232,25 @@ def test_export_service_uses_sheet_writer_strategy():
|
|||||||
|
|
||||||
assert default_writer.calls == 1
|
assert default_writer.calls == 1
|
||||||
assert smeta_writer.calls == 1
|
assert smeta_writer.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_standalone_project_after_program_is_sibling_of_program():
|
||||||
|
"""AURORA-1984 I-1: самостоятельный проект (ITEM без program_id) — уровень программы, без GROUP и после программы."""
|
||||||
|
|
||||||
|
def node(kind, **header):
|
||||||
|
return (kind, 0, 0, {"header": header, "line_id": None})
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
node("ROOT"),
|
||||||
|
node("ITEM", project_id=9),
|
||||||
|
("INPUT", 4, 0, {"header": {"project_id": 9}, "line_id": 4}),
|
||||||
|
node("GROUP", program_id=1),
|
||||||
|
node("ITEM", project_id=10, program_id=1),
|
||||||
|
("INPUT", 4, 0, {"header": {"project_id": 10, "program_id": 1}, "line_id": 5}),
|
||||||
|
node("ITEM", project_id=11, program_id=None),
|
||||||
|
("INPUT", 4, 0, {"header": {"project_id": 11}, "line_id": 6}),
|
||||||
|
node("GROUP", program_id=2),
|
||||||
|
node("ITEM", project_id=12),
|
||||||
|
("INPUT", 4, 0, {"header": {"project_id": 12}, "line_id": 7}),
|
||||||
|
]
|
||||||
|
assert [level for _, level in _hierarchical_rows(rows)] == [0, 1, 2, 1, 2, 3, 1, 2, 1, 1, 2]
|
||||||
|
|||||||
54
api/tests/unit/test_sheet_service_form4_standalone.py
Normal file
54
api/tests/unit/test_sheet_service_form4_standalone.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.db.models.role import UserRoleEnum
|
||||||
|
from src.repository.sheet_repository import SheetValidationError
|
||||||
|
from src.services.sheet_service import SheetService
|
||||||
|
|
||||||
|
ADMIN = SimpleNamespace(id=1, role_id=UserRoleEnum.ADMIN)
|
||||||
|
EXECUTOR = SimpleNamespace(id=5, role_id=UserRoleEnum.EXECUTOR_RF)
|
||||||
|
|
||||||
|
|
||||||
|
def make_service() -> SheetService:
|
||||||
|
service = SheetService(AsyncMock())
|
||||||
|
service.sheet_repo = AsyncMock()
|
||||||
|
service.bf_repo = AsyncMock()
|
||||||
|
service.user_repo = AsyncMock()
|
||||||
|
service.f4_project_repo = AsyncMock()
|
||||||
|
service.user_repo.get.return_value = SimpleNamespace(org_units=[])
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_creates_in_section():
|
||||||
|
service = make_service()
|
||||||
|
service.sheet_repo.add_project.return_value = [(9, "ITEM", 2, 1, {})]
|
||||||
|
result = await service.add_project(name="П", form_id=7, sheet="CAP", user=ADMIN)
|
||||||
|
assert result == (9, [("ITEM", 2, 1, {})])
|
||||||
|
service.sheet_repo.add_project.assert_awaited_once_with(
|
||||||
|
name="П", program_id=None, form_id=7, section_code="CAP"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_foreign_executor_cannot_create_in_section():
|
||||||
|
service = make_service()
|
||||||
|
service.bf_repo.get.return_value = None
|
||||||
|
with pytest.raises(SheetValidationError):
|
||||||
|
await service.add_project(name="П", form_id=7, sheet="CAP", user=EXECUTOR)
|
||||||
|
service.sheet_repo.add_project.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("method", ["update_form4_project", "delete_form4_project"])
|
||||||
|
async def test_foreign_executor_cannot_change_standalone(method):
|
||||||
|
service = make_service()
|
||||||
|
service.f4_project_repo.get.return_value = SimpleNamespace(form_id=7, parent_id=None)
|
||||||
|
service.bf_repo.get.return_value = None
|
||||||
|
kwargs = {"project_id": 3, "user": EXECUTOR}
|
||||||
|
if method == "update_form4_project":
|
||||||
|
kwargs["name"] = "Н"
|
||||||
|
with pytest.raises(SheetValidationError):
|
||||||
|
await getattr(service, method)(**kwargs)
|
||||||
@ -0,0 +1,713 @@
|
|||||||
|
# AURORA-1984: проекты ПД без программы (+1979, 1980, 1989) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** На форме 4 проект создаётся прямо в разделе, без программы, и живёт в листе, итогах и выгрузке наравне с проектом в программе; плюс подпись типа в третьем столбце, переименование программ и проектов, удобный выбор статьи расхода.
|
||||||
|
|
||||||
|
**Architecture:** Самостоятельный проект — запись `v3.form4_project` с `level='project'`, `parent_id IS NULL` и своими `section_code`/`form_id`. Миграция 0059 ослабляет ограничение, расширяет `add_/upd_/del_form4_project` и `v_form4_sheet_jsonb` (ITEM прямо под ROOT, порядок по id верхнего узла). Сервер принимает `project_added` без программы, фронт вставляет ITEM под ROOT и получает окно переименования; подпись типа считается при отображении, данные не меняются.
|
||||||
|
|
||||||
|
**Tech Stack:** PostgreSQL (PL/pgSQL, Alembic), FastAPI + SQLAlchemy async, React 18 (JSX) + material-react-table, `pytest`, `node --test`.
|
||||||
|
|
||||||
|
**Spec:** `openspec/changes/aurora-1984-pd-standalone-projects/` — `proposal.md`, `design.md` (D1–D11, поправлен при перепроверке 08.11), `specs/form4-programs-projects/spec.md`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Worktree `.claude/worktrees/aurora-1984`, ветка `feat/305-aurora-1984-pd-standalone-projects` поверх PR #304; PR — `--base test`.
|
||||||
|
- Перед изменением — `codegraph_explore` с `projectPath` = абсолютный путь worktree.
|
||||||
|
- Миграция: `revision = "0059"`, `down_revision = "0058"`, `downgrade()` — `pass`, формат — `api/alembic/versions/0058_form2_ahr_delta_depth4.py`.
|
||||||
|
- Тело `v3.v_form4_sheet_jsonb` копируется из `sql/0053_form2_ahr_source_sheet.sql:840-1437`, меняются только блоки из задачи 1.
|
||||||
|
- `v3._form_affected_rows`, `v3.upd_form4_program`, `v3.del_form4_program`, `add_budget_line`, `del_budget_line` не меняются.
|
||||||
|
- `sort_path` и `sort_order` существующих строк (программы, проекты в программах, расходы на программе, исторические расходы без проекта с `999999`) не меняются.
|
||||||
|
- `header.section_code` в данных не меняется: подпись «программа»/«проект» — только при отображении и в выгрузке.
|
||||||
|
- В лист и выгрузку не попадает GROUP с пустым `program_id`.
|
||||||
|
- В конфигах колонок (`constants/FORM_4/*.js`) нет функций: `tableColumns.jsx:131` делает `structuredClone`.
|
||||||
|
- `header.program_id` сравнивать только через `!= null`: GET срезает null (AURORA-1848), по WS приходит `null`.
|
||||||
|
- Подписи ровно: «программа», «проект», «Переименовать», «Переименование программы», «Переименование проекта», «Сохранить», «Отмена».
|
||||||
|
- Backend-тесты: `cd api && pytest` (зелёным считается весь набор), фронт: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`. Известное падение на базе: `tests/unit/test_export_service_query_params.py` (O-2 из #304), новых быть не должно.
|
||||||
|
- Интеграционным тестам нужен Postgres из `DATABASE_URL`: стенд `perf-pg` (`docker compose` в `perf/`), отдельная БД, первый прогон с `--init_db`.
|
||||||
|
- Коммиты: сообщение начинается с `AURORA-1984:` и заканчивается строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||||
|
- Решения без обсуждения дописываются в `design.md`, раздел `## Decisions made without discussion`, с маркером `R-n` (занято R-1…R-6, O-1).
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
1. Первый расход в разделе, где уже стоит самостоятельный проект, а ROOT ещё нет — ROOT появляется над проектом, расход встаёт под проект (задача 4, `insertCell после addRootRow`).
|
||||||
|
2. В разделе есть исторический расход без проекта (`999999`) — новый проект встаёт перед ним и на сервере, и локально (задачи 1 и 4).
|
||||||
|
3. Самостоятельный проект раздела CAP не появляется на листе AHR той же формы (задача 1).
|
||||||
|
4. Другой пользователь переименовал тот же узел, пока у меня открыто окно — моё окно не закрывается (задача 5, `isRenameResponse`).
|
||||||
|
5. Удаление последнего пустого самостоятельного проекта в разделе без ROOT — ответ пустой, сервис не падает и отдаёт `(id, [])` (задача 2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Ограничения и лист
|
||||||
|
|
||||||
|
**Модель:** sonnet
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `api/alembic/versions/0059_form4_standalone_project.py`
|
||||||
|
- Create: `api/alembic/versions/sql/0059_form4_standalone_project.sql`
|
||||||
|
- Modify: `api/src/db/models/form4_project.py:26-29`
|
||||||
|
- Test: `api/tests/integration/test_form4_standalone_project.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `F4`, `SHEET`, `_loads` из `tests/integration/test_form4_sheet_sums.py` (импорт `from test_form4_sheet_sums import ...`).
|
||||||
|
- Produces: строка ITEM самостоятельного проекта — `row_type='ITEM'`, `depth=2`, `header = {section_code, name, program_id: null, project_id, expense_item_id: <id раздела>}`; `sort_path` ITEM `[sec, prj_id, 0, 0]`, INPUT `[sec, prj_id, 0, line_id]`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Написать падающие тесты**
|
||||||
|
|
||||||
|
Фикстура `f4` — копия из `test_form4_sheet_sums.py:157`. Хелпер и тесты:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def standalone(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",
|
||||||
|
n=name, f=f4.form_id, s=section,
|
||||||
|
)[0][0]
|
||||||
|
|
||||||
|
def outline(rows):
|
||||||
|
return [(r["row_type"], r["data"]["header"]["name"]) for r in rows if r["row_type"] != "META"]
|
||||||
|
|
||||||
|
def test_mixed_order_by_creation(f4):
|
||||||
|
f4.program("А"); standalone(f4, "Б"); f4.program("В")
|
||||||
|
assert outline(f4.sheet()) == [("GROUP", "А"), ("ITEM", "Б"), ("GROUP", "В")]
|
||||||
|
|
||||||
|
def test_sums_counted_once_and_no_null_group(f4):
|
||||||
|
prog = f4.program(); prj = f4.project(prog); solo = standalone(f4)
|
||||||
|
f4.cells(f4.line(prj), {"plan.q1": 100})
|
||||||
|
f4.cells(f4.line(solo), {"plan.q1": 50})
|
||||||
|
rows = f4.sheet()
|
||||||
|
root = next(r for r in rows if r["row_type"] == "ROOT")
|
||||||
|
item = next(r for r in rows if r["row_type"] == "ITEM" and r["data"]["header"]["project_id"] == solo)
|
||||||
|
assert Decimal(str(root["data"]["plan"]["q1"])) == 150
|
||||||
|
assert Decimal(str(item["data"]["plan"]["q1"])) == 50
|
||||||
|
assert item["data"]["header"].get("program_id") is None
|
||||||
|
assert all(r["data"]["header"].get("program_id") is not None for r in rows if r["row_type"] == "GROUP")
|
||||||
|
|
||||||
|
def test_expense_goes_right_after_its_project(f4):
|
||||||
|
a = f4.program("А"); solo = standalone(f4, "Б"); f4.program("В")
|
||||||
|
f4.line(f4.project(a, "А1")); line = f4.line(solo)
|
||||||
|
rows = [r for r in f4.sheet() if r["row_type"] != "META"]
|
||||||
|
idx = next(i for i, r in enumerate(rows) if r["data"].get("line_id") == line)
|
||||||
|
assert rows[idx - 1]["data"]["header"]["project_id"] == solo
|
||||||
|
assert rows[idx + 1]["data"]["header"]["name"] == "В"
|
||||||
|
|
||||||
|
def test_historical_line_stays_after_standalone(f4):
|
||||||
|
solo = standalone(f4)
|
||||||
|
f4.line(solo); hist = f4.line(None)
|
||||||
|
rows = [r for r in f4.sheet() if r["row_type"] != "META"]
|
||||||
|
assert rows[-1]["data"].get("line_id") == hist
|
||||||
|
|
||||||
|
def test_other_section_does_not_show_standalone(f4):
|
||||||
|
solo = standalone(f4, section="CAP")
|
||||||
|
count = f4.sql(
|
||||||
|
"SELECT count(*) FROM v3.v_form4_sheet_jsonb(:f, 'AHR', NULL) "
|
||||||
|
"WHERE data->'header'->>'project_id' = :p", f=f4.form_id, p=str(solo),
|
||||||
|
)[0][0]
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
def test_cell_patch_response(f4, client):
|
||||||
|
solo = standalone(f4); line = f4.line(solo)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/v1/form/{f4.form_id}/sheet/{SHEET}/cell",
|
||||||
|
json={"line_id": line, "column": "plan.q1", "value": 20}, headers=f4.h,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
fresh = next(r for r in f4.sheet() if r["row_type"] == "ITEM")
|
||||||
|
assert by_type["ITEM"]["plan"] == fresh["data"]["plan"]
|
||||||
|
|
||||||
|
def test_delete_expense_recalculates(f4):
|
||||||
|
solo = standalone(f4)
|
||||||
|
keep, gone = f4.line(solo), f4.line(solo)
|
||||||
|
f4.cells(keep, {"plan.q1": 10})
|
||||||
|
f4.cells(gone, {"plan.q1": 5})
|
||||||
|
rows = f4.sql("SELECT row_type, data::text FROM v3.del_budget_line(:l, NULL, 'CAP')", l=gone)
|
||||||
|
by_type = {r[0]: _loads(r[1]) for r in rows}
|
||||||
|
assert "GROUP" not in by_type
|
||||||
|
assert Decimal(str(by_type["ITEM"]["plan"]["q1"])) == 10
|
||||||
|
assert Decimal(str(by_type["ROOT"]["plan"]["q1"])) == 10
|
||||||
|
|
||||||
|
def test_sheet_without_standalone_equals_0053(f4):
|
||||||
|
source = Path("alembic/versions/sql/0053_form2_ahr_source_sheet.sql").read_text(encoding="utf-8")
|
||||||
|
body = re.search(r"CREATE OR REPLACE FUNCTION v3\.v_form4_sheet_jsonb.*?\$function\$\s*;", source, re.S).group(0)
|
||||||
|
ddl = body.replace("v3.v_form4_sheet_jsonb", "pg_temp.v_form4_sheet_old", 1)
|
||||||
|
async def _create():
|
||||||
|
conn = await f4.db.connection()
|
||||||
|
await conn.exec_driver_sql(ddl)
|
||||||
|
f4.client.portal.call(_create)
|
||||||
|
prog = f4.program(); prj = f4.project(prog); f4.program(); f4.project(prog)
|
||||||
|
f4.cells(f4.line(prj), {"plan.q1": 7})
|
||||||
|
f4.line(prog) # расход прямо на программе
|
||||||
|
f4.line(None) # исторический расход без проекта
|
||||||
|
q = "SELECT row_type, depth, sort_order, data::text FROM {}(:f, 'CAP', NULL) ORDER BY sort_order"
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Убедиться, что тесты падают**
|
||||||
|
|
||||||
|
Run: `cd api && pytest tests/integration/test_form4_standalone_project.py --init_db -v`
|
||||||
|
Expected: FAIL — `INSERT` нарушает `chk_form4_project_section_code`; `test_sheet_without_standalone_equals_0053` проходит.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Написать миграцию**
|
||||||
|
|
||||||
|
В `.sql` по порядку:
|
||||||
|
|
||||||
|
1. `DO`-блок: `RAISE EXCEPTION`, если есть строка с `parent_id IS NULL AND form_id IS NULL`.
|
||||||
|
2. `DROP CONSTRAINT chk_form4_project_section_code` и `ADD CONSTRAINT` с `((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL)`.
|
||||||
|
3. `ADD CONSTRAINT chk_form4_project_standalone_form CHECK (parent_id IS NOT NULL OR form_id IS NOT NULL)`.
|
||||||
|
4. `CREATE OR REPLACE FUNCTION v3.v_form4_sheet_jsonb` — копия из 0053 с правками (номера строк — по 0053):
|
||||||
|
- `all_projects` (1149-1158): `UNION ALL` самостоятельных проектов: `prj.level='project' AND prj.parent_id IS NULL AND prj.section_code = p_sheet AND prj.form_id = p_form_id AND (p_sections IS NULL OR prj.section_code = ANY(p_sections))`, `NULL::INT AS prog_id`, `sec_id/sec_code/sec_name` — `LATERAL` по `expense_item` с `depth = 0`, как в `all_programs`.
|
||||||
|
- `prj_agg` (1289): `d.prog_id IS NOT DISTINCT FROM n.prog_id`.
|
||||||
|
- `sort_path` обеих веток ITEM (1370, 1385): `ARRAY[sec_id, COALESCE(prog_id, prj_id), CASE WHEN prog_id IS NULL THEN 0 ELSE prj_id END, 0]::INT[]`.
|
||||||
|
- `sort_path` INPUT (1407): `ARRAY[sec_id, COALESCE(prog_id, prj_id, 999999), CASE WHEN prj_id IS NULL AND prog_id IS NULL THEN 999999 WHEN prj_id IS NULL OR prog_id IS NULL THEN 0 ELSE prj_id END, line_id]::INT[]`.
|
||||||
|
|
||||||
|
В ORM заменить условие `chk_form4_project_section_code` на то же выражение и добавить `CheckConstraint` `chk_form4_project_standalone_form`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Убедиться, что тесты проходят**
|
||||||
|
|
||||||
|
Run: `cd api && pytest tests/integration/test_form4_standalone_project.py tests/integration/test_form4_sheet_sums.py tests/integration/test_form4_transfer_econ.py --init_db -v`
|
||||||
|
Expected: PASS, файлы `test_form4_sheet_sums.py` и `test_form4_transfer_econ.py` не правились.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Коммит**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add api/alembic/versions/0059_form4_standalone_project.py api/alembic/versions/sql/0059_form4_standalone_project.sql api/src/db/models/form4_project.py api/tests/integration/test_form4_standalone_project.py
|
||||||
|
git commit -m "AURORA-1984: самостоятельный проект в модели и листе формы 4"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Создание, переименование и удаление в SQL
|
||||||
|
|
||||||
|
**Модель:** sonnet
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `api/alembic/versions/sql/0059_form4_standalone_project.sql`
|
||||||
|
- Test: `api/tests/integration/test_form4_standalone_project.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: ограничения задачи 1.
|
||||||
|
- Produces: `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)` — те же колонки результата `(project_id, row_type, depth, sort_order, data)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Написать падающие тесты**
|
||||||
|
|
||||||
|
Хелпер `standalone` из задачи 1 заменить вызовом функции, остальные тесты задачи 1 остаются:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def add(f4, name="solo", section="CAP"):
|
||||||
|
return f4.sql(
|
||||||
|
"SELECT project_id, row_type FROM v3.add_form4_project(:n, NULL, :f, :s)",
|
||||||
|
n=name, f=f4.form_id, s=section,
|
||||||
|
)
|
||||||
|
|
||||||
|
def standalone(f4, name="solo", section="CAP"):
|
||||||
|
return add(f4, name, section)[0][0]
|
||||||
|
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Убедиться, что тесты падают**
|
||||||
|
|
||||||
|
Run: `cd api && pytest tests/integration/test_form4_standalone_project.py --init_db -v`
|
||||||
|
Expected: FAIL — `function v3.add_form4_project(... ) does not exist` / нет ожидаемых сообщений.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Реализовать функции в 0059, перед `v_form4_sheet_jsonb`**
|
||||||
|
|
||||||
|
- `DROP FUNCTION v3.add_form4_project(varchar, int4);` и новая функция с сигнатурой из Interfaces, тело — от `sql/0026_budget_line_stage_access.sql:486`.
|
||||||
|
- Проверка: если `(p_program_id IS NOT NULL) = (p_form_id IS NOT NULL OR p_section_code IS NOT NULL)` либо задан только один из `p_form_id`/`p_section_code` — `RAISE EXCEPTION 'Укажите либо программу, либо форму и раздел'`.
|
||||||
|
- Без программы: `'Только FORM_4'` — как в `add_form4_program`; при `p_section_code NOT IN ('AHR','CAP','OPER')` — `RAISE EXCEPTION 'Раздел % недоступен для проекта', p_section_code`; `v_form_id := p_form_id`, `v_section_code := p_section_code`, блок `can_edit` — общий, те же коды ошибок. `INSERT` с `parent_id NULL` и `section_code`; в `log_event` добавить `'section_code', v_section_code`.
|
||||||
|
- С программой — поведение прежнее. `RETURN QUERY` общий: при пустом `p_program_id` условие GROUP ложно само.
|
||||||
|
- `upd_form4_project`, `del_form4_project` — тела из `sql/0024_upd_form4_projects.sql:79-155`, раздел везде `COALESCE(v_program.section_code, v_project.section_code)` в локальной переменной `v_section_code`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Убедиться, что тесты проходят**
|
||||||
|
|
||||||
|
Run: `cd api && pytest tests/integration/test_form4_standalone_project.py tests/integration/test_form4_sheet_sums.py --init_db -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Коммит**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add api/alembic/versions/sql/0059_form4_standalone_project.sql api/tests/integration/test_form4_standalone_project.py
|
||||||
|
git commit -m "AURORA-1984: создание, переименование и удаление проекта без программы в SQL"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Сервер: `project_added` без программы
|
||||||
|
|
||||||
|
**Модель:** haiku
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `api/src/api/v1/websocket.py:482-486`, `api/src/api/v1/websocket.py:610-625`
|
||||||
|
- Modify: `api/src/services/sheet_service.py:158-188`
|
||||||
|
- Modify: `api/src/repository/sheet_repository.py:208-231`
|
||||||
|
- Test: `api/tests/unit/test_sheet_service_form4_standalone.py`, `api/tests/integration/test_form4_standalone_project.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: SQL-функция задачи 2.
|
||||||
|
- Produces:
|
||||||
|
- `SheetRepository.add_project(self, name: str, program_id: int | None = None, form_id: int | None = None, section_code: str | None = None) -> list[tuple]`
|
||||||
|
- `SheetService.add_project(self, name: str, program_id: int | None = None, form_id: int | None = None, sheet: str | None = None, user: AppUser | None = None) -> tuple[int, list[tuple]] | None`
|
||||||
|
- WS `project_added`: `data = {name: str, program_id?: int}`, ответ `[project_id, rows]`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Написать падающие тесты**
|
||||||
|
|
||||||
|
Unit (через `asyncio.run`, `SheetService(AsyncMock())`, репозитории заменены на `AsyncMock`, импорт `SheetValidationError` — как в `sheet_service.py`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_admin_creates_in_section():
|
||||||
|
service = make_service()
|
||||||
|
service.sheet_repo.add_project.return_value = [(9, "ITEM", 2, 1, {})]
|
||||||
|
result = asyncio.run(service.add_project(name="П", form_id=7, sheet="CAP", user=ADMIN))
|
||||||
|
assert result == (9, [("ITEM", 2, 1, {})])
|
||||||
|
service.sheet_repo.add_project.assert_awaited_once_with(name="П", program_id=None, form_id=7, section_code="CAP")
|
||||||
|
|
||||||
|
def test_foreign_executor_cannot_create_in_section():
|
||||||
|
service = make_service()
|
||||||
|
service.bf_repo.get.return_value = None
|
||||||
|
with pytest.raises(SheetValidationError):
|
||||||
|
asyncio.run(service.add_project(name="П", form_id=7, sheet="CAP", user=EXECUTOR))
|
||||||
|
service.sheet_repo.add_project.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", ["update_form4_project", "delete_form4_project"])
|
||||||
|
def test_foreign_executor_cannot_change_standalone(method):
|
||||||
|
service = make_service()
|
||||||
|
service.f4_project_repo.get.return_value = SimpleNamespace(form_id=7, parent_id=None)
|
||||||
|
service.bf_repo.get.return_value = None
|
||||||
|
kwargs = {"project_id": 3, "user": EXECUTOR}
|
||||||
|
if method == "update_form4_project":
|
||||||
|
kwargs["name"] = "Н"
|
||||||
|
with pytest.raises(SheetValidationError):
|
||||||
|
asyncio.run(getattr(service, method)(**kwargs))
|
||||||
|
```
|
||||||
|
|
||||||
|
Здесь `ADMIN = SimpleNamespace(id=1, role_id=UserRoleEnum.ADMIN)`, `EXECUTOR = SimpleNamespace(id=5, role_id=UserRoleEnum.EXECUTOR_RF)`, `make_service()` дополнительно задаёт `user_repo.get.return_value = SimpleNamespace(org_units=[])`.
|
||||||
|
|
||||||
|
Integration (NULL-параметры доходят до функции):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_repository_creates_in_section(f4):
|
||||||
|
repo = SheetRepository(f4.db)
|
||||||
|
rows = f4.client.portal.call(lambda: repo.add_project(name="Р", form_id=f4.form_id, section_code="CAP"))
|
||||||
|
assert [r[1] for r in rows] == ["ITEM"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Убедиться, что тесты падают**
|
||||||
|
|
||||||
|
Run: `cd api && pytest tests/unit/test_sheet_service_form4_standalone.py -v`
|
||||||
|
Expected: FAIL — `add_project() got an unexpected keyword argument 'form_id'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Реализовать**
|
||||||
|
|
||||||
|
- Repository — вызов `v3.add_form4_project(:p_name, :p_program_id, :p_form_id, :p_section_code)`.
|
||||||
|
- Service — при `program_id is None` для не-админа та же проверка, что в `add_program` (`sheet_service.py:137-147`, ошибка «Форма не найдена»), вызов `sheet_repo.add_project(name=name, program_id=None, form_id=form_id, section_code=sheet)`. Ветка с программой прежняя, вызов репозитория `add_project(name=name, program_id=program_id, form_id=None, section_code=None)`.
|
||||||
|
- WebSocket — в `process` передать `form_id=form_id, sheet=sheet`; в `__add_project` `program_id=event_data.get("program_id")`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Убедиться, что тесты проходят**
|
||||||
|
|
||||||
|
Run: `cd api && pytest tests/unit/test_sheet_service_form4_standalone.py tests/integration/test_form4_standalone_project.py --init_db -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Коммит**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add api/src/api/v1/websocket.py api/src/services/sheet_service.py api/src/repository/sheet_repository.py api/tests/unit/test_sheet_service_form4_standalone.py api/tests/integration/test_form4_standalone_project.py
|
||||||
|
git commit -m "AURORA-1984: событие project_added без программы"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Фронт: создание и вставка проекта
|
||||||
|
|
||||||
|
**Модель:** sonnet
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/components/RealtimeTable/utils/rowUtils.js`, `web/src/components/RealtimeTable/utils/cellUtils.js`
|
||||||
|
- Modify: `web/src/components/RealtimeTable/hooks/useRealtimeData.js:135-158`
|
||||||
|
- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx:936-943`, `web/src/components/RealtimeTable/RealtimeTable.jsx:1013-1029`
|
||||||
|
- Modify: `web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx:35`
|
||||||
|
- Test: `web/src/components/RealtimeTable/utils/rowUtils.test.js`, `web/src/components/RealtimeTable/utils/cellUtils.test.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: WS из задачи 3; `alignNodeRows`, `addRootRow`, `updateCells`, `insertProjectRow` из `cellUtils.js`.
|
||||||
|
- Produces:
|
||||||
|
- `getProjectAddTarget(rowData): { program_id?: number }` (`rowUtils.js`)
|
||||||
|
- `insertStandaloneProjectRow(data, newRow): rows` (`cellUtils.js`), `newRow = [row_type, depth, sort_order, data]`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Написать падающие тесты**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// rowUtils.test.js
|
||||||
|
test('AURORA-1984: getProjectAddTarget — цель по выделенной строке', () => {
|
||||||
|
assert.deepEqual(getProjectAddTarget(undefined), {});
|
||||||
|
assert.deepEqual(getProjectAddTarget(form4Row('ROOT', { section_code: 'CAP' })), {});
|
||||||
|
assert.deepEqual(getProjectAddTarget(form4Row('GROUP', { program_id: 11 })), { program_id: 11 });
|
||||||
|
assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { program_id: 11, project_id: 21 })), { program_id: 11 });
|
||||||
|
assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { program_id: null, project_id: 21 })), {});
|
||||||
|
assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { project_id: 21 })), {});
|
||||||
|
assert.deepEqual(getProjectAddTarget(form4Row('INPUT', { program_id: 11, project_id: 21 })), { program_id: 11 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// cellUtils.test.js
|
||||||
|
const node = (rowType, sortOrder, header = {}, subRows = []) => ({
|
||||||
|
row_type: rowType, depth: { ROOT: 0, GROUP: 1, ITEM: 2, INPUT: 3 }[rowType], sort_order: sortOrder, subRows, data: { header },
|
||||||
|
});
|
||||||
|
const newItem = (sortOrder) => ['ITEM', 2, sortOrder, { header: { project_id: 77, name: 'Н' } }];
|
||||||
|
|
||||||
|
test('AURORA-1984: самостоятельный проект — под ROOT, после соседей', () => {
|
||||||
|
const result = insertStandaloneProjectRow([node('ROOT', 1, {}, [node('GROUP', 2)])], newItem(3));
|
||||||
|
assert.deepEqual(result[0].subRows.map((r) => [r.row_type, r.sort_order]), [['GROUP', 2], ['ITEM', 3]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1984: самостоятельный проект — на верхний уровень без ROOT', () => {
|
||||||
|
const result = insertStandaloneProjectRow([node('GROUP', 1)], newItem(2));
|
||||||
|
assert.deepEqual(result.map((r) => r.row_type), ['GROUP', 'ITEM']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1984: самостоятельный проект — перед историческим расходом, номера сдвигаются', () => {
|
||||||
|
const result = insertStandaloneProjectRow([node('ROOT', 1, {}, [node('GROUP', 2), node('INPUT', 3)])], newItem(3));
|
||||||
|
assert.deepEqual(result[0].subRows.map((r) => [r.row_type, r.sort_order]), [['GROUP', 2], ['ITEM', 3], ['INPUT', 4]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1984: insertCell после addRootRow — первый расход самостоятельного проекта', () => {
|
||||||
|
const withRoot = addRootRow([node('ITEM', 1, { project_id: 21 })], ['ROOT', 0, 1, { header: { expense_item_id: 900 } }]);
|
||||||
|
const result = insertCell(withRoot, ['INPUT', 3, 3, { line_id: 5, header: { project_id: 21 } }], 21);
|
||||||
|
assert.equal(result[0].subRows[0].subRows[0].data.line_id, 5);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Импорт в `cellUtils.test.js` дополнить `addRootRow, insertCell, insertStandaloneProjectRow`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Убедиться, что тесты падают**
|
||||||
|
|
||||||
|
Run: `cd web && node --test src/components/RealtimeTable/utils/rowUtils.test.js src/components/RealtimeTable/utils/cellUtils.test.js`
|
||||||
|
Expected: FAIL — `getProjectAddTarget` / `insertStandaloneProjectRow` нет в модуле.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Реализовать**
|
||||||
|
|
||||||
|
- `getProjectAddTarget`: `header.program_id != null` у GROUP, ITEM, INPUT → `{ program_id }`, иначе `{}`.
|
||||||
|
- `insertStandaloneProjectRow`: копия данных (`structuredClone`), у всех строк дерева `sort_order >= нового` → `+1`; целевой список — `subRows` первого ROOT или верхний уровень; вставка перед первой строкой списка с `sort_order` больше нового, иначе в конец.
|
||||||
|
- `subscribeToProjectAdds`: `program_id = data.data.program_id`. В `setData`: если `prevData[0]?.row_type !== 'ROOT'` и в ответе есть ROOT — `addRootRow`; затем `updateCells(x, alignNodeRows(x, updatedCells))`; затем `program_id != null ? insertProjectRow(...) : insertStandaloneProjectRow(...)`.
|
||||||
|
- `RealtimeTable.jsx`: `addProject` без проверок выделения и типа строки (проверка `canModifyRows` остаётся); `handleAddProjectRow` — `contextAddProject({ name: name.trim(), ...getProjectAddTarget(getSelectedRow()?.original) })`.
|
||||||
|
- `CreateProgramProjectModal.jsx`: `isFormValid = name.trim() !== ''`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Убедиться, что тесты проходят**
|
||||||
|
|
||||||
|
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Коммит**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/components/RealtimeTable/utils/rowUtils.js web/src/components/RealtimeTable/utils/rowUtils.test.js web/src/components/RealtimeTable/utils/cellUtils.js web/src/components/RealtimeTable/utils/cellUtils.test.js web/src/components/RealtimeTable/hooks/useRealtimeData.js web/src/components/RealtimeTable/RealtimeTable.jsx web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx
|
||||||
|
git commit -m "AURORA-1984: добавление проекта в разделе на фронте"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: AURORA-1980 — переименование
|
||||||
|
|
||||||
|
**Модель:** sonnet
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx`
|
||||||
|
- Modify: `web/src/components/RealtimeTable/utils/rowUtils.js`
|
||||||
|
- Modify: `web/src/components/RealtimeTable/contexts/RealtimeContext.js:114-180`
|
||||||
|
- Modify: `web/src/components/RealtimeTable/hooks/useRealtimeData.js`
|
||||||
|
- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx`
|
||||||
|
- Modify: `web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx:225-232`
|
||||||
|
- Test: `web/src/components/RealtimeTable/utils/rowUtils.test.js`, `web/src/components/RealtimeTable/utils/cellUtils.test.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `alignNodeRows`, `updateCells`; `upd_form4_*` возвращают `[id, rows]`.
|
||||||
|
- Produces:
|
||||||
|
- `getRenameTarget(rowData): { kind: 'program' | 'project', id: number, name: string } | null`
|
||||||
|
- `isRenameResponse(msg, target): boolean`
|
||||||
|
- из `useRealtimeActions()` (тот же объект, что `deleteProgram`): `renameNode(kind, id, name): boolean`, `subscribeToNodeUpdates(cb)`, `subscribeToNodeRenameResult(cb)` — по образцу `subscribeToNodeDeletes`.
|
||||||
|
- `RenameNodeModal({ target, isSaving, onClose, onSubmit })`, `onSubmit(trimmedName)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Написать падающие тесты**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// rowUtils.test.js
|
||||||
|
test('AURORA-1980: getRenameTarget — программа, проект, остальное', () => {
|
||||||
|
assert.deepEqual(getRenameTarget(form4Row('GROUP', { program_id: 11, name: 'А' })), { kind: 'program', id: 11, name: 'А' });
|
||||||
|
assert.deepEqual(getRenameTarget(form4Row('ITEM', { project_id: 21, name: 'Б' })), { kind: 'project', id: 21, name: 'Б' });
|
||||||
|
assert.equal(getRenameTarget(form4Row('ROOT', { section_code: 'CAP' })), null);
|
||||||
|
assert.equal(getRenameTarget(form4Row('INPUT', { project_id: 21 })), null);
|
||||||
|
assert.equal(getRenameTarget(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1980: isRenameResponse — только свой ответ на свой узел', () => {
|
||||||
|
const target = { kind: 'project', id: 21, name: 'Б' };
|
||||||
|
const msg = { is_self: true, event: 'project_updated', data: { project_id: 21 } };
|
||||||
|
assert.equal(isRenameResponse(msg, target), true);
|
||||||
|
assert.equal(isRenameResponse({ ...msg, error: 'нет прав' }, target), true);
|
||||||
|
assert.equal(isRenameResponse({ ...msg, is_self: false }, target), false);
|
||||||
|
assert.equal(isRenameResponse({ ...msg, data: { project_id: 22 } }, target), false);
|
||||||
|
assert.equal(isRenameResponse({ ...msg, event: 'program_updated', data: { program_id: 21 } }, target), false);
|
||||||
|
assert.equal(isRenameResponse(msg, null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// cellUtils.test.js
|
||||||
|
test('AURORA-1980: ответ переименования обновляет название в строке', () => {
|
||||||
|
const local = [node('ROOT', 1, { section_code: 'CAP' }, [node('ITEM', 2, { project_id: 21, name: 'Старое' })])];
|
||||||
|
const rows = [['ITEM', 2, 5, { header: { project_id: 21, name: 'Новое' } }]];
|
||||||
|
const result = updateCells(local, alignNodeRows(local, rows));
|
||||||
|
assert.equal(result[0].subRows[0].data.header.name, 'Новое');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Убедиться, что тесты падают**
|
||||||
|
|
||||||
|
Run: `cd web && node --test src/components/RealtimeTable/utils/rowUtils.test.js src/components/RealtimeTable/utils/cellUtils.test.js`
|
||||||
|
Expected: FAIL — `getRenameTarget` нет в модуле (тест `cellUtils` проходит: он фиксирует поведение существующих функций).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Реализовать функции и канал событий**
|
||||||
|
|
||||||
|
- `RealtimeContext.js`, `handleMessage`: в ветке `data.error` после тоста и `onErrorRef` — `onNodeRenameResultRef.current?.(data)`, если `data.event` — `program_updated` или `project_updated`. В `switch` добавить оба события: при `data.result` вызвать `onNodeUpdateRef.current?.(data)` и `onNodeRenameResultRef.current?.(data)`.
|
||||||
|
- `renameNode(kind, id, name)` шлёт `{ event: `${kind}_updated`, data: { [`${kind}_id`]: id, name } }` через `addCommonField` и `sendMessage`, как `deleteProject`.
|
||||||
|
- `useRealtimeData`: `subscribeToNodeUpdates((msg) => setData((prev) => updateCells(prev, alignNodeRows(prev, msg.result[1] ?? []))))`, отписка — как у `subscribeToNodeDeletes`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Реализовать окно и кнопку**
|
||||||
|
|
||||||
|
- `RenameNodeModal` — разметка как у `CreateProgramModal`; поле управляемое (`value`), начальное значение из `target.name` при смене `target`; заголовок по `target.kind`; кнопка «Сохранить» неактивна при `name.trim() === ''` или `isSaving`; «Отмена» и клик по фону закрывают без отправки.
|
||||||
|
- `RealtimeTable.jsx`: состояния `pendingRename`, `isRenameSaving` рядом с `pendingDelete`; подписка `subscribeToNodeRenameResult` в `useEffect`: при `isRenameResponse(msg, pendingRename)` — при `msg.error` снять `isRenameSaving` (окно и текст остаются), иначе закрыть окно. `onSubmit`: `renameNode(...)`; при `false` — окно остаётся, иначе `isRenameSaving = true`. Рендер рядом с `ConfirmDeleteRowModal` (`:1159`).
|
||||||
|
- `SettingPanel.jsx`: в блоке формы 4 после «Добавить проект» — `Tooltip` «Переименовать», `IconButton` с `onClick={onRenameNode}`, `disabled={!canRenameNode}`, иконка `EditSvg` из `common/icons/icons`. Вокруг отключаемой кнопки — `<span>`: MUI `Tooltip` не работает на `disabled`. `canRenameNode` считается в `RealtimeTable` при рендере: `Boolean(getRenameTarget(getSelectedRow()?.original))`; `onRenameNode` — `setPendingRename(getRenameTarget(getSelectedRow()?.original))`; оба пропса передаются в панель рядом с `onAddProject` (`:1099`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Убедиться, что тесты и линт проходят**
|
||||||
|
|
||||||
|
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npx biome check src/components/RealtimeTable/Modals/RenameNodeModal.jsx`
|
||||||
|
Expected: PASS, без ошибок линта.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Коммит**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx web/src/components/RealtimeTable/utils/rowUtils.js web/src/components/RealtimeTable/utils/rowUtils.test.js web/src/components/RealtimeTable/utils/cellUtils.test.js web/src/components/RealtimeTable/contexts/RealtimeContext.js web/src/components/RealtimeTable/hooks/useRealtimeData.js web/src/components/RealtimeTable/RealtimeTable.jsx web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx
|
||||||
|
git commit -m "AURORA-1980: переименование программы и проекта"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: AURORA-1979 — подпись типа на экране и в выгрузке
|
||||||
|
|
||||||
|
**Модель:** sonnet
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/components/RealtimeTable/utils/rowUtils.js`
|
||||||
|
- Modify: `web/src/components/RealtimeTable/tableColumns.jsx:255`
|
||||||
|
- Modify: `web/src/components/RealtimeTable/constants/FORM_4/AHR.js`, `CAP.js`, `OPER.js` (колонка `data.header.section_code`)
|
||||||
|
- Modify: `api/src/services/export_service/export_normalizers.py`, `api/src/services/export_service/export_service.py:125,975,981,987`
|
||||||
|
- Test: `web/src/components/RealtimeTable/utils/rowUtils.test.js`, `web/src/components/RealtimeTable/utils/form4TypeLabel.test.js` (новый), `api/tests/unit/test_export_normalizers.py`, `api/tests/unit/test_export_writers.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: строки листа задачи 1.
|
||||||
|
- Produces:
|
||||||
|
- `getForm4TypeLabel(row): 'программа' | 'проект' | null`, `row = { row_type, data }`
|
||||||
|
- признак колонки `valueLabel: 'form4NodeType'`
|
||||||
|
- `Form4DepthRowNormalizer(Form1DepthRowNormalizer)` в `export_normalizers.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Написать падающие тесты**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// rowUtils.test.js
|
||||||
|
test('AURORA-1979: getForm4TypeLabel', () => {
|
||||||
|
assert.equal(getForm4TypeLabel(form4Row('GROUP', { program_id: 11 })), 'программа');
|
||||||
|
assert.equal(getForm4TypeLabel(form4Row('ITEM', { project_id: 21 })), 'проект');
|
||||||
|
assert.equal(getForm4TypeLabel(form4Row('ITEM', { program_id: 11, project_id: 21 })), 'проект');
|
||||||
|
assert.equal(getForm4TypeLabel(form4Row('ROOT', { section_code: 'CAP' })), null);
|
||||||
|
assert.equal(getForm4TypeLabel(form4Row('INPUT', { program_id: 11, project_id: 21 })), null);
|
||||||
|
assert.equal(getForm4TypeLabel(form4Row('GROUP', { section_code: '1.' })), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// form4TypeLabel.test.js — конфиги читаются как текст, см. form2SmetaConfig.test.js
|
||||||
|
for (const sheet of ['AHR', 'CAP', 'OPER']) {
|
||||||
|
test(`AURORA-1979: FORM_4/${sheet} — признак подписи без функций в конфиге`, () => {
|
||||||
|
const source = readFileSync(fileURLToPath(new URL(`../constants/FORM_4/${sheet}.js`, import.meta.url)), 'utf8');
|
||||||
|
assert.match(source, /accessorKey: "data\.header\.section_code",\s*valueLabel: "form4NodeType"/);
|
||||||
|
assert.doesNotMatch(source, /accessorFn/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_export_normalizers.py
|
||||||
|
@pytest.mark.parametrize("data, expected", [
|
||||||
|
({"line_id": None, "header": {"section_code": "CAP", "program_id": 1}}, "программа"),
|
||||||
|
({"line_id": None, "header": {"section_code": "CAP", "program_id": None, "project_id": 2}}, "проект"),
|
||||||
|
({"line_id": None, "header": {"section_code": "CAP", "program_id": 1, "project_id": 2}}, "проект"),
|
||||||
|
({"line_id": None, "header": {"section_code": "CAP"}}, "CAP"),
|
||||||
|
({"line_id": 9, "header": {"section_code": "3.01.", "program_id": 1, "project_id": 2}}, "3.01."),
|
||||||
|
])
|
||||||
|
def test_form4_depth_normalizer_type_label(data, expected):
|
||||||
|
normalized = Form4DepthRowNormalizer(ExcelValueSerializer()).normalize(data)
|
||||||
|
assert normalized["header.section_code"] == expected
|
||||||
|
assert data["header"]["section_code"] != "программа"
|
||||||
|
|
||||||
|
# test_export_writers.py
|
||||||
|
def test_standalone_project_outline_without_group():
|
||||||
|
rows = [("ROOT", 0, 1, {}), ("ITEM", 2, 2, {}), ("INPUT", 3, 3, {}), ("GROUP", 1, 4, {})]
|
||||||
|
assert [(row[0], level) for row, level in _hierarchical_rows(rows)] == [
|
||||||
|
("ROOT", 0), ("ITEM", 1), ("INPUT", 2), ("GROUP", 1),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Убедиться, что тесты падают**
|
||||||
|
|
||||||
|
Run: `cd web && node --test src/components/RealtimeTable/utils/rowUtils.test.js src/components/RealtimeTable/utils/form4TypeLabel.test.js; cd ../api && pytest tests/unit/test_export_normalizers.py -v`
|
||||||
|
Expected: FAIL — нет `getForm4TypeLabel`, `valueLabel`, `Form4DepthRowNormalizer`; `test_standalone_project_outline_without_group` проходит (закрепляет D7a).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Реализовать**
|
||||||
|
|
||||||
|
- `getForm4TypeLabel`: GROUP с `program_id != null` → «программа»; ITEM с `project_id != null` → «проект»; иначе `null`.
|
||||||
|
- В трёх конфигах в дереве `columns`, в листовой колонке под заголовком «Код статьи / подстатьи / позиции» (строка с `columnLetter: "C"` рядом; не в карте цветов в начале файла), строкой сразу после `accessorKey: "data.header.section_code",` добавить `valueLabel: "form4NodeType",`.
|
||||||
|
- `processColumns`, до условия на `:255`: при `isLeafColumn && col.valueLabel === 'form4NodeType'` — `col.id = col.id ?? col.accessorKey; col.accessorFn = (row) => getForm4TypeLabel(row) ?? getColumnValue(row, col.accessorKey);`.
|
||||||
|
- `Form4DepthRowNormalizer.normalize`: `super().normalize(data)`; при `data.get("line_id") is None` и `header.get("project_id") is not None` → `normalized["header.section_code"] = "проект"`, при `header.get("program_id") is not None` → «программа». Проект проверяется первым.
|
||||||
|
- `export_service.py`: `self.form4_row_normalizer = Form4DepthRowNormalizer(self.value_serializer)` рядом с `:125`; в `writer_configs` формы 4 — только `OPER`, `AHR`, `CAP` (`:975,981,987`), `STRUCTURE` не трогать.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Убедиться, что тесты проходят**
|
||||||
|
|
||||||
|
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js; cd ../api && pytest tests/unit/test_export_normalizers.py tests/unit/test_export_writers.py tests/unit/test_export_form1_form4_mapping.py -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Коммит**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/components/RealtimeTable/utils/rowUtils.js web/src/components/RealtimeTable/utils/rowUtils.test.js web/src/components/RealtimeTable/utils/form4TypeLabel.test.js web/src/components/RealtimeTable/tableColumns.jsx web/src/components/RealtimeTable/constants/FORM_4/AHR.js web/src/components/RealtimeTable/constants/FORM_4/CAP.js web/src/components/RealtimeTable/constants/FORM_4/OPER.js api/src/services/export_service/export_normalizers.py api/src/services/export_service/export_service.py api/tests/unit/test_export_normalizers.py api/tests/unit/test_export_writers.py
|
||||||
|
git commit -m "AURORA-1979: подпись программы и проекта в третьем столбце и выгрузке"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: AURORA-1989 — окно выбора расхода
|
||||||
|
|
||||||
|
**Модель:** haiku
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/components/RealtimeTable/utils/expenseItemOptions.js`
|
||||||
|
- Modify: `web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx:14-22,75`
|
||||||
|
- Test: `web/src/components/RealtimeTable/utils/expenseItemOptions.test.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `getExpenseItemLabel(option): string`, `compareExpenseItems(a, b): number`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Написать падающие тесты**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { compareExpenseItems, getExpenseItemLabel } from './expenseItemOptions.js';
|
||||||
|
|
||||||
|
const sorted = (ids) => ids.map((item_id) => ({ item_id, name: 'x' })).sort(compareExpenseItems).map((o) => o.item_id);
|
||||||
|
|
||||||
|
test('AURORA-1989: подпись варианта', () => {
|
||||||
|
assert.equal(getExpenseItemLabel({ item_id: '3.01.', name: 'Связь' }), '3.01. — Связь');
|
||||||
|
assert.equal(getExpenseItemLabel({ item_id: null, name: 'Связь' }), 'Связь');
|
||||||
|
assert.equal(getExpenseItemLabel({ name: '' }), '');
|
||||||
|
assert.equal(getExpenseItemLabel(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1989: сортировка с учётом числовых частей, без кода — в конце', () => {
|
||||||
|
assert.deepEqual(sorted(['3.10.', '3.2.', '10', '2']), ['2', '3.2.', '3.10.', '10']);
|
||||||
|
assert.deepEqual(sorted([null, '1.', '']), ['1.', null, '']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1989: поиск по подписи находит номер и название', () => {
|
||||||
|
const label = getExpenseItemLabel({ item_id: '3.01.', name: 'Связь' });
|
||||||
|
assert.ok(label.includes('3.01'));
|
||||||
|
assert.ok(label.includes('Связь'));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Убедиться, что тесты падают**
|
||||||
|
|
||||||
|
Run: `cd web && node --test src/components/RealtimeTable/utils/expenseItemOptions.test.js`
|
||||||
|
Expected: FAIL — модуль не найден.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Реализовать**
|
||||||
|
|
||||||
|
- `compareExpenseItems`: сравнение `item_id` через `localeCompare(b, 'ru', { numeric: true })`; пустой или отсутствующий код — после любых с кодом; порядок пустых между собой сохраняется (возврат 0).
|
||||||
|
- `SelectExpenseItemModal.jsx`: `setExpenseItemOption([...data.result].sort(compareExpenseItems))`; `getOptionLabel={getExpenseItemLabel}`. Встроенный фильтр Autocomplete ищет по подписи, серверный запрос не меняется.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Убедиться, что тесты проходят**
|
||||||
|
|
||||||
|
Run: `cd web && node --test src/components/RealtimeTable/utils/expenseItemOptions.test.js`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Коммит**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/components/RealtimeTable/utils/expenseItemOptions.js web/src/components/RealtimeTable/utils/expenseItemOptions.test.js web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx
|
||||||
|
git commit -m "AURORA-1989: номер статьи, сортировка и поиск в окне выбора расхода"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 8: Полная проверка и стенд
|
||||||
|
|
||||||
|
**Модель:** sonnet
|
||||||
|
|
||||||
|
**Files:** код не меняется; находки исправляются отдельными коммитами в файлах своей задачи.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: всё, что сделано в задачах 1–7.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Прогнать весь backend**
|
||||||
|
|
||||||
|
Run: `cd api && pytest --init_db`
|
||||||
|
Expected: PASS, кроме известного `tests/unit/test_export_service_query_params.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Прогнать фронт и линт**
|
||||||
|
|
||||||
|
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npx biome check src/components/RealtimeTable`
|
||||||
|
Expected: PASS; линт — без новых ошибок относительно ветки `test`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Живая проверка на стенде (форма 4, две вкладки)**
|
||||||
|
|
||||||
|
Поднять API и фронт по способу из заметки о стенде (`perf-pg`, логин admin/admin123). Каждый пункт отмечается результатом:
|
||||||
|
|
||||||
|
- Проект в разделе: ничего не выделено и выделен ROOT — виден в обеих вкладках, после F5 на том же месте.
|
||||||
|
- Выделены программа, проект в программе, расход, самостоятельный проект — новый проект создаётся там, где требует спека.
|
||||||
|
- Порядок «программа, проект, программа» по созданию.
|
||||||
|
- Пустой раздел: проект без ROOT; первый расход — появляется ROOT, расход под проектом.
|
||||||
|
- Правка `plan.q1` у расхода самостоятельного проекта пересчитывает ITEM и ROOT.
|
||||||
|
- Переименование программы и обоих видов проекта: видно в двух вкладках; пробелы не сохраняются; ошибка (остановить API или `role_not_allowed`) оставляет окно с текстом.
|
||||||
|
- Удаление пустого самостоятельного проекта; с расходом — отказ с причиной.
|
||||||
|
- Подписи «программа»/«проект» и цвета строк на экране; те же подписи и outline в Excel, строки GROUP с пустой программой нет.
|
||||||
|
- Окно выбора расхода: «номер — название», порядок, поиск «3.01».
|
||||||
|
|
||||||
|
- [ ] **Step 4: Записать найденное**
|
||||||
|
|
||||||
|
Новые решения — в `design.md` с маркером `R-n`, наблюдения — `O-n`. Исправления кода — коммитами с тестом.
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-11-08
|
||||||
137
openspec/changes/aurora-1984-pd-standalone-projects/design.md
Normal file
137
openspec/changes/aurora-1984-pd-standalone-projects/design.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Форма 4 (FORM_4) в БД — «Смета РФ поддержка», на фронте — «Проектная деятельность». Листы AHR/CAP/OPER строятся `v3.v_form4_sheet_jsonb` (последняя версия — `sql/0053_form2_ahr_source_sheet.sql:840`). Иерархия листа: ROOT (раздел, depth 0) → GROUP (программа, depth 1) → ITEM (проект, depth 2) → INPUT (расход, `budget_line`, depth 3). `sort_order` — `ROW_NUMBER() OVER (ORDER BY sort_path)`, где `sort_path` у INPUT — `ARRAY[sec_id, COALESCE(prog_id, 999999), …, line_id]`. ROOT выводится только если в разделе есть хотя бы один расход (`root_names` из `inp`); пустые программы и проекты выводятся из `all_programs` / `all_projects`.
|
||||||
|
|
||||||
|
Таблица `v3.form4_project` (`sql/0010_form4.sql:25`): `level IN ('program','project')`, `parent_id` (self-FK), `section_code`, `form_id`. Ограничения: `chk_form4_project_parent` — у программы `parent_id IS NULL`; `chk_form4_project_section_code` — `(level = 'program') = (section_code IS NOT NULL)`. `budget_line.project_id` → `form4_project.id` (FK); расход может ссылаться и на программу напрямую (ветка «B» в `inp`, исторические данные).
|
||||||
|
|
||||||
|
Операции (`sql/0024_upd_form4_projects.sql`, `sql/0026_budget_line_stage_access.sql:486`): `add_form4_program(name, form_id, section)`, `add_form4_project(name, program_id)`, `upd_/del_form4_program`, `upd_/del_form4_project`. Все возвращают `(project_id, row_type, depth, sort_order, data)` — затронутые строки листа. WebSocket (`api/src/api/v1/websocket.py:470-505`): `program_added`, `project_added` (`{name, program_id}`), `program_updated`, `program_deleted`, `project_updated`, `project_deleted`; рассылка всем подключённым к листу.
|
||||||
|
|
||||||
|
Ответ на правку ячейки для FORM_4 — `v3._form_affected_rows` (`sql/0052_…:3293`): полный лист, отбор INPUT + ROOT + GROUP по `program_id` + ITEM по `project_id` изменённых строк.
|
||||||
|
|
||||||
|
Фронт: `web/src/components/RealtimeTable/` — `RealtimeTable.jsx` (кнопки «Добавить программу/проект», `getDeleteTarget` из AURORA-1978), `hooks/useRealtimeData.js` (обработчики событий, `insertProjectRow` по `program_id`), `utils/cellUtils.js` (`alignNodeRows`, `deleteProgramRow`/`deleteProjectRow` из AURORA-1978), `utils/rowUtils.js` (`buildRowHierarchy` по `row_type`, переживает пропуск уровня). Выгрузка Excel (`api/src/services/export_service/`) берёт те же строки листа, иерархию строит по `row_type` (`export_writers._hierarchical_rows`).
|
||||||
|
|
||||||
|
Кто ещё читает программы/проекты — проверено 08.11: своды (`v_summary_view`), SMETA (`v_form4_smeta`) и STRUCTURE (`v_form4_view`, ветка STRUCTURE) — нет; `hard_delete_budget_form` удаляет по `form_id`; `add_budget_line` проверяет только существование `project_id`.
|
||||||
|
|
||||||
|
Ветка `feat/305-aurora-1984-pd-standalone-projects` начата от `feat/303-aurora-1978-pd-delete-program-project` (PR #304, не влит на 08.11).
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals**
|
||||||
|
- Самостоятельный проект: создание, расходы, переименование, удаление пустого, место в листе в порядке создания, суммы в ROOT ровно один раз, выгрузка.
|
||||||
|
- AURORA-1979, 1980, 1989 (см. proposal).
|
||||||
|
|
||||||
|
**Non-Goals**
|
||||||
|
- Перенос существующего проекта в программу и из неё.
|
||||||
|
- Миграция существующих данных: проекты в программах не трогаются.
|
||||||
|
- Расходы, привязанные прямо к программе (ветка «B»), — поведение не меняется.
|
||||||
|
- Изменение сводов, листов SMETA/STRUCTURE.
|
||||||
|
|
||||||
|
**Anti-goals** (сломать — хуже, чем не сделать)
|
||||||
|
- Фиктивная GROUP с пустым `program_id` в листе или выгрузке; двойной учёт сумм самостоятельного проекта в ROOT.
|
||||||
|
- Любое изменение вида листа и сумм для форм без самостоятельных проектов.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1. Модель: проект без родителя хранит раздел сам
|
||||||
|
`form4_project(level='project', parent_id NULL, section_code NOT NULL, form_id NOT NULL)`. Ограничение `chk_form4_project_section_code` пересоздаётся как `((level = 'program') OR (parent_id IS NULL)) = (section_code IS NOT NULL)`. Новое ограничение `chk_form4_project_standalone_form`: `parent_id IS NOT NULL OR form_id IS NOT NULL` (у программы и самостоятельного проекта форма обязательна; существующие программы её имеют — проверить запросом в миграции перед `ADD CONSTRAINT`). Существующие строки обоим ограничениям удовлетворяют (у всех проектов есть родитель и нет раздела). Отдельный тип/флаг не вводится: «самостоятельный» = `level='project' AND parent_id IS NULL`. ORM-модель `api/src/db/models/form4_project.py` повторяет ограничения — правится вместе с миграцией.
|
||||||
|
|
||||||
|
### D2. `add_form4_project` — одна функция, программа или форма+раздел
|
||||||
|
Новая сигнатура: `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)`; старая `(varchar, int)` удаляется (`DROP FUNCTION`), вызовы с двумя аргументами продолжают работать за счёт DEFAULT. Ровно один из вариантов: `p_program_id` или пара `p_form_id` + `p_section_code`; иначе `RAISE`. Для самостоятельного: проверка, что форма — FORM_4 и раздел — один из AHR/CAP/OPER; права — `v3.can_edit(p_form_id, p_section_code, NULL, NULL, user)` с теми же кодами ошибок, что сейчас; аудит `PROJECT_CREATE` с `section_code` (ключ `parent_id` в событии отсутствует: `log_event` применяет `jsonb_strip_nulls`). Возвращает строки так же, как вариант с программой: новый ITEM + ROOT раздела (если он есть в листе).
|
||||||
|
|
||||||
|
### D3. Лист: ITEM под ROOT, порядок по id верхнего узла
|
||||||
|
В `v3.v_form4_sheet_jsonb`:
|
||||||
|
- `inp`: для расхода самостоятельного проекта `prj_id` = проект (соединение `prj` по `level='project'` его находит), `prog_via_prj` и `prog_direct` пусты → `prog_id` = NULL.
|
||||||
|
- `all_projects`: добавить самостоятельные проекты листа (`parent_id IS NULL AND section_code = p_sheet AND form_id = p_form_id`, с `p_sections`); сейчас там `JOIN all_programs`, самостоятельные туда не попадают.
|
||||||
|
- Агрегаты (блок «4», GROUPING SETS по раздел / программа / проект). Уровень программы (`lvl = 1`) уже отсекает `prog_id IS NULL` (`0053:1248`) — фиктивной GROUP не будет. **Уровень проекта соединяется по `d.prog_id = n.prog_id AND d.prj_id = n.prj_id` (`0053:1289`) — при `prog_id IS NULL` равенство ложно, суммы ITEM самостоятельного проекта потеряются.** Заменить сравнение `prog_id` на `IS NOT DISTINCT FROM` во всех соединениях уровня проекта (`prj_names` ↔ агрегаты, пустые проекты из `all_projects`); для строк с непустой программой результат идентичен. ROOT суммирует все расходы раздела один раз (как сейчас).
|
||||||
|
- Исторические расходы FORM_4 без проекта и программы (`project_id IS NULL`) остаются как есть: `sort_path` с `999999`, только в ROOT. Самостоятельные проекты используют реальные id (< 999999), пересечения нет. Тест: лист с таким расходом не меняется.
|
||||||
|
- `sort_path` на уровне под ROOT — id верхнего узла: для программы `prog_id`, для самостоятельного проекта `prj_id`. Id из одной последовательности → порядок создания. Все `sort_path` остаются четырёхэлементными: GROUP `[sec, prog_id, 0, 0]`, ITEM в программе `[sec, prog_id, prj_id, 0]`, самостоятельный ITEM `[sec, prj_id, 0, 0]`, его INPUT `[sec, prj_id, 0, line_id]`. Сортировка программ и проектов в программах не меняется, `sort_path` существующих строк не меняется.
|
||||||
|
- ITEM самостоятельного проекта: `depth = 2` (как у проекта в программе), `header`: `section_code`, `name`, `program_id: null`, `project_id`, `expense_item_id` раздела.
|
||||||
|
Тело функции — от версии 0053 (последней), меняются только перечисленные блоки.
|
||||||
|
|
||||||
|
### D4. Правка и удаление без родителя
|
||||||
|
`upd_form4_project` / `del_form4_project`: раздел — `COALESCE(программа.section_code, проект.section_code)`; в возвращаемых строках GROUP только при наличии родителя. `del_form4_project` — прежний запрет при расходах. `upd/del_form4_program` не меняются.
|
||||||
|
|
||||||
|
### D4a. Контракт ответа, когда ROOT раздела нет
|
||||||
|
ROOT выводится только при наличии расходов в разделе (как сейчас, не меняется). Ответ `add_/upd_/del_form4_project` для самостоятельного проекта: затронутый ITEM (кроме удаления) и ROOT раздела, **если ROOT есть в листе после операции**; `sort_order` — глобальный, из полной сборки листа (как у всех функций, строящих ответ из `v_form4_sheet_jsonb`). В пустом разделе ответ на создание — один ITEM. Первый расход в разделе возвращает ROOT существующим путём `add_budget_line` → фронт добавляет ROOT (`addRootRow` в `useRealtimeData`), это поведение уже есть. Фронт вставляет ITEM под ROOT, если ROOT есть локально, иначе на верхний уровень.
|
||||||
|
|
||||||
|
### D5. `_form_affected_rows` не меняется
|
||||||
|
Для расхода самостоятельного проекта `program_id` в header INPUT пустой → GROUP не отбирается, ITEM — по `project_id`, ROOT — по пути статей. Покрывается интеграционным тестом (спека: «Правка расхода»). Если тест покажет иначе — правка в той же миграции.
|
||||||
|
|
||||||
|
### D6. Сервер (Python)
|
||||||
|
- `websocket.py` `__add_project`: `program_id` необязателен; без него — `form_id` и `sheet` соединения передаются в сервис.
|
||||||
|
- `SheetService.add_project`: без программы — проверка доступа по форме, как в `add_program` (не админ → форма среди оргединиц пользователя), затем `sheet_repo.add_project(name, program_id=None, form_id, section_code=sheet)`.
|
||||||
|
- `SheetRepository.add_project` — вызов новой сигнатуры с именованными параметрами.
|
||||||
|
- Ответ `project_added`: `[project_id, rows]` как сейчас.
|
||||||
|
|
||||||
|
### D7. Фронт: создание и вставка
|
||||||
|
- «Добавить проект» доступна без выделения. Цель по выделенной строке (функция рядом с `getDeleteTarget` в `rowUtils.js`, с unit-тестом): GROUP → `program_id` этой программы; ITEM/INPUT → `program_id` из header (может быть null → в разделе); ROOT или ничего → в разделе. Отправка `project_added {name}` или `{name, program_id}`.
|
||||||
|
- Обработка ответа: новый ITEM — в `subRows` программы (как сейчас) или под ROOT раздела, либо на верхний уровень, если ROOT в локальном дереве нет. Позиция — по `sort_order` среди соседей; агрегаты из ответа вливаются через `alignNodeRows` (сопоставление по id, а не по `sort_order`, см. AURORA-1978). Если в ответе пришёл ROOT, которого локально нет, — добавить его (аналог `addRootRow`).
|
||||||
|
- Удаление — без изменений (AURORA-1978 работает по `project_id`).
|
||||||
|
- Добавление расхода к самостоятельному проекту — существующий путь без правок: `SelectExpenseItemModal` берёт статьи по листу (`ExpenseItemApi.getAll({ r_start, sheet })`), раздел самостоятельного проекта = лист; `add_budget_line` раздел по проекту не проверяет, только существование `project_id`; `handleAddExpenseItemRow` берёт `project_id` из header ITEM.
|
||||||
|
- Одновременное создание проектов двумя пользователями: сервер упорядочивает по id; клиент вставляет по `sort_order` из ответа; возможное расхождение позиции до перезагрузки допустимо, отдельных тестов гонки не делаем.
|
||||||
|
|
||||||
|
### D7a. Вид в выгрузке и на экране
|
||||||
|
Самостоятельный ITEM: раскраска — как у проекта (`row_type` ITEM); отступ названия и уровень группировки (outline) — как у программы (1): и на экране (`Cell.jsx`, `row.depth` дерева), и в Excel (`alignment.indent = outline_level`) они считаются по фактической вложенности, это НЕ получается само: оба построителя вкладывают строки по `row_type` (ITEM глубже GROUP), и самостоятельный проект после программы оказался бы её потомком (свёртка программы прячет его, в Excel уровни 2/3). Поэтому построители распознают самостоятельный проект явно (ITEM, `header.project_id != null`, `header.program_id == null`) и ставят его на уровень GROUP: `isStandaloneProject` в `rowUtils.js` (`buildRowHierarchy`) и `_is_standalone_project` в `export_writers.py` (`_hierarchical_rows`) (находка I-1 / B1 живой проверки). Значение `depth = 2` в данных нужно только для поиска строки в `updateCells` (`findRowPath` сравнивает его с серверным). На экране свёртка — по `row_type` (`buildRowHierarchy`), так же. Эталон теста выгрузки: строки самостоятельного проекта — ITEM под ROOT, без GROUP, outline 1, расходы под ним outline 2.
|
||||||
|
|
||||||
|
### D8. AURORA-1980: переименование
|
||||||
|
- Кнопка «Переименовать» в группе «Строки» панели (только FORM_4), активна при выделенной программе или проекте. Окно — отдельный компонент `RenameNodeModal` с начальным значением и заголовком «Переименование программы/проекта»: `CreateProgramModal` закрывается сразу после отправки и не принимает начальное значение. Состояние окна — в `RealtimeTable` рядом с `pendingDelete` (редьюсер `SheetModals` не хранит данных узла).
|
||||||
|
- Отправка `program_updated {program_id, name}` / `project_updated {project_id, name}` с обрезанным названием; пустое после обрезки — кнопка сохранения неактивна.
|
||||||
|
- Окно закрывается по успешному ответу на своё событие; при `error` в ответе — остаётся открытым с введённым текстом, тост с причиной (`getWsErrorMessage`). Сопоставление ответа с запросом — по `is_self`, событию и id узла (WS без идентификатора запроса; чужое переименование того же узла окно не закрывает). Ошибка приходит только отправителю с исходными `event` и `data`, но `handleMessage` сейчас передаёт в `onError` лишь `data.error` — для `program_updated` / `project_updated` добавляется отдельный канал с полным сообщением.
|
||||||
|
- Обработчики ответов `program_updated` / `project_updated` (их сейчас нет на фронте): имя в строке и агрегаты через `alignNodeRows` + `updateCells`; рассылка приходит всем подключённым.
|
||||||
|
|
||||||
|
### D9. AURORA-1979: подпись в третьем столбце
|
||||||
|
Колонка «Код статьи / подстатьи / позиции» листов AHR/CAP/OPER формы 4: для GROUP с `program_id` — «программа», для ITEM с `project_id` — «проект»; иначе — как сейчас. Чистая функция подписи (unit-тест) — в `rowUtils.js`; в конфиге колонок FORM_4 только строковый признак `valueLabel: 'form4NodeType'`, а `accessorFn` назначает `processColumns` после клонирования: `tableColumns.jsx:131` делает `structuredClone` конфига, функция в нём уронит таблицу. В выгрузке Excel — та же подпись в той же колонке: новый `Form4DepthRowNormalizer` (наследник `Form1DepthRowNormalizer`, который общий для всех форм) только для листов AHR/CAP/OPER формы 4; нормализатор не получает `row_type`, тип строки определяется по `line_id` и id в header. `header.section_code` в данных не меняется, раскраска (`getColorCell`, `export_service.py:840`) читает его из исходной строки.
|
||||||
|
|
||||||
|
### D10. AURORA-1989: окно выбора расхода
|
||||||
|
`SelectExpenseItemModal`: подпись варианта `«${item_id} — ${name}»` (если `item_id` пуст — только название); сортировка на клиенте естественная по `item_id` (числовые части сравниваются как числа: «3.2.» < «3.10.»), без кода — в конце; поиск MUI Autocomplete по подписи (номер и название). Чистые функции подписи/сравнения — в utils с unit-тестами. Серверный запрос не меняется.
|
||||||
|
|
||||||
|
### D11. Миграция
|
||||||
|
`api/alembic/versions/0059_form4_standalone_project.py` + `sql/0059_form4_standalone_project.sql`. Порядок: ограничение → `DROP FUNCTION add_form4_project(varchar, int4)` → новая `add_form4_project` → `upd_/del_form4_project` → `v_form4_sheet_jsonb`. Downgrade — `pass`, как у SQL-миграций репозитория (26 из 29 последних; 0058 — образец). Номер 0059 может сдвинуться при rebase на свежий `test`.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
- Интеграционные (`api/tests/integration/test_form4_standalone_project.py`, по образцу `test_form4_sheet_sums.py`): создание самостоятельного проекта (WS или SQL + GET листа) — ITEM под ROOT, нет GROUP с пустым `program_id`; порядок «программа, проект, программа» по созданию; суммы ROOT = программа + самостоятельный проект; правка ячейки расхода — состав ответа (INPUT, ITEM, ROOT, без GROUP) и равенство `data` с GET; переименование и удаление (пустой — ок, с расходом — отказ); права — исполнитель чужой формы получает отказ на создание, переименование и удаление самостоятельного проекта; создание с пустым названием — отказ; вызов с двумя аргументами (программа) работает как раньше; лист с историческим расходом без проекта и форма без самостоятельных проектов — результат `v_form4_sheet_jsonb` совпадает побайтно с версией 0053: тесты идут на БД после миграций, поэтому эталон — тело функции из `0053_*.sql`, созданное в тесте как `pg_temp`-функция.
|
||||||
|
- Существующие тесты FORM_4 (`test_form4_sheet_sums.py`, `test_form4_transfer_econ.py`) — зелёные без правок.
|
||||||
|
- Фронт (`node --test`): цель «Добавить проект» по выделению; вставка ITEM под ROOT / на верхний уровень; подпись третьего столбца; подпись/сортировка/фильтр статей расходов.
|
||||||
|
- Выгрузка: unit или интеграционный тест — самостоятельный проект в Excel без лишней строки, подпись «проект».
|
||||||
|
- Живая проверка на стенде (perf-pg, форма 4): все сценарии спеки, две вкладки для рассылки.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- Перенумерация `sort_order` сервером при появлении/исчезновении ROOT — фронт сопоставляет агрегаты по id (`alignNodeRows`), но позиция вставки нового ITEM опирается на `sort_order`; при расхождении строка может встать не на своё место до перезагрузки. Митигация — позиция по `sort_order` соседей из ответа, тест на пустой раздел.
|
||||||
|
- Ветка поверх #304: если #304 изменится по ревью, нужен rebase.
|
||||||
|
- Excel: самостоятельный ITEM на уровне группировки 1 (вместо 2) — проверить вид, не ломать раскладку колонок по `depth`.
|
||||||
|
- `rowUtils.getParentId` для ROOT и GROUP возвращает `expense_item_id`, и `insertCell` сравнивает его с `project_id`: при совпадении чисел новый расход встанет под ROOT до перезагрузки. Поведение существовало и для проектов в программах; в этой ветке не меняется (O-1).
|
||||||
|
- Исторический расход без проекта (`project_id` NULL, sort 999999), оказавшийся сразу после самостоятельного проекта, построители дерева (фронт `buildRowHierarchy` и выгрузка) прикрепят к нему — тот же класс дефекта, что уже есть у последнего проекта программы. Follow-up, если в реальных данных есть такие строки (M-1 итогового ревью).
|
||||||
|
- GET срезает null (AURORA-1848), поэтому `header.program_id` приходит то `null`, то `undefined`: сравнивать только через `!= null`.
|
||||||
|
|
||||||
|
## Оставлено исполнителю
|
||||||
|
|
||||||
|
Пунктов нет: форма `sort_path`, окно переименования и размещение подписи зафиксированы при перепроверке 08.11.
|
||||||
|
|
||||||
|
## Decisions made without discussion
|
||||||
|
|
||||||
|
| Дата | Решение | Обоснование | Цена ошибки |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2026-11-08 | Ветка от `feat/303-aurora-1978-…` (PR #304), а не от `test` | удаление программ/проектов и `alignNodeRows` уже там; 1984 их использует | rebase при изменениях #304 по ревью |
|
||||||
|
| 2026-11-08 | «Добавить проект»: программа → в ней; раздел/ничего → в разделе; проект/расход → рядом (та же программа или раздел) | естественное продолжение текущей кнопки, без новой кнопки | при другом ожидании — правка выбора цели (одна функция) |
|
||||||
|
| 2026-11-08 | Одна функция `add_form4_project` с DEFAULT-параметрами вместо новой | старые вызовы работают, одна точка прав и аудита | DROP/CREATE функции в миграции |
|
||||||
|
| 2026-11-08 | Самостоятельный ITEM с `depth = 2`, без новой глубины | раскраска и вид — по `row_type`, как у проекта; уровень в дереве задаёт явный признак самостоятельного проекта (`isStandaloneProject` / `_is_standalone_project`, R-8, design #20), `depth = 2` остаётся в данных для `findRowPath` | отступ в Excel/на экране может потребовать правки |
|
||||||
|
| 2026-11-08 | Подпись «программа»/«проект» (1979) и в выгрузке Excel | выгрузка повторяет экран (как 1902/1903) | лишняя правка export_service, если заказчику нужен код |
|
||||||
|
| 2026-11-08 | Сортировка и подпись статей (1989) на клиенте | список короткий, сервер не трогаем | при большом списке — перенос на сервер |
|
||||||
|
| 2026-11-08 | Окно переименования закрывается по ответу сервера, сопоставление по событию и id | требование «при ошибке сохранять введённое значение» | при двух быстрых переименованиях одного узла — ответ может прийти «не тому» окну (маловероятно) |
|
||||||
|
| 2026-11-08 | Соединения уровня проекта в `v_form4_sheet_jsonb` — `IS NOT DISTINCT FROM` по `prog_id` (находка рецензента дизайна) | иначе суммы самостоятельного проекта теряются | при ошибке — изменение сумм существующих форм; закрывается тестом эквивалентности GET |
|
||||||
|
| 2026-11-08 | Ограничение `form_id IS NOT NULL` для узлов без родителя | самостоятельный проект без формы не должен существовать | миграция упадёт, если в данных есть программа без формы — проверка перед `ADD CONSTRAINT` |
|
||||||
|
| 2026-11-08 | Outline и отступ самостоятельного проекта — уровень программы (1), на экране и в Excel | уровень 1 задаёт явный признак самостоятельного проекта в построителях дерева (фронт `buildRowHierarchy`) и выгрузки (`_hierarchical_rows`), см. R-8 (design #20); писатель и `Cell.jsx` не менялись, «нет уровня программы» | если заказчик ждёт отступ как у проекта — правка отображения |
|
||||||
|
| 2026-11-08 | Гонку одновременного создания не тестируем, позиция до перезагрузки может отличаться | сервер упорядочивает по id, редкий случай | редкое визуальное расхождение до F5 |
|
||||||
|
| 2026-11-08 | R-1 (design #13): подпись третьего столбца — через строковый признак в конфиге и `accessorFn` в `processColumns` | `structuredClone` конфига падает на функциях | правка одной ветки в `processColumns` |
|
||||||
|
| 2026-11-08 | R-2 (design #14): отдельный `Form4DepthRowNormalizer` для AHR/CAP/OPER формы 4 | `Form1DepthRowNormalizer` общий для всех форм, его правка задела бы их выгрузки | лишний класс из нескольких строк |
|
||||||
|
| 2026-11-08 | R-3 (design #15): отдельное окно `RenameNodeModal`, закрытие по `is_self` + событие + id, отдельный канал ошибок | `CreateProgramModal` закрывается сразу, ошибка теряет событие и id | лишний компонент; чужой ответ окно не закроет |
|
||||||
|
| 2026-11-08 | R-4 (design #16): downgrade миграции 0059 — `pass` вместо возврата трёх функций | соглашение репозитория; ~800 строк копий тел, которые никто не запускает | откат только вручную |
|
||||||
|
| 2026-11-08 | R-5 (design #17): эталон «как до миграции» — `pg_temp`-копия функции из `0053_*.sql` | тесты идут на БД после миграций, снять снимок до неё нельзя | тест привязан к файлу 0053 |
|
||||||
|
| 2026-11-08 | R-6 (design #18): права на переименование и удаление оставлены как есть — членство в форме (`_validate_form4_node_access`), без `v3.can_edit` | D4 не меняет `upd/del_form4_program`; единообразно для программ и проектов; кнопки в UI скрыты при `canModifyRows = false` | прямой запрос по WS может переименовать или удалить узел при закрытом окне ввода |
|
||||||
|
| 2026-11-08 | R-7 (design #19): путь «проект в программу» в `subscribeToProjectAdds` теперь тоже идёт через `alignNodeRows` + `updateCells` (как требует шаг 3 плана, задача 4), а не через `updateCells` с серверными номерами. Находка T4.1 ревью задачи 4 | сопоставление по id устойчивее номеров, ROOT добавляется общим кодом; для программы, уже присутствующей локально, результат тот же или точнее | строки ответа с ключом, которого нет локально (например GROUP чужой только что созданной программы), отбрасываются до события `program_added`/перезагрузки |
|
||||||
|
| 2026-11-08 | R-8 (design #20): самостоятельный проект узнаётся явно (`ITEM` с `project_id != null` и без `program_id`) и в дереве на экране, и в выгрузке получает уровень программы (брат GROUP под ROOT); данные (`depth = 2`) не меняются. Находка I-1 живой проверки (D7a ошибочно считал, что это получается само) | проект, созданный после программы, иначе вкладывался в предыдущую программу: сворачивание программы скрывало его, в Excel уровни 2/3 вместо 1/2 | если у ITEM другой формы появится `project_id` без `program_id`, он станет братом верхнего уровня; для SMETA и форм 1/2 `project_id` в header нет |
|
||||||
|
| 2026-11-08 | R-9 (design #21): находка B2 живой проверки (после добавления расхода у строк ниже вставки временно показывается имя предыдущей строки до F5) не чинится в этой ветке: воспроизводится на программах без самостоятельных проектов, код `useRealtimeData.js:118-119` / `findRowPath` на базе не менялся | не относится к 1984; правка затрагивает общий путь `row_added` всех форм | пользователь видит неверные названия до перезагрузки; вынесено в follow-up (deferred) |
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
На форме 4 (FORM_4, на фронте «Проектная деятельность») иерархия листа жёсткая: раздел (лист AHR/CAP/OPER) → программа → проект → расходы. Проект без программы создать нельзя: интерфейс требует выделить программу, `v3.add_form4_project` отвергает вызов без `p_program_id`, у проекта нет своего раздела (ограничение `chk_form4_project_section_code`), а лист находит проекты только через программу. Пользователю приходится заводить программу-обёртку ради одного проекта.
|
||||||
|
|
||||||
|
Замечание тестировщика AURORA-1984 (Gitea #305): проект можно создать прямо в разделе, без программы. Человек (08.11.2026): «всё то же самое, как и сейчас, только нет уровня программы»; в разделе программы и самостоятельные проекты стоят вперемешку в порядке создания.
|
||||||
|
|
||||||
|
В той же ветке закрываются три смежных замечания по ПД: AURORA-1979 (подпись «программа»/«проект» в третьем столбце), AURORA-1980 (переименование программы и проекта), AURORA-1989 (окно выбора расхода: номер статьи, сортировка, поиск).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Самостоятельный проект.** Запись `v3.form4_project` с `level='project'`, `parent_id IS NULL`, собственными `section_code` и `form_id`. Ограничение `chk_form4_project_section_code` ослабляется: раздел обязателен у программы и у проекта без родителя, запрещён у проекта в программе.
|
||||||
|
- **Создание.** `v3.add_form4_project` принимает либо программу (как сейчас), либо форму и раздел. WS-событие `project_added` принимает `program_id` необязательно; без него проект создаётся в разделе текущего листа соединения.
|
||||||
|
- **Лист.** `v3.v_form4_sheet_jsonb` выводит самостоятельный проект строкой ITEM прямо под ROOT раздела (без GROUP), с расходами под ним. Порядок на втором уровне — по id верхнего узла (программы или самостоятельного проекта), то есть в порядке создания. Суммы проекта входят в ROOT один раз; фиктивной GROUP с `program_id = NULL` нет.
|
||||||
|
- **Правка и удаление.** `v3.upd_form4_project` и `v3.del_form4_project` работают и без родителя: раздел берётся у проекта, в ответе нет GROUP. Запрет удалять проект с расходами — прежний.
|
||||||
|
- **Фронт.** «Добавить проект»: выделена программа → внутри неё; выделена строка раздела или ничего → в разделе; выделен проект или расход → рядом, в той же программе или в разделе. Вставка нового ITEM под ROOT. Удаление (AURORA-1978) уже работает по `project_id`.
|
||||||
|
- **AURORA-1979.** В третьем столбце листов ПД у программы «программа», у проекта (в программе и самостоятельного) «проект» вместо кода раздела; так же в выгрузке Excel. Коды статей у строк расходов и ROOT не меняются.
|
||||||
|
- **AURORA-1980.** Действие «Переименовать» для выделенной программы или проекта: окно с текущим названием, события `program_updated` / `project_updated` (сервер уже поддерживает). Пустое название и пробелы не сохраняются; при ошибке окно остаётся открытым с введённым текстом.
|
||||||
|
- **AURORA-1989.** Окно выбора расхода: «номер статьи — название», сортировка по номеру статьи с учётом числовых частей (2 раньше 10), поиск по номеру и названию.
|
||||||
|
- Новая миграция `0059_form4_standalone_project` (номер — следующий после 0058 на момент ветки).
|
||||||
|
|
||||||
|
**Не меняется:**
|
||||||
|
- существующие данные: все проекты остаются в своих программах, перенос проекта в программу и из неё не делается;
|
||||||
|
- своды, листы SMETA и STRUCTURE (не читают программы и проекты);
|
||||||
|
- права: создание самостоятельного проекта проверяется так же, как у программы (`v3.can_edit` по форме и разделу), переименование и удаление — тоже как у программы (членство в форме, `_validate_form4_node_access`);
|
||||||
|
- контракт строк листа для программ и проектов в программах.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `form4-programs-projects`: программы и проекты листов AHR/CAP/OPER формы 4 — создание (в программе и в разделе), переименование, удаление, место в листе и в итогах, подпись типа в третьем столбце, выбор расхода для проекта.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- SQL: миграция `0059_*.py` + `sql/0059_*.sql`: ограничение `form4_project`, `v3.add_form4_project` (новая сигнатура — DROP/CREATE), `v3.upd_form4_project`, `v3.del_form4_project`, `v3.v_form4_sheet_jsonb` (тело — от версии 0053).
|
||||||
|
- Python: `websocket.py` (`__add_project`), `SheetService.add_project` (проверка доступа по форме без программы), `SheetRepository.add_project`, ORM `form4_project.py`; export_service — новый `Form4DepthRowNormalizer` (подпись «программа»/«проект» в третьем столбце).
|
||||||
|
- Фронт: `RealtimeTable.jsx`, `RealtimeContext.js`, `useRealtimeData.js`, `utils/cellUtils.js`, `utils/rowUtils.js`, `tableColumns.jsx`, `SettingPanel.jsx`, модалки создания/переименования, `SelectExpenseItemModal.jsx`, конфиг колонок FORM_4.
|
||||||
|
- Тесты: интеграционные (лист, итоги, создание/правка/удаление самостоятельного проекта, ответ `_form_affected_rows`), unit фронта (`node --test`), живая проверка на стенде.
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Программы и проекты на листах AHR/CAP/OPER формы 4 (FORM_4, «Проектная деятельность»): как они создаются, переименовываются и удаляются, где стоят в листе, как участвуют в итогах и выгрузке, как подписаны и как к проекту добавляется расход.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Проект создаётся в программе или прямо в разделе
|
||||||
|
Пользователь с правом добавлять строки в раздел листа SHALL иметь возможность создать проект как внутри программы этого раздела, так и прямо в разделе, без программы. Название SHALL быть непустым после обрезки пробелов. Права SHALL проверяться так же, как при создании программы в этом разделе.
|
||||||
|
|
||||||
|
#### Scenario: Проект в разделе
|
||||||
|
- **WHEN** на листе CAP выделена строка раздела (или ничего не выделено) и пользователь создаёт проект «П1»
|
||||||
|
- **THEN** «П1» появляется в разделе CAP без программы у всех подключённых к листу пользователей без перезагрузки
|
||||||
|
- **AND** после перезагрузки страницы «П1» стоит на том же месте
|
||||||
|
|
||||||
|
#### Scenario: Проект в программе — как раньше
|
||||||
|
- **WHEN** выделена программа «Пр» и пользователь создаёт проект
|
||||||
|
- **THEN** проект создаётся внутри «Пр»
|
||||||
|
|
||||||
|
#### Scenario: Выделен проект или расход
|
||||||
|
- **WHEN** выделен проект или строка расхода и пользователь создаёт проект
|
||||||
|
- **THEN** новый проект создаётся там же, где выделенный проект: в той же программе или прямо в разделе
|
||||||
|
|
||||||
|
#### Scenario: Пустое название
|
||||||
|
- **WHEN** пользователь вводит название из одних пробелов
|
||||||
|
- **THEN** проект не создаётся
|
||||||
|
|
||||||
|
### Requirement: Место самостоятельного проекта в листе
|
||||||
|
Самостоятельный проект SHALL выводиться строкой ITEM непосредственно под ROOT своего раздела, без строки GROUP над ним; его расходы — строками INPUT под ним. На уровне под ROOT программы и самостоятельные проекты SHALL идти в порядке создания (по id записи `form4_project`). Проекты внутри программы и их расходы SHALL выводиться как раньше.
|
||||||
|
|
||||||
|
#### Scenario: Вперемешку в порядке создания
|
||||||
|
- **WHEN** в разделе последовательно созданы программа «А», самостоятельный проект «Б», программа «В»
|
||||||
|
- **THEN** под ROOT раздела строки идут в порядке «А» (GROUP), «Б» (ITEM), «В» (GROUP)
|
||||||
|
|
||||||
|
#### Scenario: Пустой раздел
|
||||||
|
- **WHEN** в разделе нет ни одного расхода, но есть самостоятельный проект
|
||||||
|
- **THEN** проект виден в листе так же, как видна пустая программа
|
||||||
|
|
||||||
|
### Requirement: Суммы самостоятельного проекта
|
||||||
|
Суммы расходов самостоятельного проекта SHALL входить в строку ITEM проекта и в ROOT раздела ровно один раз. Лист SHALL NOT содержать строку GROUP без программы (`program_id` пустой). Ответ на правку ячейки расхода самостоятельного проекта SHALL содержать строку расхода, ITEM проекта и ROOT раздела.
|
||||||
|
|
||||||
|
#### Scenario: Итог раздела
|
||||||
|
- **WHEN** в разделе есть программа с расходом 100 и самостоятельный проект с расходом 50 в `plan.q1`
|
||||||
|
- **THEN** ROOT раздела показывает 150 в `plan.q1`, ITEM самостоятельного проекта — 50
|
||||||
|
|
||||||
|
#### Scenario: Правка расхода
|
||||||
|
- **WHEN** пользователь меняет `plan.q1` у расхода самостоятельного проекта
|
||||||
|
- **THEN** ответ содержит этот расход, ITEM проекта и ROOT раздела с пересчитанными суммами и не содержит GROUP
|
||||||
|
|
||||||
|
### Requirement: Расходы самостоятельного проекта
|
||||||
|
К самостоятельному проекту SHALL можно добавлять и удалять расходы так же, как к проекту в программе.
|
||||||
|
|
||||||
|
#### Scenario: Добавление расхода
|
||||||
|
- **WHEN** выделен самостоятельный проект и пользователь добавляет расход по статье
|
||||||
|
- **THEN** расход появляется под этим проектом, суммы проекта и раздела пересчитываются
|
||||||
|
|
||||||
|
### Requirement: Переименование программы и проекта
|
||||||
|
Пользователь с правом изменения SHALL иметь возможность переименовать выделенную программу или проект (в программе и самостоятельный). Окно переименования SHALL открываться с текущим названием. Пустое название и название из одних пробелов SHALL NOT сохраняться. При ошибке сервера окно SHALL оставаться открытым с введённым текстом и показывать понятное сообщение. Отмена SHALL оставлять прежнее название. Переименование SHALL NOT менять состав проектов, расходов и суммы.
|
||||||
|
|
||||||
|
#### Scenario: Успешное переименование
|
||||||
|
- **WHEN** пользователь переименовывает программу «А» в «А2»
|
||||||
|
- **THEN** у всех подключённых пользователей строка показывает «А2», после перезагрузки тоже
|
||||||
|
|
||||||
|
#### Scenario: Ошибка
|
||||||
|
- **WHEN** сервер отказывает в переименовании (например, нет прав)
|
||||||
|
- **THEN** окно остаётся открытым с введённым названием и показывает причину
|
||||||
|
|
||||||
|
### Requirement: Удаление самостоятельного проекта
|
||||||
|
Пустой самостоятельный проект SHALL удаляться с подтверждением, как проект в программе (AURORA-1978). Проект с расходами SHALL NOT удаляться, пользователь видит причину.
|
||||||
|
|
||||||
|
#### Scenario: Удаление пустого
|
||||||
|
- **WHEN** пользователь удаляет пустой самостоятельный проект и подтверждает
|
||||||
|
- **THEN** проект исчезает из листа у всех подключённых, ROOT раздела пересчитывается
|
||||||
|
|
||||||
|
### Requirement: Подпись типа в третьем столбце
|
||||||
|
В третьем столбце листов AHR/CAP/OPER формы 4 строка программы SHALL быть подписана «программа», строка проекта (в программе и самостоятельного) — «проект». Строки ROOT и расходов SHALL показывать код, как раньше. Подпись SHALL совпадать на экране и в выгрузке Excel.
|
||||||
|
|
||||||
|
#### Scenario: Подписи
|
||||||
|
- **WHEN** открыт лист CAP с программой, проектом в ней и самостоятельным проектом
|
||||||
|
- **THEN** напротив программы «программа», напротив обоих проектов «проект», у расходов — код статьи
|
||||||
|
|
||||||
|
### Requirement: Выбор расхода для проекта
|
||||||
|
Окно выбора расхода SHALL показывать варианты в виде «<номер статьи> — <название>», упорядоченные по номеру статьи с учётом числовых частей (например, 2 раньше 10). Поиск SHALL находить вариант по номеру и по названию. Выбор SHALL добавлять расход именно по выбранной статье.
|
||||||
|
|
||||||
|
#### Scenario: Сортировка
|
||||||
|
- **WHEN** среди статей есть номера «3.2.» и «3.10.»
|
||||||
|
- **THEN** «3.2.» стоит выше «3.10.»
|
||||||
|
|
||||||
|
#### Scenario: Поиск по номеру
|
||||||
|
- **WHEN** пользователь вводит «3.01»
|
||||||
|
- **THEN** в списке остаются статьи, номер которых содержит «3.01»
|
||||||
@ -30,7 +30,7 @@ const CreateProgramModalComponent = ({ isOpen, onClose, onCreate, title = '', is
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFormValid = name !== '';
|
const isFormValid = name.trim() !== '';
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
|||||||
61
web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx
Normal file
61
web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { Box, Divider, IconButton, Stack } from '@mui/material';
|
||||||
|
import { memo, useEffect, useState } from 'react';
|
||||||
|
import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons';
|
||||||
|
import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled';
|
||||||
|
import { StyledTextField } from '../../common/StyledTextField';
|
||||||
|
import { Cancel } from '../../common/icons/icons';
|
||||||
|
|
||||||
|
const TITLES = { program: 'Переименование программы', project: 'Переименование проекта' };
|
||||||
|
|
||||||
|
// AURORA-1980: target — результат getRenameTarget; onSubmit получает обрезанное название.
|
||||||
|
// Окно само не закрывается после отправки: закрытие/остаток при ошибке решает родитель.
|
||||||
|
const RenameNodeModalComponent = ({ target, isSaving = false, onClose, onSubmit }) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName(target?.name ?? '');
|
||||||
|
}, [target]);
|
||||||
|
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
const isFormValid = name.trim() !== '';
|
||||||
|
|
||||||
|
const handleBackdropClick = (e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!isFormValid || isSaving) return;
|
||||||
|
onSubmit?.(name.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop isOpen onClick={handleBackdropClick}>
|
||||||
|
<ModalContainer height='max-content'>
|
||||||
|
<HeaderContainer>
|
||||||
|
<Stack direction='row' spacing={2} sx={{ alignItems: 'center' }}>
|
||||||
|
<ModalTitle>{TITLES[target.kind]}</ModalTitle>
|
||||||
|
</Stack>
|
||||||
|
<IconButton size='small' onClick={onClose}>
|
||||||
|
<Cancel />
|
||||||
|
</IconButton>
|
||||||
|
</HeaderContainer>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Divider sx={{ marginBottom: '0.75rem' }} />
|
||||||
|
<StyledTextField label='Название' value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<Stack direction='row' spacing={0.75} sx={{ justifyContent: 'flex-end', marginTop: '1.5rem', width: '100%' }}>
|
||||||
|
<DangerOutlinedButton onClick={onClose} disabled={isSaving}>
|
||||||
|
Отмена
|
||||||
|
</DangerOutlinedButton>
|
||||||
|
<PrimaryButton onClick={handleSave} disabled={!isFormValid || isSaving} loading={isSaving}>
|
||||||
|
Сохранить
|
||||||
|
</PrimaryButton>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</ModalContainer>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RenameNodeModal = memo(RenameNodeModalComponent);
|
||||||
@ -5,6 +5,7 @@ import { ExpenseItemApi } from '../../../api/expense-item';
|
|||||||
import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons';
|
import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons';
|
||||||
import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled';
|
import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled';
|
||||||
import { Cancel } from '../../common/icons/icons';
|
import { Cancel } from '../../common/icons/icons';
|
||||||
|
import { compareExpenseItems, getExpenseItemLabel } from '../utils/expenseItemOptions';
|
||||||
|
|
||||||
const SelectExpenseItemModalComponent = ({ isOpen, onClose, onSelect, formId, title = '', isSaving = false, sheet }) => {
|
const SelectExpenseItemModalComponent = ({ isOpen, onClose, onSelect, formId, title = '', isSaving = false, sheet }) => {
|
||||||
const [selectedValue, setSelectedValue] = useState('');
|
const [selectedValue, setSelectedValue] = useState('');
|
||||||
@ -14,7 +15,7 @@ const SelectExpenseItemModalComponent = ({ isOpen, onClose, onSelect, formId, ti
|
|||||||
if (!isOpen || formId == null) return;
|
if (!isOpen || formId == null) return;
|
||||||
ExpenseItemApi.getAll({ r_start: true, sheet: sheet }).then((data) => {
|
ExpenseItemApi.getAll({ r_start: true, sheet: sheet }).then((data) => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setExpenseItemOption(data.result);
|
setExpenseItemOption([...data.result].sort(compareExpenseItems));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.error('Ошибка получения статья расходов');
|
toast.error('Ошибка получения статья расходов');
|
||||||
@ -69,7 +70,7 @@ const SelectExpenseItemModalComponent = ({ isOpen, onClose, onSelect, formId, ti
|
|||||||
size='small'
|
size='small'
|
||||||
options={expenseItemOptions}
|
options={expenseItemOptions}
|
||||||
noOptionsText='Не найдено'
|
noOptionsText='Не найдено'
|
||||||
getOptionLabel={(option) => option.name || ''}
|
getOptionLabel={getExpenseItemLabel}
|
||||||
getOptionKey={(option) => option.id}
|
getOptionKey={(option) => option.id}
|
||||||
value={selectedValue}
|
value={selectedValue}
|
||||||
onChange={(_event, newValue) => {
|
onChange={(_event, newValue) => {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { CircularProgress } from '@mui/material';
|
|||||||
import { debounce } from '@mui/material';
|
import { debounce } from '@mui/material';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { ConfirmDeleteRowModal } from './Modals/ConfirmDeleteRowModal';
|
import { ConfirmDeleteRowModal } from './Modals/ConfirmDeleteRowModal';
|
||||||
|
import { RenameNodeModal } from './Modals/RenameNodeModal';
|
||||||
import { SheetModals } from './Modals/SheetModals';
|
import { SheetModals } from './Modals/SheetModals';
|
||||||
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
||||||
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
|
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
|
||||||
@ -28,7 +29,7 @@ import { useValidationRules } from './hooks/useValidationRules';
|
|||||||
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
|
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
|
||||||
import { isCellEditable } from './utils/cellEditability';
|
import { isCellEditable } from './utils/cellEditability';
|
||||||
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
||||||
import { canAddRowForHeader, getDeleteTarget } from './utils/rowUtils';
|
import { canAddRowForHeader, getDeleteTarget, getProjectAddTarget, getRenameTarget, isRenameResponse } from './utils/rowUtils';
|
||||||
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
|
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
|
||||||
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
|
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
|
||||||
|
|
||||||
@ -139,6 +140,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
|
|
||||||
// AURORA-1978: { kind: 'row' | 'program' | 'project', id, name } — см. getDeleteTarget.
|
// AURORA-1978: { kind: 'row' | 'program' | 'project', id, name } — см. getDeleteTarget.
|
||||||
const [pendingDelete, setPendingDelete] = useState(null);
|
const [pendingDelete, setPendingDelete] = useState(null);
|
||||||
|
// AURORA-1980: { kind: 'program' | 'project', id, name } — см. getRenameTarget.
|
||||||
|
const [pendingRename, setPendingRename] = useState(null);
|
||||||
|
const [isRenameSaving, setIsRenameSaving] = useState(false);
|
||||||
const modalsRef = useRef(null);
|
const modalsRef = useRef(null);
|
||||||
|
|
||||||
const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
|
const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
|
||||||
@ -174,6 +178,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
deleteRow: contextDeleteRow,
|
deleteRow: contextDeleteRow,
|
||||||
deleteProgram: contextDeleteProgram,
|
deleteProgram: contextDeleteProgram,
|
||||||
deleteProject: contextDeleteProject,
|
deleteProject: contextDeleteProject,
|
||||||
|
renameNode: contextRenameNode,
|
||||||
|
subscribeToNodeRenameResult,
|
||||||
startEditing: contextStartEditing,
|
startEditing: contextStartEditing,
|
||||||
addProgram: contextAddProgram,
|
addProgram: contextAddProgram,
|
||||||
addProject: contextAddProject,
|
addProject: contextAddProject,
|
||||||
@ -931,9 +937,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
|
|
||||||
const handleAddProjectRow = useCallback(
|
const handleAddProjectRow = useCallback(
|
||||||
(name) => {
|
(name) => {
|
||||||
const row = getSelectedRow();
|
contextAddProject({ name: name.trim(), ...getProjectAddTarget(getSelectedRow()?.original) });
|
||||||
if (!row) return;
|
|
||||||
contextAddProject({ name, program_id: row.original.data.header.program_id });
|
|
||||||
},
|
},
|
||||||
[contextAddProject, getSelectedRow],
|
[contextAddProject, getSelectedRow],
|
||||||
);
|
);
|
||||||
@ -988,6 +992,37 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
setRowSelection({});
|
setRowSelection({});
|
||||||
}, [contextDeleteProgram, contextDeleteProject, contextDeleteRow, pendingDelete]);
|
}, [contextDeleteProgram, contextDeleteProject, contextDeleteRow, pendingDelete]);
|
||||||
|
|
||||||
|
// AURORA-1980: ответ на своё переименование — успех закрывает окно, ошибка
|
||||||
|
// оставляет его с введённым текстом (тост показывает общий обработчик).
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribeToNodeRenameResult((msg) => {
|
||||||
|
if (!isRenameResponse(msg, pendingRename)) return;
|
||||||
|
setIsRenameSaving(false);
|
||||||
|
if (!msg.error) setPendingRename(null);
|
||||||
|
}),
|
||||||
|
[pendingRename, subscribeToNodeRenameResult],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canRenameNode = Boolean(getRenameTarget(getSelectedRow()?.original));
|
||||||
|
|
||||||
|
const handleRenameNode = useCallback(() => {
|
||||||
|
setPendingRename(getRenameTarget(getSelectedRow()?.original));
|
||||||
|
}, [getSelectedRow]);
|
||||||
|
|
||||||
|
const closeRenameNode = useCallback(() => {
|
||||||
|
setPendingRename(null);
|
||||||
|
setIsRenameSaving(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitRenameNode = useCallback(
|
||||||
|
(name) => {
|
||||||
|
if (!pendingRename) return;
|
||||||
|
setIsRenameSaving(contextRenameNode(pendingRename.kind, pendingRename.id, name));
|
||||||
|
},
|
||||||
|
[contextRenameNode, pendingRename],
|
||||||
|
);
|
||||||
|
|
||||||
const addRow = useCallback(() => {
|
const addRow = useCallback(() => {
|
||||||
if (!canModifyRows) {
|
if (!canModifyRows) {
|
||||||
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
|
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
|
||||||
@ -1015,17 +1050,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
|
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const row = getSelectedRow();
|
|
||||||
if (!row) {
|
|
||||||
toast.error('Выделите строку для вставки');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (row.original.row_type !== 'GROUP') {
|
|
||||||
toast.error('Выделите строку программы');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
modalsRef.current?.open('project');
|
modalsRef.current?.open('project');
|
||||||
}, [canModifyRows, getSelectedRow]);
|
}, [canModifyRows]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -1097,6 +1123,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
onAddRow={addRow}
|
onAddRow={addRow}
|
||||||
onAddProgram={addProgram}
|
onAddProgram={addProgram}
|
||||||
onAddProject={addProject}
|
onAddProject={addProject}
|
||||||
|
onRenameNode={handleRenameNode}
|
||||||
|
canRenameNode={canRenameNode}
|
||||||
onDeleteRow={handleDeleteRow}
|
onDeleteRow={handleDeleteRow}
|
||||||
formId={formId}
|
formId={formId}
|
||||||
sheetName={sheetName}
|
sheetName={sheetName}
|
||||||
@ -1157,6 +1185,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ConfirmDeleteRowModal target={pendingDelete} onClose={closeConfirmDeleteRow} onConfirm={confirmDeleteRow} />
|
<ConfirmDeleteRowModal target={pendingDelete} onClose={closeConfirmDeleteRow} onConfirm={confirmDeleteRow} />
|
||||||
|
<RenameNodeModal target={pendingRename} isSaving={isRenameSaving} onClose={closeRenameNode} onSubmit={submitRenameNode} />
|
||||||
<SheetModals
|
<SheetModals
|
||||||
ref={modalsRef}
|
ref={modalsRef}
|
||||||
formId={formId}
|
formId={formId}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
AddLine,
|
AddLine,
|
||||||
AllStageColumns,
|
AllStageColumns,
|
||||||
CurrentStageColumns,
|
CurrentStageColumns,
|
||||||
|
EditSvg,
|
||||||
HiddenExpenseItems,
|
HiddenExpenseItems,
|
||||||
Minus,
|
Minus,
|
||||||
Pin,
|
Pin,
|
||||||
@ -88,6 +89,8 @@ const SettingPanel = ({
|
|||||||
onAddRow,
|
onAddRow,
|
||||||
onAddProgram,
|
onAddProgram,
|
||||||
onAddProject,
|
onAddProject,
|
||||||
|
onRenameNode,
|
||||||
|
canRenameNode,
|
||||||
onDeleteRow,
|
onDeleteRow,
|
||||||
formId,
|
formId,
|
||||||
sheetName,
|
sheetName,
|
||||||
@ -229,6 +232,14 @@ const SettingPanel = ({
|
|||||||
<Project />
|
<Project />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title='Переименовать'>
|
||||||
|
<span>
|
||||||
|
<IconButton onClick={onRenameNode} disabled={!canRenameNode} variant='outlined' sx={controlSx}>
|
||||||
|
<EditSvg />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -790,6 +790,7 @@ export const config = {
|
|||||||
{
|
{
|
||||||
header: "",
|
header: "",
|
||||||
accessorKey: "data.header.section_code",
|
accessorKey: "data.header.section_code",
|
||||||
|
valueLabel: "form4NodeType",
|
||||||
columnLetter: "C",
|
columnLetter: "C",
|
||||||
size: 150,
|
size: 150,
|
||||||
filterFn: "contains",
|
filterFn: "contains",
|
||||||
|
|||||||
@ -790,6 +790,7 @@ export const config = {
|
|||||||
{
|
{
|
||||||
header: "",
|
header: "",
|
||||||
accessorKey: "data.header.section_code",
|
accessorKey: "data.header.section_code",
|
||||||
|
valueLabel: "form4NodeType",
|
||||||
columnLetter: "C",
|
columnLetter: "C",
|
||||||
size: 150,
|
size: 150,
|
||||||
filterFn: "contains",
|
filterFn: "contains",
|
||||||
|
|||||||
@ -791,6 +791,7 @@ export const config = {
|
|||||||
{
|
{
|
||||||
header: "",
|
header: "",
|
||||||
accessorKey: "data.header.section_code",
|
accessorKey: "data.header.section_code",
|
||||||
|
valueLabel: "form4NodeType",
|
||||||
columnLetter: "C",
|
columnLetter: "C",
|
||||||
size: 150,
|
size: 150,
|
||||||
filterFn: "contains",
|
filterFn: "contains",
|
||||||
|
|||||||
@ -117,6 +117,11 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
console.log(data.error);
|
console.log(data.error);
|
||||||
toast.error(getWsErrorMessage(data.error));
|
toast.error(getWsErrorMessage(data.error));
|
||||||
onErrorRef.current?.(data.error);
|
onErrorRef.current?.(data.error);
|
||||||
|
// AURORA-1980: окну переименования нужно полное сообщение (event + data),
|
||||||
|
// а не только текст ошибки.
|
||||||
|
if (data.event === 'program_updated' || data.event === 'project_updated') {
|
||||||
|
onNodeRenameResultRef.current?.(data);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,6 +181,15 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// AURORA-1980: переименование программы/проекта формы 4.
|
||||||
|
case 'program_updated':
|
||||||
|
case 'project_updated':
|
||||||
|
if (data.result) {
|
||||||
|
onNodeUpdateRef.current?.(data);
|
||||||
|
onNodeRenameResultRef.current?.(data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('Unknown event:', data.event);
|
console.log('Unknown event:', data.event);
|
||||||
}
|
}
|
||||||
@ -189,6 +203,8 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
const onProgramAddRef = useRef(null);
|
const onProgramAddRef = useRef(null);
|
||||||
const onProjectAddRef = useRef(null);
|
const onProjectAddRef = useRef(null);
|
||||||
const onNodeDeleteRef = useRef(null);
|
const onNodeDeleteRef = useRef(null);
|
||||||
|
const onNodeUpdateRef = useRef(null);
|
||||||
|
const onNodeRenameResultRef = useRef(null);
|
||||||
const onErrorRef = useRef(null);
|
const onErrorRef = useRef(null);
|
||||||
const onAuthSuccessRef = useRef(null);
|
const onAuthSuccessRef = useRef(null);
|
||||||
const onCellEditStartRef = useRef(null);
|
const onCellEditStartRef = useRef(null);
|
||||||
@ -450,6 +466,25 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
[addCommonField, isConnectedRef, sendMessage],
|
[addCommonField, isConnectedRef, sendMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// AURORA-1980: переименование программы/проекта формы 4.
|
||||||
|
const renameNode = useCallback(
|
||||||
|
(kind, id, name) => {
|
||||||
|
if (!isConnectedRef.current) {
|
||||||
|
onErrorRef.current?.('Нет подключения или авторизации');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
event: `${kind}_updated`,
|
||||||
|
data: { [`${kind}_id`]: id, name },
|
||||||
|
};
|
||||||
|
addCommonField(message);
|
||||||
|
|
||||||
|
return sendMessage(message);
|
||||||
|
},
|
||||||
|
[addCommonField, isConnectedRef, sendMessage],
|
||||||
|
);
|
||||||
|
|
||||||
const subscribeToCellUpdates = useCallback((callback) => {
|
const subscribeToCellUpdates = useCallback((callback) => {
|
||||||
onCellUpdateRef.current = callback;
|
onCellUpdateRef.current = callback;
|
||||||
return () => {
|
return () => {
|
||||||
@ -492,6 +527,20 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const subscribeToNodeUpdates = useCallback((callback) => {
|
||||||
|
onNodeUpdateRef.current = callback;
|
||||||
|
return () => {
|
||||||
|
onNodeUpdateRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const subscribeToNodeRenameResult = useCallback((callback) => {
|
||||||
|
onNodeRenameResultRef.current = callback;
|
||||||
|
return () => {
|
||||||
|
onNodeRenameResultRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const subscribeToErrors = useCallback((callback) => {
|
const subscribeToErrors = useCallback((callback) => {
|
||||||
onErrorRef.current = callback;
|
onErrorRef.current = callback;
|
||||||
return () => {
|
return () => {
|
||||||
@ -538,11 +587,14 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
deleteRow,
|
deleteRow,
|
||||||
deleteProgram,
|
deleteProgram,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
|
renameNode,
|
||||||
subscribeToRowAdds,
|
subscribeToRowAdds,
|
||||||
subscribeToRowDeletes,
|
subscribeToRowDeletes,
|
||||||
subscribeToProgramAdds,
|
subscribeToProgramAdds,
|
||||||
subscribeToProjectAdds,
|
subscribeToProjectAdds,
|
||||||
subscribeToNodeDeletes,
|
subscribeToNodeDeletes,
|
||||||
|
subscribeToNodeUpdates,
|
||||||
|
subscribeToNodeRenameResult,
|
||||||
subscribeToErrors,
|
subscribeToErrors,
|
||||||
subscribeToAuthSuccess,
|
subscribeToAuthSuccess,
|
||||||
subscribeToCellEditStart,
|
subscribeToCellEditStart,
|
||||||
@ -560,6 +612,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
deleteProject,
|
deleteProject,
|
||||||
deleteRow,
|
deleteRow,
|
||||||
endEditing,
|
endEditing,
|
||||||
|
renameNode,
|
||||||
lockedCellsStore,
|
lockedCellsStore,
|
||||||
retryLogin,
|
retryLogin,
|
||||||
startEditing,
|
startEditing,
|
||||||
@ -569,6 +622,8 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
subscribeToCellUpdates,
|
subscribeToCellUpdates,
|
||||||
subscribeToErrors,
|
subscribeToErrors,
|
||||||
subscribeToNodeDeletes,
|
subscribeToNodeDeletes,
|
||||||
|
subscribeToNodeRenameResult,
|
||||||
|
subscribeToNodeUpdates,
|
||||||
subscribeToProgramAdds,
|
subscribeToProgramAdds,
|
||||||
subscribeToProjectAdds,
|
subscribeToProjectAdds,
|
||||||
subscribeToRowAdds,
|
subscribeToRowAdds,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
deleteRow,
|
deleteRow,
|
||||||
insertCell,
|
insertCell,
|
||||||
insertProjectRow,
|
insertProjectRow,
|
||||||
|
insertStandaloneProjectRow,
|
||||||
updateCells,
|
updateCells,
|
||||||
} from '../utils/cellUtils';
|
} from '../utils/cellUtils';
|
||||||
import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils';
|
import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils';
|
||||||
@ -32,6 +33,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
|||||||
subscribeToProjectAdds,
|
subscribeToProjectAdds,
|
||||||
subscribeToRowDeletes,
|
subscribeToRowDeletes,
|
||||||
subscribeToNodeDeletes,
|
subscribeToNodeDeletes,
|
||||||
|
subscribeToNodeUpdates,
|
||||||
subscribeToErrors,
|
subscribeToErrors,
|
||||||
subscribeToCellEditStart,
|
subscribeToCellEditStart,
|
||||||
subscribeToCellEditEnd,
|
subscribeToCellEditEnd,
|
||||||
@ -150,9 +152,14 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setData((prevData) => {
|
setData((prevData) => {
|
||||||
const updateData = updateCells(prevData, updatedCells);
|
let base = structuredClone(prevData);
|
||||||
const newData = insertProjectRow(updateData, newRow, program_id);
|
const rootRow = updates.find((row) => row[0] === 'ROOT');
|
||||||
return newData;
|
if (base[0]?.row_type !== 'ROOT' && rootRow) {
|
||||||
|
base = addRootRow(base, rootRow);
|
||||||
|
}
|
||||||
|
const updateData = updateCells(base, alignNodeRows(base, updatedCells));
|
||||||
|
if (program_id != null) return insertProjectRow(updateData, newRow, program_id);
|
||||||
|
return insertStandaloneProjectRow(updateData, newRow);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -207,6 +214,11 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AURORA-1980: переименование — имя в строке и пересчёт приходят всем.
|
||||||
|
const unsubscribeNodeUpdates = subscribeToNodeUpdates((msg) => {
|
||||||
|
setData((prevData) => updateCells(prevData, alignNodeRows(prevData, msg.result[1] ?? [])));
|
||||||
|
});
|
||||||
|
|
||||||
// Обработчик начала редактирования ячейки
|
// Обработчик начала редактирования ячейки
|
||||||
const unsubscribeCellEditStart = subscribeToCellEditStart((data) => {
|
const unsubscribeCellEditStart = subscribeToCellEditStart((data) => {
|
||||||
const dataCell = data.data;
|
const dataCell = data.data;
|
||||||
@ -247,6 +259,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
|||||||
unsubscribeProjectAdds();
|
unsubscribeProjectAdds();
|
||||||
unsubscribeRowDeletes();
|
unsubscribeRowDeletes();
|
||||||
unsubscribeNodeDeletes();
|
unsubscribeNodeDeletes();
|
||||||
|
unsubscribeNodeUpdates();
|
||||||
unsubscribeErrors();
|
unsubscribeErrors();
|
||||||
unsubscribeCellEditStart();
|
unsubscribeCellEditStart();
|
||||||
unsubscribeCellEditEnd();
|
unsubscribeCellEditEnd();
|
||||||
@ -257,6 +270,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
|||||||
subscribeToRowAdds,
|
subscribeToRowAdds,
|
||||||
subscribeToRowDeletes,
|
subscribeToRowDeletes,
|
||||||
subscribeToNodeDeletes,
|
subscribeToNodeDeletes,
|
||||||
|
subscribeToNodeUpdates,
|
||||||
subscribeToErrors,
|
subscribeToErrors,
|
||||||
subscribeToCellEditStart,
|
subscribeToCellEditStart,
|
||||||
subscribeToCellEditEnd,
|
subscribeToCellEditEnd,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { isCellEditable } from './utils/cellEditability';
|
|||||||
import { getColumnValue } from './utils/columnUtils';
|
import { getColumnValue } from './utils/columnUtils';
|
||||||
|
|
||||||
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
|
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
|
||||||
import { getRowId } from './utils/rowUtils';
|
import { getForm4TypeLabel, getRowId } from './utils/rowUtils';
|
||||||
|
|
||||||
const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => {
|
const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => {
|
||||||
const { formId, formType, sheetName, direction } = useParams();
|
const { formId, formType, sheetName, direction } = useParams();
|
||||||
@ -252,7 +252,12 @@ export const getTableColumns = ({
|
|||||||
// Группирующие колонки-заголовки (есть col.columns) не рендерят Cell/Edit
|
// Группирующие колонки-заголовки (есть col.columns) не рендерят Cell/Edit
|
||||||
// по строкам сами — enableEditing им не нужен, в отличие от листовых.
|
// по строкам сами — enableEditing им не нужен, в отличие от листовых.
|
||||||
const isLeafColumn = !col.columns?.length;
|
const isLeafColumn = !col.columns?.length;
|
||||||
if (isLeafColumn && !col.accessorFn && typeof col.accessorKey === 'string' && col.accessorKey.includes('.')) {
|
if (isLeafColumn && !col.accessorFn && col.valueLabel === 'form4NodeType') {
|
||||||
|
// AURORA-1979: подпись «программа»/«проект»; функция назначается здесь,
|
||||||
|
// а не в конфиге — structuredClone конфига не переносит функции.
|
||||||
|
col.id = col.id ?? col.accessorKey;
|
||||||
|
col.accessorFn = (row) => getForm4TypeLabel(row) ?? getColumnValue(row, col.accessorKey);
|
||||||
|
} else if (isLeafColumn && !col.accessorFn && typeof col.accessorKey === 'string' && col.accessorKey.includes('.')) {
|
||||||
// MRT предупреждает об undefined для вложенных путей, отсутствующих
|
// MRT предупреждает об undefined для вложенных путей, отсутствующих
|
||||||
// у отдельных типов строк. Сохраняем исходный id для настроек и прав.
|
// у отдельных типов строк. Сохраняем исходный id для настроек и прав.
|
||||||
col.id = col.id ?? col.accessorKey;
|
col.id = col.id ?? col.accessorKey;
|
||||||
|
|||||||
@ -284,6 +284,30 @@ export function insertProjectRow(data, newRow, program_id) {
|
|||||||
return updatedRows;
|
return updatedRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AURORA-1984: самостоятельный проект (без программы) — под первый ROOT или на
|
||||||
|
// верхний уровень, перед первой строкой списка с большим sort_order (исторические
|
||||||
|
// INPUT идут после проектов). Номера строк с sort_order >= нового сдвигаются.
|
||||||
|
export function insertStandaloneProjectRow(data, newRow) {
|
||||||
|
const updatedRows = structuredClone(data);
|
||||||
|
const [row_type, depth, sort_order, dataRow] = newRow;
|
||||||
|
const newRowObject = { row_type, depth, sort_order, subRows: [], data: dataRow };
|
||||||
|
|
||||||
|
const shift = (rows) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.sort_order >= sort_order) row.sort_order += 1;
|
||||||
|
if (Array.isArray(row.subRows)) shift(row.subRows);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
shift(updatedRows);
|
||||||
|
|
||||||
|
const root = updatedRows.find((row) => row.row_type === 'ROOT');
|
||||||
|
if (root && !Array.isArray(root.subRows)) root.subRows = [];
|
||||||
|
const target = root ? root.subRows : updatedRows;
|
||||||
|
const index = target.findIndex((row) => row.sort_order > sort_order);
|
||||||
|
target.splice(index === -1 ? target.length : index, 0, newRowObject);
|
||||||
|
return updatedRows;
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteRow(data, lineId) {
|
export function deleteRow(data, lineId) {
|
||||||
if (lineId == null) return data;
|
if (lineId == null) return data;
|
||||||
return deleteRowWhere(data, (row) => getDataRowId(row.data) === lineId);
|
return deleteRowWhere(data, (row) => getDataRowId(row.data) === lineId);
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { alignNodeRows, deleteProgramRow, deleteProjectRow, deleteRow, updateCells } from './cellUtils.js';
|
import {
|
||||||
|
addRootRow,
|
||||||
|
alignNodeRows,
|
||||||
|
deleteProgramRow,
|
||||||
|
deleteProjectRow,
|
||||||
|
deleteRow,
|
||||||
|
insertCell,
|
||||||
|
insertStandaloneProjectRow,
|
||||||
|
updateCells,
|
||||||
|
} from './cellUtils.js';
|
||||||
|
|
||||||
// Эталон: прежняя реализация updateCells (до AURORA-1838).
|
// Эталон: прежняя реализация updateCells (до AURORA-1838).
|
||||||
function oldUpdateCells(data, newRows) {
|
function oldUpdateCells(data, newRows) {
|
||||||
@ -385,3 +394,56 @@ test('AURORA-1978: alignNodeRows — ROOT по section_code, строки без
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const node = (rowType, sortOrder, header = {}, subRows = []) => ({
|
||||||
|
row_type: rowType,
|
||||||
|
depth: { ROOT: 0, GROUP: 1, ITEM: 2, INPUT: 3 }[rowType],
|
||||||
|
sort_order: sortOrder,
|
||||||
|
subRows,
|
||||||
|
data: { header },
|
||||||
|
});
|
||||||
|
const newItem = (sortOrder) => ['ITEM', 2, sortOrder, { header: { project_id: 77, name: 'Н' } }];
|
||||||
|
|
||||||
|
test('AURORA-1984: самостоятельный проект — под ROOT, после соседей', () => {
|
||||||
|
const result = insertStandaloneProjectRow([node('ROOT', 1, {}, [node('GROUP', 2)])], newItem(3));
|
||||||
|
assert.deepEqual(
|
||||||
|
result[0].subRows.map((r) => [r.row_type, r.sort_order]),
|
||||||
|
[
|
||||||
|
['GROUP', 2],
|
||||||
|
['ITEM', 3],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1984: самостоятельный проект — на верхний уровень без ROOT', () => {
|
||||||
|
const result = insertStandaloneProjectRow([node('GROUP', 1)], newItem(2));
|
||||||
|
assert.deepEqual(
|
||||||
|
result.map((r) => r.row_type),
|
||||||
|
['GROUP', 'ITEM'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1984: самостоятельный проект — перед историческим расходом, номера сдвигаются', () => {
|
||||||
|
const result = insertStandaloneProjectRow([node('ROOT', 1, {}, [node('GROUP', 2), node('INPUT', 3)])], newItem(3));
|
||||||
|
assert.deepEqual(
|
||||||
|
result[0].subRows.map((r) => [r.row_type, r.sort_order]),
|
||||||
|
[
|
||||||
|
['GROUP', 2],
|
||||||
|
['ITEM', 3],
|
||||||
|
['INPUT', 4],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1984: insertCell после addRootRow — первый расход самостоятельного проекта', () => {
|
||||||
|
const withRoot = addRootRow([node('ITEM', 1, { project_id: 21 })], ['ROOT', 0, 1, { header: { expense_item_id: 900 } }]);
|
||||||
|
const result = insertCell(withRoot, ['INPUT', 3, 3, { line_id: 5, header: { project_id: 21 } }], 21);
|
||||||
|
assert.equal(result[0].subRows[0].subRows[0].data.line_id, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1980: ответ переименования обновляет название в строке', () => {
|
||||||
|
const local = [node('ROOT', 1, { section_code: 'CAP' }, [node('ITEM', 2, { project_id: 21, name: 'Старое' })])];
|
||||||
|
const rows = [['ITEM', 2, 5, { header: { project_id: 21, name: 'Новое' } }]];
|
||||||
|
const result = updateCells(local, alignNodeRows(local, rows));
|
||||||
|
assert.equal(result[0].subRows[0].data.header.name, 'Новое');
|
||||||
|
});
|
||||||
|
|||||||
22
web/src/components/RealtimeTable/utils/expenseItemOptions.js
Normal file
22
web/src/components/RealtimeTable/utils/expenseItemOptions.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
const getItemCode = (option) => {
|
||||||
|
const code = option?.item_id;
|
||||||
|
if (code == null) return '';
|
||||||
|
return String(code).trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExpenseItemLabel = (option) => {
|
||||||
|
if (!option || typeof option !== 'object') return '';
|
||||||
|
const code = getItemCode(option);
|
||||||
|
const name = option.name || '';
|
||||||
|
if (code && name) return `${code} — ${name}`;
|
||||||
|
return code || name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compareExpenseItems = (a, b) => {
|
||||||
|
const codeA = getItemCode(a);
|
||||||
|
const codeB = getItemCode(b);
|
||||||
|
if (!codeA && !codeB) return 0;
|
||||||
|
if (!codeA) return 1;
|
||||||
|
if (!codeB) return -1;
|
||||||
|
return codeA.localeCompare(codeB, 'ru', { numeric: true });
|
||||||
|
};
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { compareExpenseItems, getExpenseItemLabel } from './expenseItemOptions.js';
|
||||||
|
|
||||||
|
const sorted = (ids) =>
|
||||||
|
ids
|
||||||
|
.map((item_id) => ({ item_id, name: 'x' }))
|
||||||
|
.sort(compareExpenseItems)
|
||||||
|
.map((o) => o.item_id);
|
||||||
|
|
||||||
|
test('AURORA-1989: подпись варианта', () => {
|
||||||
|
assert.equal(getExpenseItemLabel({ item_id: '3.01.', name: 'Связь' }), '3.01. — Связь');
|
||||||
|
assert.equal(getExpenseItemLabel({ item_id: null, name: 'Связь' }), 'Связь');
|
||||||
|
assert.equal(getExpenseItemLabel({ name: '' }), '');
|
||||||
|
assert.equal(getExpenseItemLabel(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1989: сортировка с учётом числовых частей, без кода — в конце', () => {
|
||||||
|
assert.deepEqual(sorted(['3.10.', '3.2.', '10', '2']), ['2', '3.2.', '3.10.', '10']);
|
||||||
|
assert.deepEqual(sorted([null, '1.', '']), ['1.', null, '']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1989: поиск по подписи находит номер и название', () => {
|
||||||
|
const label = getExpenseItemLabel({ item_id: '3.01.', name: 'Связь' });
|
||||||
|
assert.ok(label.includes('3.01'));
|
||||||
|
assert.ok(label.includes('Связь'));
|
||||||
|
});
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
// Конфиги читаются как текст (импорт без расширений не грузится в node).
|
||||||
|
for (const sheet of ['AHR', 'CAP', 'OPER']) {
|
||||||
|
test(`AURORA-1979: FORM_4/${sheet} — признак подписи без функций в конфиге`, () => {
|
||||||
|
const source = readFileSync(fileURLToPath(new URL(`../constants/FORM_4/${sheet}.js`, import.meta.url)), 'utf8');
|
||||||
|
assert.match(source, /accessorKey: "data\.header\.section_code",\s*valueLabel: "form4NodeType"/);
|
||||||
|
assert.doesNotMatch(source, /accessorFn/);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -6,24 +6,38 @@ const ROW_TYPE_LEVELS = {
|
|||||||
INPUT: 4,
|
INPUT: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AURORA-1984 (миграция 0059, design D3): самостоятельный проект формы 4 — ITEM с
|
||||||
|
// header.project_id и без header.program_id. По row_type он глубже GROUP, но по
|
||||||
|
// смыслу — ровня программе (прямой потомок ROOT), где бы он ни стоял в порядке листа.
|
||||||
|
// Строки других форм/листов project_id в header не имеют, их это не затрагивает.
|
||||||
|
export function isStandaloneProject(item) {
|
||||||
|
const header = item?.data?.header;
|
||||||
|
return item?.row_type === 'ITEM' && header?.project_id != null && header?.program_id == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHierarchyLevel(item) {
|
||||||
|
if (isStandaloneProject(item)) return ROW_TYPE_LEVELS.GROUP;
|
||||||
|
return ROW_TYPE_LEVELS[item.row_type] ?? 999;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildRowHierarchy(items, mapItem = (item) => item) {
|
export function buildRowHierarchy(items, mapItem = (item) => item) {
|
||||||
const result = [];
|
const result = [];
|
||||||
const stack = [];
|
const stack = [];
|
||||||
|
|
||||||
for (const [index, item] of items.entries()) {
|
for (const [index, item] of items.entries()) {
|
||||||
const currentLevel = ROW_TYPE_LEVELS[item.row_type] ?? 999;
|
const currentLevel = getHierarchyLevel(item);
|
||||||
|
|
||||||
while (stack.length > 0 && (ROW_TYPE_LEVELS[stack.at(-1).row_type] ?? 999) >= currentLevel) {
|
while (stack.length > 0 && stack.at(-1).level >= currentLevel) {
|
||||||
stack.pop();
|
stack.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parent = stack.at(-1);
|
const parent = stack.at(-1)?.node;
|
||||||
const newNode = { ...mapItem(item, index), subRows: [] };
|
const newNode = { ...mapItem(item, index), subRows: [] };
|
||||||
|
|
||||||
if (parent) parent.subRows.push(newNode);
|
if (parent) parent.subRows.push(newNode);
|
||||||
else result.push(newNode);
|
else result.push(newNode);
|
||||||
|
|
||||||
if (item.row_type !== 'INPUT') stack.push(newNode);
|
if (item.row_type !== 'INPUT') stack.push({ node: newNode, level: currentLevel });
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@ -69,6 +83,15 @@ export function getDeleteTarget(rowData, { isForm4 = false } = {}) {
|
|||||||
return id == null ? null : { kind: 'row', id };
|
return id == null ? null : { kind: 'row', id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AURORA-1984: цель добавления проекта по выделенной строке. Программа задана
|
||||||
|
// только если у GROUP/ITEM/INPUT header.program_id != null; иначе проект
|
||||||
|
// самостоятельный. rowData — row.original.
|
||||||
|
export function getProjectAddTarget(rowData) {
|
||||||
|
const programId = rowData?.data?.header?.program_id;
|
||||||
|
const typed = ['GROUP', 'ITEM', 'INPUT'].includes(rowData?.row_type);
|
||||||
|
return typed && programId != null ? { program_id: programId } : {};
|
||||||
|
}
|
||||||
|
|
||||||
export function isVspRow(row) {
|
export function isVspRow(row) {
|
||||||
if (row?.original?.data?.id) {
|
if (row?.original?.data?.id) {
|
||||||
return true;
|
return true;
|
||||||
@ -94,3 +117,30 @@ export function getParentId(data) {
|
|||||||
export function canAddRowForHeader(header) {
|
export function canAddRowForHeader(header) {
|
||||||
return header?.can_add_row !== false;
|
return header?.can_add_row !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AURORA-1980: что переименовывает кнопка панели — программу (GROUP) или проект (ITEM) формы 4.
|
||||||
|
export function getRenameTarget(rowData) {
|
||||||
|
const header = rowData?.data?.header;
|
||||||
|
if (rowData?.row_type === 'GROUP' && header?.program_id != null) {
|
||||||
|
return { kind: 'program', id: header.program_id, name: header.name ?? '' };
|
||||||
|
}
|
||||||
|
if (rowData?.row_type === 'ITEM' && header?.project_id != null) {
|
||||||
|
return { kind: 'project', id: header.project_id, name: header.name ?? '' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AURORA-1980: WS без идентификатора запроса — свой ответ на своё переименование
|
||||||
|
// узнаём по is_self, событию и id узла (чужое переименование окно не закрывает).
|
||||||
|
export function isRenameResponse(msg, target) {
|
||||||
|
if (!msg || !target || msg.is_self !== true) return false;
|
||||||
|
return msg.event === `${target.kind}_updated` && msg.data?.[`${target.kind}_id`] === target.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AURORA-1979: подпись типа узла в колонке «Код статьи / подстатьи / позиции» формы 4.
|
||||||
|
export function getForm4TypeLabel(row) {
|
||||||
|
const header = row?.data?.header;
|
||||||
|
if (row?.row_type === 'GROUP' && header?.program_id != null) return 'программа';
|
||||||
|
if (row?.row_type === 'ITEM' && header?.project_id != null) return 'проект';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,16 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { buildRowHierarchy, canAddRowForHeader, getDataRowId, getDeleteTarget } from './rowUtils.js';
|
import {
|
||||||
|
buildRowHierarchy,
|
||||||
|
canAddRowForHeader,
|
||||||
|
getDataRowId,
|
||||||
|
getDeleteTarget,
|
||||||
|
getForm4TypeLabel,
|
||||||
|
getProjectAddTarget,
|
||||||
|
getRenameTarget,
|
||||||
|
isRenameResponse,
|
||||||
|
isStandaloneProject,
|
||||||
|
} from './rowUtils.js';
|
||||||
|
|
||||||
test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => {
|
test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
@ -87,3 +97,77 @@ test('AURORA-1978: getDeleteTarget — строка бюджета и прочи
|
|||||||
assert.equal(getDeleteTarget(form4Row('ROOT', { section_code: 'S' }), { isForm4: true }), null);
|
assert.equal(getDeleteTarget(form4Row('ROOT', { section_code: 'S' }), { isForm4: true }), null);
|
||||||
assert.equal(getDeleteTarget(null, { isForm4: true }), null);
|
assert.equal(getDeleteTarget(null, { isForm4: true }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AURORA-1984: getProjectAddTarget — цель по выделенной строке', () => {
|
||||||
|
assert.deepEqual(getProjectAddTarget(undefined), {});
|
||||||
|
assert.deepEqual(getProjectAddTarget(form4Row('ROOT', { section_code: 'CAP' })), {});
|
||||||
|
assert.deepEqual(getProjectAddTarget(form4Row('GROUP', { program_id: 11 })), { program_id: 11 });
|
||||||
|
assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { program_id: 11, project_id: 21 })), { program_id: 11 });
|
||||||
|
assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { program_id: null, project_id: 21 })), {});
|
||||||
|
assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { project_id: 21 })), {});
|
||||||
|
assert.deepEqual(getProjectAddTarget(form4Row('INPUT', { program_id: 11, project_id: 21 })), { program_id: 11 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1980: getRenameTarget — программа, проект, остальное', () => {
|
||||||
|
assert.deepEqual(getRenameTarget(form4Row('GROUP', { program_id: 11, name: 'А' })), { kind: 'program', id: 11, name: 'А' });
|
||||||
|
assert.deepEqual(getRenameTarget(form4Row('ITEM', { project_id: 21, name: 'Б' })), { kind: 'project', id: 21, name: 'Б' });
|
||||||
|
assert.equal(getRenameTarget(form4Row('ROOT', { section_code: 'CAP' })), null);
|
||||||
|
assert.equal(getRenameTarget(form4Row('INPUT', { project_id: 21 })), null);
|
||||||
|
assert.equal(getRenameTarget(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1980: isRenameResponse — только свой ответ на свой узел', () => {
|
||||||
|
const target = { kind: 'project', id: 21, name: 'Б' };
|
||||||
|
const msg = { is_self: true, event: 'project_updated', data: { project_id: 21 } };
|
||||||
|
assert.equal(isRenameResponse(msg, target), true);
|
||||||
|
assert.equal(isRenameResponse({ ...msg, error: 'нет прав' }, target), true);
|
||||||
|
assert.equal(isRenameResponse({ ...msg, is_self: false }, target), false);
|
||||||
|
assert.equal(isRenameResponse({ ...msg, data: { project_id: 22 } }, target), false);
|
||||||
|
assert.equal(isRenameResponse({ ...msg, event: 'program_updated', data: { program_id: 21 } }, target), false);
|
||||||
|
assert.equal(isRenameResponse(msg, null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1979: getForm4TypeLabel', () => {
|
||||||
|
assert.equal(getForm4TypeLabel(form4Row('GROUP', { program_id: 11 })), 'программа');
|
||||||
|
assert.equal(getForm4TypeLabel(form4Row('ITEM', { project_id: 21 })), 'проект');
|
||||||
|
assert.equal(getForm4TypeLabel(form4Row('ITEM', { program_id: 11, project_id: 21 })), 'проект');
|
||||||
|
assert.equal(getForm4TypeLabel(form4Row('ROOT', { section_code: 'CAP' })), null);
|
||||||
|
assert.equal(getForm4TypeLabel(form4Row('INPUT', { program_id: 11, project_id: 21 })), null);
|
||||||
|
assert.equal(getForm4TypeLabel(form4Row('GROUP', { section_code: '1.' })), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const node = (row_type, header = {}, extra = {}) => ({ row_type, data: { header, ...extra } });
|
||||||
|
const names = (rows) => rows.map((r) => [r.data.header.name, names(r.subRows)]);
|
||||||
|
const rows = [
|
||||||
|
node('ROOT', { name: 'root' }),
|
||||||
|
node('GROUP', { name: 'prog1', program_id: 1 }),
|
||||||
|
node('ITEM', { name: 'in-prog', project_id: 10, program_id: 1 }),
|
||||||
|
node('INPUT', { name: 'e1', project_id: 10, program_id: 1 }, { line_id: 1 }),
|
||||||
|
node('ITEM', { name: 'solo', project_id: 11 }),
|
||||||
|
node('INPUT', { name: 'e2', project_id: 11 }, { line_id: 2 }),
|
||||||
|
node('GROUP', { name: 'prog2', program_id: 2 }),
|
||||||
|
node('ITEM', { name: 'solo2', project_id: 12, program_id: null }),
|
||||||
|
node('INPUT', { name: 'e3', project_id: 12 }, { line_id: 3 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
test('I-1: standalone ITEM после программы — потомок ROOT, расходы — его дети', () => {
|
||||||
|
const tree = buildRowHierarchy(rows);
|
||||||
|
assert.deepEqual(names(tree), [
|
||||||
|
[
|
||||||
|
'root',
|
||||||
|
[
|
||||||
|
['prog1', [['in-prog', [['e1', []]]]]],
|
||||||
|
['solo', [['e2', []]]],
|
||||||
|
['prog2', []],
|
||||||
|
['solo2', [['e3', []]]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('I-1: isStandaloneProject различает узлы', () => {
|
||||||
|
assert.equal(isStandaloneProject(rows[4]), true);
|
||||||
|
assert.equal(isStandaloneProject(rows[2]), false);
|
||||||
|
assert.equal(isStandaloneProject(rows[1]), false);
|
||||||
|
assert.equal(isStandaloneProject(rows[5]), false);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user