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

Merged
aep merged 13 commits from feat/305-aurora-1984-pd-standalone-projects into test 2026-11-11 18:12:24 +03:00
36 changed files with 2713 additions and 59 deletions

View 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

View 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$
;

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"]

View File

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

View File

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

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

View File

@ -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`. Исправления кода — коммитами с тестом.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-08

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

View File

@ -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`), живая проверка на стенде.

View File

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

View File

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

View 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);

View File

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

View File

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

View File

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

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

@ -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, 'Новое');
});

View 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 });
};

View File

@ -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('Связь'));
});

View File

@ -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/);
});
}

View File

@ -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;
}

View File

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