AURORA-1846: FORM_4 суммы листа без рекурсивного jsonb_sum #232
15
api/alembic/versions/0053_form4_sheet_sum.py
Normal file
15
api/alembic/versions/0053_form4_sheet_sum.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from sql_migration import run_sql_file_migration
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0053"
|
||||||
|
down_revision = "0052"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
run_sql_file_migration("0053_form4_sheet_sum.sql")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
603
api/alembic/versions/sql/0053_form4_sheet_sum.sql
Normal file
603
api/alembic/versions/sql/0053_form4_sheet_sum.sql
Normal file
@ -0,0 +1,603 @@
|
|||||||
|
-- AURORA-1846: FORM_4, замена рекурсивного агрегата v3.jsonb_sum в
|
||||||
|
-- v3.v_form4_sheet_jsonb на разворот листьев jsonb + GROUPING SETS.
|
||||||
|
-- Тело — копия sql/0012_del_project.sql:1091-1617, изменён только блок
|
||||||
|
-- «4. Агрегаты». v3.jsonb_sum / v3.jsonb_numeric_sum не удаляются (эталон в тестах).
|
||||||
|
|
||||||
|
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)
|
||||||
|
),
|
||||||
|
|
||||||
|
-- ═══ 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 = 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, prog_id, prj_id, 0]::INT[]
|
||||||
|
FROM prj_agg
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- ITEM — пустые проекты (нет budget_line)
|
||||||
|
SELECT
|
||||||
|
'ITEM', 2, NULL,
|
||||||
|
jsonb_build_object('section_code', sec_code, 'name', prj_name,
|
||||||
|
'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id),
|
||||||
|
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, prj_id, 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, 999999), COALESCE(CASE WHEN prj_id is null and prog_id is not null THEN 0 ELSE prj_id END, 999999), line_id]::INT[]
|
||||||
|
FROM inp
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
u.row_type, u.depth,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY u.sort_path) AS sort_order,
|
||||||
|
jsonb_build_object(
|
||||||
|
'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$
|
||||||
|
;
|
||||||
@ -13,8 +13,15 @@ from src.main import app
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def db_session():
|
||||||
|
"""Сессия БД теста: её же использует client, всё откатывается в конце теста."""
|
||||||
db_ref = SessionLocal()
|
db_ref = SessionLocal()
|
||||||
|
yield db_ref
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(db_session):
|
||||||
|
db_ref = db_session
|
||||||
loop_ref = None
|
loop_ref = None
|
||||||
|
|
||||||
async def _get_db():
|
async def _get_db():
|
||||||
|
|||||||
355
api/tests/integration/test_form4_sheet_sums.py
Normal file
355
api/tests/integration/test_form4_sheet_sums.py
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
"""AURORA-1846: суммы ROOT/GROUP/ITEM на листе FORM_4/CAP.
|
||||||
|
|
||||||
|
Два эталона:
|
||||||
|
1. старый рекурсивный агрегат v3.jsonb_sum поверх INPUT-строк (точное совпадение,
|
||||||
|
включая масштаб чисел и правило «текст сохраняется при одном вхождении»);
|
||||||
|
2. независимая сумма числовых листьев на Python.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from decimal import Decimal
|
||||||
|
from itertools import count
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
SHEET = "CAP"
|
||||||
|
CAP_ITEM = 525 # статья CAP depth=2
|
||||||
|
BLOCKS = [
|
||||||
|
"plan", "seq_dfip", "approved", "contract_summary", "allocation", "reserve",
|
||||||
|
"collegial", "ckk", "contract", "booking", "q1", "q2", "q3", "q4", "totals",
|
||||||
|
]
|
||||||
|
_names = count(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _loads(s):
|
||||||
|
return json.loads(s, parse_float=Decimal)
|
||||||
|
|
||||||
|
|
||||||
|
def canon(x):
|
||||||
|
"""Decimal -> str (с масштабом), чтобы 3.50 и 3.5 считались разными."""
|
||||||
|
if isinstance(x, Decimal):
|
||||||
|
return str(x)
|
||||||
|
if isinstance(x, dict):
|
||||||
|
return {k: canon(v) for k, v in x.items()}
|
||||||
|
if isinstance(x, list):
|
||||||
|
return [canon(v) for v in x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
|
||||||
|
def canon_num(x):
|
||||||
|
"""Числа -> нормализованный Decimal: API отдаёт float, масштаб теряется."""
|
||||||
|
if isinstance(x, bool):
|
||||||
|
return x
|
||||||
|
if isinstance(x, (int, float, Decimal)):
|
||||||
|
return Decimal(str(x)).normalize()
|
||||||
|
if isinstance(x, dict):
|
||||||
|
return {k: canon_num(v) for k, v in x.items()}
|
||||||
|
if isinstance(x, list):
|
||||||
|
return [canon_num(v) for v in x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
|
||||||
|
def leaves(block):
|
||||||
|
"""(путь, значение) для объекта глубиной до 2."""
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
return
|
||||||
|
for k1, v1 in block.items():
|
||||||
|
if isinstance(v1, dict):
|
||||||
|
for k2, v2 in v1.items():
|
||||||
|
yield (k1, k2), v2
|
||||||
|
else:
|
||||||
|
yield (k1,), v1
|
||||||
|
|
||||||
|
|
||||||
|
def get_path(block, path):
|
||||||
|
for k in path:
|
||||||
|
if not isinstance(block, dict) or k not in block:
|
||||||
|
return None
|
||||||
|
block = block[k]
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
class F4:
|
||||||
|
def __init__(self, client, db_session, headers):
|
||||||
|
self.client, self.db, self.h = client, db_session, headers
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/form/",
|
||||||
|
json={"form_type_code": "FORM_4", "year": 2026, "org_unit_ids": [2]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
self.form_id = resp.json()["result"][0]["id"]
|
||||||
|
|
||||||
|
def sql(self, query, **params):
|
||||||
|
async def _go():
|
||||||
|
r = await self.db.execute(text(query), params)
|
||||||
|
return r.fetchall() if r.returns_rows else []
|
||||||
|
|
||||||
|
return self.client.portal.call(_go)
|
||||||
|
|
||||||
|
def program(self, name=None):
|
||||||
|
name = name or f"prog{next(_names)}"
|
||||||
|
return self.sql(
|
||||||
|
"SELECT project_id FROM v3.add_form4_program(:n, :f, 'CAP') LIMIT 1",
|
||||||
|
n=name, f=self.form_id,
|
||||||
|
)[0][0]
|
||||||
|
|
||||||
|
def project(self, program_id, name=None):
|
||||||
|
name = name or f"prj{next(_names)}"
|
||||||
|
return self.sql(
|
||||||
|
"SELECT project_id FROM v3.add_form4_project(:n, :p) LIMIT 1",
|
||||||
|
n=name, p=program_id,
|
||||||
|
)[0][0]
|
||||||
|
|
||||||
|
def line(self, project_id):
|
||||||
|
before = {r[0] for r in self.sql("SELECT id FROM v3.budget_line WHERE budget_form_id=:f", f=self.form_id)}
|
||||||
|
resp = self.client.post(
|
||||||
|
f"/api/v1/form/{self.form_id}/sheet/{SHEET}/line",
|
||||||
|
json={"expense_item_id": CAP_ITEM, "project_id": project_id, "name": f"line{next(_names)}"},
|
||||||
|
headers=self.h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
new = {r[0] for r in self.sql("SELECT id FROM v3.budget_line WHERE budget_form_id=:f", f=self.form_id)} - before
|
||||||
|
assert len(new) == 1
|
||||||
|
return new.pop()
|
||||||
|
|
||||||
|
def cell(self, line_id, column, value):
|
||||||
|
self.sql(
|
||||||
|
"SELECT v3._apply_form_cell(:l, :c, CAST(:v AS jsonb), 'CAP', :f)",
|
||||||
|
l=line_id, c=column, v=json.dumps(value), f=self.form_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def cells(self, line_id, values):
|
||||||
|
for col, val in values.items():
|
||||||
|
self.cell(line_id, col, val)
|
||||||
|
|
||||||
|
def sheet(self, sections=None):
|
||||||
|
params = {"sections": ",".join(sections)} if sections else {}
|
||||||
|
resp = self.client.get(f"/api/v1/form/{self.form_id}/sheet/{SHEET}", params=params, headers=self.h)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return _loads(resp.text)["result"]
|
||||||
|
|
||||||
|
def reference(self, sections=None):
|
||||||
|
"""Эталон 1: старый v3.jsonb_sum по INPUT-строкам, по уровням."""
|
||||||
|
sums = ", ".join(f"'{b}', v3.jsonb_sum(data->'{b}')" for b in BLOCKS)
|
||||||
|
q = f"""
|
||||||
|
SELECT lvl, gid, jsonb_build_object({sums})::text
|
||||||
|
FROM (
|
||||||
|
SELECT 'P' AS lvl, (data->'header'->>'program_id')::int AS gid, data
|
||||||
|
FROM v3.v_form4_sheet_jsonb(:f, 'CAP', CAST(:s AS text[])) WHERE row_type = 'INPUT'
|
||||||
|
AND data->'header'->>'program_id' IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'J', (data->'header'->>'project_id')::int, data
|
||||||
|
FROM v3.v_form4_sheet_jsonb(:f, 'CAP', CAST(:s AS text[])) WHERE row_type = 'INPUT'
|
||||||
|
AND data->'header'->>'project_id' IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'R', 0, data
|
||||||
|
FROM v3.v_form4_sheet_jsonb(:f, 'CAP', CAST(:s AS text[])) WHERE row_type = 'INPUT'
|
||||||
|
) x
|
||||||
|
GROUP BY lvl, gid
|
||||||
|
"""
|
||||||
|
rows = self.sql(q, f=self.form_id, s=sections)
|
||||||
|
return {(lvl, gid): _loads(js) for lvl, gid, js in rows}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def f4(client, db_session, admin_tokens, auth_headers):
|
||||||
|
return F4(client, db_session, auth_headers(admin_tokens))
|
||||||
|
|
||||||
|
|
||||||
|
def _split(rows):
|
||||||
|
root = [r for r in rows if r["row_type"] == "ROOT"]
|
||||||
|
assert len(root) == 1
|
||||||
|
groups = {r["data"]["header"]["program_id"]: r["data"] for r in rows if r["row_type"] == "GROUP"}
|
||||||
|
items = {r["data"]["header"]["project_id"]: r["data"] for r in rows if r["row_type"] == "ITEM"}
|
||||||
|
inputs = [r["data"] for r in rows if r["row_type"] == "INPUT"]
|
||||||
|
return root[0]["data"], groups, items, inputs
|
||||||
|
|
||||||
|
|
||||||
|
def check_sums(f4, sections=None):
|
||||||
|
"""Сверка листа с обоими эталонами. Возвращает (root, groups, items, inputs)."""
|
||||||
|
rows = f4.sheet(sections)
|
||||||
|
root, groups, items, inputs = _split(rows)
|
||||||
|
ref = f4.reference(sections)
|
||||||
|
|
||||||
|
# Эталон 1a: ответ API совпадает с v3.jsonb_sum (числа сравниваются по значению)
|
||||||
|
assert canon_num({b: root[b] for b in BLOCKS}) == canon_num(ref[("R", 0)])
|
||||||
|
for pid, data in groups.items():
|
||||||
|
if ("P", pid) in ref:
|
||||||
|
assert canon_num({b: data[b] for b in BLOCKS}) == canon_num(ref[("P", pid)]), f"program {pid}"
|
||||||
|
for jid, data in items.items():
|
||||||
|
if ("J", jid) in ref:
|
||||||
|
assert canon_num({b: data[b] for b in BLOCKS}) == canon_num(ref[("J", jid)]), f"project {jid}"
|
||||||
|
|
||||||
|
# Эталон 1b: прямой вызов функции совпадает с v3.jsonb_sum точно, включая масштаб чисел
|
||||||
|
direct = f4.sql(
|
||||||
|
"SELECT row_type, data::text FROM v3.v_form4_sheet_jsonb(:f, 'CAP', CAST(:s AS text[]))",
|
||||||
|
f=f4.form_id, s=sections,
|
||||||
|
)
|
||||||
|
d_root, d_groups, d_items, _ = _split([{"row_type": t, "data": _loads(js)} for t, js in direct])
|
||||||
|
assert canon({b: d_root[b] for b in BLOCKS}) == canon(ref[("R", 0)])
|
||||||
|
for pid, data in d_groups.items():
|
||||||
|
if ("P", pid) in ref:
|
||||||
|
assert canon({b: data[b] for b in BLOCKS}) == canon(ref[("P", pid)]), f"program {pid}"
|
||||||
|
for jid, data in d_items.items():
|
||||||
|
if ("J", jid) in ref:
|
||||||
|
assert canon({b: data[b] for b in BLOCKS}) == canon(ref[("J", jid)]), f"project {jid}"
|
||||||
|
|
||||||
|
# Эталон 2: сумма числовых листьев на Python
|
||||||
|
def expect(level_inputs, agg):
|
||||||
|
for b in BLOCKS:
|
||||||
|
paths = {p for inp in level_inputs for p, _ in leaves(inp[b])}
|
||||||
|
for path in paths:
|
||||||
|
nums = [
|
||||||
|
Decimal(str(v))
|
||||||
|
for inp in level_inputs
|
||||||
|
for p, v in leaves(inp[b])
|
||||||
|
if p == path and isinstance(v, (int, Decimal)) and not isinstance(v, bool)
|
||||||
|
]
|
||||||
|
if nums:
|
||||||
|
assert Decimal(str(get_path(agg[b], path))) == sum(nums), (b, path)
|
||||||
|
|
||||||
|
expect(inputs, root)
|
||||||
|
for pid, data in groups.items():
|
||||||
|
lvl_inputs = [i for i in inputs if i["header"]["program_id"] == pid]
|
||||||
|
if lvl_inputs:
|
||||||
|
expect(lvl_inputs, data)
|
||||||
|
for jid, data in items.items():
|
||||||
|
lvl_inputs = [i for i in inputs if i["header"]["project_id"] == jid]
|
||||||
|
if lvl_inputs:
|
||||||
|
expect(lvl_inputs, data)
|
||||||
|
return root, groups, items, inputs
|
||||||
|
|
||||||
|
|
||||||
|
def test_program_with_two_projects(f4):
|
||||||
|
prog = f4.program()
|
||||||
|
p1, p2 = f4.project(prog), f4.project(prog)
|
||||||
|
for prj, vals in ((p1, (10, 20)), (p1, (5, 7)), (p2, (100, 200)), (p2, (1, 2))):
|
||||||
|
ln = f4.line(prj)
|
||||||
|
f4.cells(ln, {"plan.q1": vals[0], "plan.q2": vals[1], "reserve.q1": vals[0]})
|
||||||
|
root, groups, items, _ = check_sums(f4)
|
||||||
|
assert Decimal(str(items[p1]["plan"]["q1"])) == 15
|
||||||
|
assert Decimal(str(items[p2]["plan"]["q2"])) == 202
|
||||||
|
assert Decimal(str(groups[prog]["plan"]["q1"])) == 116
|
||||||
|
assert Decimal(str(root["plan"]["q1"])) == 116
|
||||||
|
|
||||||
|
|
||||||
|
def test_lines_directly_on_program(f4):
|
||||||
|
prog = f4.program()
|
||||||
|
prj = f4.project(prog)
|
||||||
|
direct = f4.line(prog)
|
||||||
|
in_prj = f4.line(prj)
|
||||||
|
f4.cell(direct, "plan.q1", 40)
|
||||||
|
f4.cell(in_prj, "plan.q1", 2)
|
||||||
|
root, groups, items, _ = check_sums(f4)
|
||||||
|
assert Decimal(str(groups[prog]["plan"]["q1"])) == 42
|
||||||
|
assert Decimal(str(items[prj]["plan"]["q1"])) == 2
|
||||||
|
assert Decimal(str(root["plan"]["q1"])) == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_line_project_keeps_text_two_lines_null(f4):
|
||||||
|
prog = f4.program()
|
||||||
|
single, pair = f4.project(prog), f4.project(prog)
|
||||||
|
ln = f4.line(single)
|
||||||
|
f4.cells(ln, {"contract.counterparty": "ООО Один", "q1.adj_comment": "комментарий", "plan.q1": 3})
|
||||||
|
for txt in ("ООО А", "ООО Б"):
|
||||||
|
f4.cells(f4.line(pair), {"contract.counterparty": txt, "q1.adj_comment": txt, "plan.q1": 1})
|
||||||
|
_, groups, items, _ = check_sums(f4)
|
||||||
|
assert items[single]["contract"]["counterparty"] == "ООО Один"
|
||||||
|
assert items[single]["q1"]["adj_comment"] == "комментарий"
|
||||||
|
assert items[pair]["contract"]["counterparty"] is None
|
||||||
|
assert items[pair]["q1"]["adj_comment"] is None
|
||||||
|
assert groups[prog]["contract"]["counterparty"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_line_program_keeps_text(f4):
|
||||||
|
prog = f4.program()
|
||||||
|
f4.cells(f4.line(prog), {"contract.counterparty": "ООО Одна", "plan.q1": 1})
|
||||||
|
_, groups, _, _ = check_sums(f4)
|
||||||
|
assert groups[prog]["contract"]["counterparty"] == "ООО Одна"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_program_and_project(f4):
|
||||||
|
filled = f4.program()
|
||||||
|
f4.cell(f4.line(filled), "plan.q1", 9)
|
||||||
|
empty_prog = f4.program()
|
||||||
|
empty_prj = f4.project(empty_prog)
|
||||||
|
_, groups, items, _ = check_sums(f4)
|
||||||
|
zero_plan = {"q1": 0, "q2": 0, "q3": 0, "q4": 0, "year": 0, "comment": None}
|
||||||
|
assert canon(groups[empty_prog]["plan"]) == canon(zero_plan)
|
||||||
|
assert canon(items[empty_prj]["plan"]) == canon(zero_plan)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decimals_negative_and_null(f4):
|
||||||
|
prog = f4.program()
|
||||||
|
prj = f4.project(prog)
|
||||||
|
other = f4.project(prog)
|
||||||
|
a, b, c, d = f4.line(prj), f4.line(prj), f4.line(prj), f4.line(other)
|
||||||
|
f4.cells(a, {"plan.q1": "1.50", "plan.q2": -3, "plan.q3": "0.125", "reserve.q1": 7})
|
||||||
|
f4.cells(b, {"plan.q1": 2, "plan.q2": "-0.5", "plan.q3": "10.1", "reserve.q1": None})
|
||||||
|
f4.cells(c, {"plan.q1": None, "plan.q2": "2.000", "plan.q3": None})
|
||||||
|
f4.cells(d, {"plan.q1": "-1.5", "plan.q2": 0})
|
||||||
|
_, groups, items, _ = check_sums(f4)
|
||||||
|
assert Decimal(str(items[prj]["plan"]["q1"])) == Decimal("3.5")
|
||||||
|
assert Decimal(str(items[prj]["plan"]["q2"])) == Decimal("-1.5")
|
||||||
|
assert Decimal(str(groups[prog]["plan"]["q1"])) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_booking_second_level(f4):
|
||||||
|
prog = f4.program()
|
||||||
|
prj = f4.project(prog)
|
||||||
|
for q1, q2, nx in ((1, "2.5", 4), (10, None, "0.75"), (None, 3, 5)):
|
||||||
|
f4.cells(f4.line(prj), {"booking.y2026.q1": q1, "booking.y2026.q2": q2, "booking.y2027.q1": nx})
|
||||||
|
_, groups, items, _ = check_sums(f4)
|
||||||
|
bk = items[prj]["booking"]
|
||||||
|
assert Decimal(str(bk["y2026"]["q1"])) == 11
|
||||||
|
assert Decimal(str(bk["y2026"]["q2"])) == Decimal("5.5")
|
||||||
|
assert Decimal(str(bk["y2027"]["q1"])) == Decimal("9.75")
|
||||||
|
assert Decimal(str(groups[prog]["booking"]["y2027"]["q1"])) == Decimal("9.75")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sections_filter(f4):
|
||||||
|
prog = f4.program()
|
||||||
|
prj = f4.project(prog)
|
||||||
|
for v in (1, 2):
|
||||||
|
f4.cells(f4.line(prj), {"plan.q1": v, "booking.y2026.q1": v * 10, "reserve.q1": v, "contract.counterparty": "x"})
|
||||||
|
root, groups, items, _ = check_sums(f4, sections=["plan", "booking"])
|
||||||
|
assert Decimal(str(items[prj]["plan"]["q1"])) == 3
|
||||||
|
assert Decimal(str(items[prj]["booking"]["y2026"]["q1"])) == 30
|
||||||
|
for b in BLOCKS:
|
||||||
|
if b not in ("plan", "booking"):
|
||||||
|
assert root[b] is None and groups[prog][b] is None and items[prj][b] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cell_patch_returns_updated_ancestor_sums(f4, client):
|
||||||
|
prog = f4.program()
|
||||||
|
prj = f4.project(prog)
|
||||||
|
l1, l2 = f4.line(prj), f4.line(prj)
|
||||||
|
f4.cells(l1, {"plan.q1": 10})
|
||||||
|
f4.cells(l2, {"plan.q1": 5})
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/v1/form/{f4.form_id}/sheet/{SHEET}/cell",
|
||||||
|
json={"line_id": l1, "column": "plan.q1", "value": 20},
|
||||||
|
headers=f4.h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
rows = _loads(resp.text)["result"]
|
||||||
|
by_type = {r["row_type"]: r["data"] for r in rows}
|
||||||
|
assert {"ROOT", "GROUP", "ITEM"} <= set(by_type), set(by_type)
|
||||||
|
for t in ("ROOT", "GROUP", "ITEM"):
|
||||||
|
assert Decimal(str(by_type[t]["plan"]["q1"])) == 25, t
|
||||||
|
# то, что отдал PATCH, совпадает со свежим GET
|
||||||
|
_, groups, items, _ = check_sums(f4)
|
||||||
|
assert Decimal(str(groups[prog]["plan"]["q1"])) == 25
|
||||||
|
assert Decimal(str(items[prj]["plan"]["q1"])) == 25
|
||||||
|
|
||||||
|
|
||||||
|
def test_jit_off_on_sheet_jsonb(f4):
|
||||||
|
"""AURORA-1846: v_form4_sheet_jsonb выполняется с jit=off (оценка плана выше порога JIT)."""
|
||||||
|
rows = f4.sql(
|
||||||
|
"""SELECT p.proname, COALESCE(p.proconfig, '{}') FROM pg_proc p
|
||||||
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||||
|
WHERE n.nspname = 'v3' AND p.proname = 'v_form4_sheet_jsonb'"""
|
||||||
|
)
|
||||||
|
assert len(rows) == 1, rows
|
||||||
|
assert "jit=off" in rows[0][1], f"нет SET jit = off: {rows[0][1]}"
|
||||||
@ -71,8 +71,8 @@ def queries(f2: int, f4: int) -> list[dict]:
|
|||||||
"sql": f"SELECT row_type, depth, sort_order, data FROM v3.v_form_view({f2}, 'AHR', NULL::text[], NULL::varchar)",
|
"sql": f"SELECT row_type, depth, sort_order, data FROM v3.v_form_view({f2}, 'AHR', NULL::text[], NULL::varchar)",
|
||||||
"variants": [("", {}), (".jit_off", {"jit": "off"}), (".work_mem_64MB", {"work_mem": "64MB"}), (".rerun_default", {})], "user": True},
|
"variants": [("", {}), (".jit_off", {"jit": "off"}), (".work_mem_64MB", {"work_mem": "64MB"}), (".rerun_default", {})], "user": True},
|
||||||
{"name": "form4_cap_sheet", "what": "FORM_4/CAP: тело v3.v_form4_sheet_jsonb (300 строк после grow)",
|
{"name": "form4_cap_sheet", "what": "FORM_4/CAP: тело v3.v_form4_sheet_jsonb (300 строк после grow)",
|
||||||
"src": "api/alembic/versions/sql/0012_del_project.sql:1097-…",
|
"src": "api/alembic/versions/sql/0053_form4_sheet_sum.sql:12-…",
|
||||||
"sql": None, "func_body": ("0012_del_project.sql", 1097, "WITH inp_raw AS ("), "form_id": f4, "sheet": "CAP",
|
"sql": None, "func_body": ("0053_form4_sheet_sum.sql", 12, "WITH inp_raw AS ("), "form_id": f4, "sheet": "CAP",
|
||||||
"variants": [("", {}), (".jit_off", {"jit": "off"})]},
|
"variants": [("", {}), (".jit_off", {"jit": "off"})]},
|
||||||
{"name": "form_view_form4_cap", "what": "FORM_4/CAP: вызов v3.v_form_view (как GET листа)",
|
{"name": "form_view_form4_cap", "what": "FORM_4/CAP: вызов v3.v_form_view (как GET листа)",
|
||||||
"src": "api/src/repository (sheet_repository)",
|
"src": "api/src/repository (sheet_repository)",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user