diff --git a/api/alembic/versions/0054_form2_ahr_hide_satellite_rows.py b/api/alembic/versions/0054_form2_ahr_hide_satellite_rows.py new file mode 100644 index 0000000..64b3793 --- /dev/null +++ b/api/alembic/versions/0054_form2_ahr_hide_satellite_rows.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0054" +down_revision = "0053" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0054_form2_ahr_hide_satellite_rows.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0054_form2_ahr_hide_satellite_rows.sql b/api/alembic/versions/sql/0054_form2_ahr_hide_satellite_rows.sql new file mode 100644 index 0000000..96283e8 --- /dev/null +++ b/api/alembic/versions/sql/0054_form2_ahr_hide_satellite_rows.sql @@ -0,0 +1,418 @@ +-- AURORA-1887: скрытие сателлитных INPUT-строк (лимит/аренда/коммуналка/ +-- охрана) на сводном листе АХР и блокировка «Добавить строку» для статей +-- аренды/коммуналки/охраны на этом листе. +-- +-- Упрощённый дизайн (согласован в чате 2026-10-31, расходится с исходным +-- текстом тикета): НЕ меняем модель budget_line, НЕ трогаем уникальность в +-- v3.form2_ahr_satellite_line_source, НЕ делаем миграцию слияния строк, НЕ +-- трогаем протокол WS, НЕ трогаем v3.v_svod2_rf (Свод 2 — построчная +-- детализация договоров там сознательно остаётся). +-- +-- Что меняется: +-- 1. v3.v_form2_sheet_jsonb: при p_sheet='AHR' не возвращаем INPUT-строки, +-- чей budget_line.source_sheet IN ('AHR_LIMIT','AHR_RENT','AHR_UTILITY', +-- 'AHR_SECURITY'). Строки с source_sheet IS NULL (ручные, в т.ч. «сверх +-- лимита» из AURORA-1907) остаются видимыми как есть. Листы-источники +-- (AHR_LIMIT/AHR_RENT/AHR_UTILITY/AHR_SECURITY) эта функция не +-- обслуживает (у них свои ветки в v3.v_form2_view, 0052) — там ничего не +-- меняется, полная детализация остаётся. Суммы на заголовках статей +-- (ITEM/SUB_ITEM) считаются в v3.v_form2_sheet_sections (CTE tw_agg) по +-- ВСЕМ строкам независимо от видимости в выдаче этой функции — фильтр +-- ниже влияет только на то, какие строки возвращаются, не на то, что +-- суммируется (limit_year/over_limit_year в этой же функции — тоже +-- отдельный агрегат по v3.budget_line/v3.mv_expense_item_tree, не по +-- видимым строкам). Также неявно решает вопрос WS-уведомлений: дельта +-- по сателлитной строке (v3.get_form2_ahr_satellite_delta, 0053) сама +-- вызывает v3.v_form_view(..., 'AHR', ...) → эту функцию, поэтому +-- скрытая INPUT-строка автоматически не попадёт и в WS-дельту; заголовки +-- (ROOT/GROUP/ITEM/SUB_ITEM) в дельте остаются и обновляют суммы. +-- websocket.py (notify_ahr_about_satellite_change) при этом не находит +-- INPUT-строку в дельте для source_event='row_added' и отправляет +-- cell_updated вместо row_added — обрабатывается существующим кодом +-- фронта (cellUtils.updateCells сопоставляет по row_type+depth+sort_order +-- и просто не находит несуществующую строку, без ошибки); row_deleted от +-- notify_ahr_about_satellite_change тоже безопасен — cellUtils.deleteRow +-- ищет lineId по всему дереву и является no-op, если не находит (проверено +-- чтением кода api/src/api/v1/websocket.py и +-- web/src/components/RealtimeTable/utils/cellUtils.js — не трогаем ни то, +-- ни другое, протокол WS не меняется). +-- 2. header.can_add_row (новое поле, вычисляется только при p_sheet='AHR'): +-- false для статей аренды/коммуналки/охраны (единственный expense_item_id +-- на каждую — см. v3._form2_satellite_expense_item ниже; те же id, что +-- зашиты в v3.add_budget_line, 0051_form2_ahr_satellite_new_line.sql:72-76, +-- 314/24/779 — проверены эмпирически на тестовой БД, см. ниже), true для +-- всех остальных статей, включая лимитные (AURORA-1907 «сверх лимита» не +-- трогаем — там единого expense_item_id у листа-источника нет вовсе, +-- только множество LEAF-статей). Это backend-флаг информационного +-- характера: фронт (RealtimeTable.jsx handleAddRow) читает его и +-- блокирует кнопку «Добавить строку» до вызова API — тот же паттерн, +-- что и у существующих правил добавления строк в этом компоненте +-- (FormsNoCanAddRow, isVspAdditionRow, isAdditionExpenseItem), ни одно из +-- которых не продублировано проверкой на backend в v3.add_budget_line; +-- тот же паттерн сохранён и здесь — без серверной валидации в +-- add_budget_line (рулинг AURORA-1887, описан в PR). +-- +-- Проверка id 314 (Аренда_ОН)/24 (Коммунальные услуги)/779 (Пультовая охрана +-- объектов недвижимости) — через psql на тестовой БД (разовый Postgres 16 в +-- Docker для этой задачи, см. PR): SELECT id, item_id, name, sheet, +-- direction, depth FROM v3.expense_item WHERE id IN (314,24,779); все три — +-- sheet='AHR', direction='Support', совпадают с текстом в +-- 0051_form2_ahr_satellite_new_line.sql и с комментарием в +-- tests/integration/test_form2_ahr_satellite_integration.py (SATELLITE_PARENT_EXPENSE_ITEM). +-- +-- v_form2_sheet_jsonb последний раз целиком определена в 0053 (воспроизводит +-- 0015_aggregates_fix.sql:2534-2807 + source_sheet/limit_year/over_limit_year +-- AURORA-1907). CREATE OR REPLACE ниже — та же версия + пункты 1-2 выше. + + +-- Новый небольшой helper: единственный expense_item_id листа-источника для +-- аренды/коммуналки/охраны. Для AHR_LIMIT единого id нет (множество LEAF +-- статей лимитов, каждая со своим expense_item_id) — NULL, как и для любого +-- другого p_sheet. +CREATE OR REPLACE FUNCTION v3._form2_satellite_expense_item(p_sheet character varying) +RETURNS integer +LANGUAGE sql +IMMUTABLE +AS $function$ + SELECT CASE p_sheet + WHEN 'AHR_RENT' THEN 314 + WHEN 'AHR_UTILITY' THEN 24 + WHEN 'AHR_SECURITY' THEN 779 + ELSE NULL + END; +$function$; + + +CREATE OR REPLACE FUNCTION v3.v_form2_sheet_jsonb(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL::text[]) + RETURNS TABLE(row_type character varying, depth integer, line_id integer, header jsonb, plan_data jsonb, seq_dfip_data jsonb, seq_ssp_data jsonb, approved_data jsonb, contract_data jsonb, booking_data jsonb, contract_summary jsonb, allocation_data jsonb, collegial_data jsonb, ckk_data jsonb, q1_data jsonb, q2_data jsonb, q3_data jsonb, q4_data jsonb, totals_data jsonb, _sort_path integer[]) + LANGUAGE sql + STABLE +AS $function$ + WITH flags AS ( + SELECT + (p_sections IS NULL OR 'plan' = ANY(p_sections)) AS s_plan, + (p_sections IS NULL OR 'seq_dfip' = ANY(p_sections)) AS s_seq_d, + (p_sections IS NULL OR 'seq_ssp' = ANY(p_sections)) AS s_seq_s, + (p_sections IS NULL OR 'approved' = ANY(p_sections)) AS s_appr, + (p_sections IS NULL OR 'contract' = ANY(p_sections)) AS s_cd, + (p_sections IS NULL OR 'booking' = ANY(p_sections)) AS s_book, + (p_sections IS NULL OR 'contract_summary' = ANY(p_sections)) AS s_cs, + (p_sections IS NULL OR 'allocation' = ANY(p_sections)) AS s_al, + (p_sections IS NULL OR 'collegial' = ANY(p_sections)) AS s_ca, + (p_sections IS NULL OR 'ckk' = ANY(p_sections)) AS s_ckk, + (p_sections IS NULL OR 'q1' = ANY(p_sections)) AS s_q1, + (p_sections IS NULL OR 'q2' = ANY(p_sections)) AS s_q2, + (p_sections IS NULL OR 'q3' = ANY(p_sections)) AS s_q3, + (p_sections IS NULL OR 'q4' = ANY(p_sections)) AS s_q4, + (p_sections IS NULL OR 'totals' = ANY(p_sections)) AS s_tot + ), + sheet_rows AS ( + SELECT s.*, + CASE WHEN s.row_type = 'INPUT' + THEN s._sort_path[array_upper(s._sort_path,1)-1] + ELSE s._sort_path[array_upper(s._sort_path,1)] END AS eid + FROM v3.v_form2_sheet_sections(p_form_id, p_sheet, p_sections) s + ), + limit_tree AS ( + SELECT + t.id AS expense_item_id, + COALESCE(SUM(CASE WHEN bl2.source_sheet = 'AHR_LIMIT' + THEN COALESCE(p2.plan_q1,0)+COALESCE(p2.plan_q2,0)+COALESCE(p2.plan_q3,0)+COALESCE(p2.plan_q4,0) + ELSE 0 END), 0) AS limit_year, + COALESCE(SUM(CASE WHEN bl2.source_sheet IS NULL + AND v3._form2_expense_item_has_limit(bl2.expense_item_id) + THEN COALESCE(p2.plan_q1,0)+COALESCE(p2.plan_q2,0)+COALESCE(p2.plan_q3,0)+COALESCE(p2.plan_q4,0) + ELSE 0 END), 0) AS over_limit_year + FROM v3.mv_expense_item_tree t + JOIN v3.budget_line bl2 + ON bl2.expense_item_id = ANY(t.desc_ids) + AND bl2.budget_form_id = p_form_id + AND bl2.direction = 'Support' + LEFT JOIN v3.plan p2 ON p2.line_id = bl2.id + WHERE p_sheet = 'AHR' + AND t.sheet = p_sheet + GROUP BY t.id + ), + sat AS ( + SELECT + v3._form2_satellite_expense_item('AHR_RENT') AS rent_id, + v3._form2_satellite_expense_item('AHR_UTILITY') AS utility_id, + v3._form2_satellite_expense_item('AHR_SECURITY') AS security_id + ) + SELECT + s.row_type, s.depth, s.line_id, + + jsonb_build_object( + 'section_code', s.col_section_code, + 'item_id', s.col_item_id, + 'num_group', s.col_num_group_id, + 'name', s.col_name, + 'vsp_id', s.col_vsp_id, + 'vsp_address', s.col_vsp_address, + 'internal_order', s.col_internal_order, + 'expense_item_id', s.eid, + 'source', CASE WHEN s.row_type = 'INPUT' + THEN COALESCE(bl.source_sheet, 'MANUAL') + ELSE NULL END, + 'is_over_limit', (s.row_type = 'INPUT' + AND bl.source_sheet IS NULL + AND v3._form2_expense_item_has_limit(bl.expense_item_id)), + 'can_add_row', CASE WHEN p_sheet = 'AHR' + THEN NOT (COALESCE(s.eid, -1) = ANY(ARRAY[sat.rent_id, sat.utility_id, sat.security_id])) + ELSE NULL END + ) AS header, + + CASE WHEN f.s_plan THEN jsonb_build_object( + 'q1', s.col_plan_q1, 'q2', s.col_plan_q2, + 'q3', s.col_plan_q3, 'q4', s.col_plan_q4, + 'year', s.col_plan_year, 'comment', s.col_plan_comment + ) || CASE WHEN p_sheet = 'AHR' THEN jsonb_build_object( + 'limit_year', CASE WHEN s.row_type = 'INPUT' + THEN CASE WHEN bl.source_sheet = 'AHR_LIMIT' + THEN COALESCE(s.col_plan_year, 0) ELSE 0 END + ELSE COALESCE(lim.limit_year, 0) END, + 'over_limit_year', CASE WHEN s.row_type = 'INPUT' + THEN CASE WHEN bl.source_sheet IS NULL + AND v3._form2_expense_item_has_limit(bl.expense_item_id) + THEN COALESCE(s.col_plan_year, 0) ELSE 0 END + ELSE COALESCE(lim.over_limit_year, 0) END + ) ELSE '{}'::jsonb END END AS plan_data, + + CASE WHEN f.s_seq_d THEN jsonb_build_object( + 'q1', s.col_seq_dfip_q1, 'q2', s.col_seq_dfip_q2, + 'q3', s.col_seq_dfip_q3, 'q4', s.col_seq_dfip_q4, + 'year', s.col_seq_dfip_year, + 'justification', s.col_seq_dfip_just + ) END AS seq_dfip_data, + + CASE WHEN f.s_seq_s THEN jsonb_build_object( + 'q1', s.col_seq_ssp_q1, 'q2', s.col_seq_ssp_q2, + 'q3', s.col_seq_ssp_q3, 'q4', s.col_seq_ssp_q4, + 'year', s.col_seq_ssp_year, + 'justification', s.col_seq_ssp_just + ) END AS seq_ssp_data, + + CASE WHEN f.s_appr THEN jsonb_build_object( + 'q1', s.col_appr_q1, 'q2', s.col_appr_q2, + 'q3', s.col_appr_q3, 'q4', s.col_appr_q4, + 'year', s.col_appr_year + ) END AS approved_data, + + CASE WHEN f.s_cd THEN jsonb_build_object( + 'counterparty', s.col_cd_counterparty, + 'reference', s.col_cd_reference, + 'addenda', s.col_cd_addenda, + 'date', TO_CHAR(s.col_cd_date, 'DD.MM.YYYY'), + 'subject', s.col_cd_subject, + 'currency', s.col_cd_currency, + 'ceiling', s.col_cd_ceiling, + 'q1', s.col_cd_exp_q1, + 'q2', s.col_cd_exp_q2, + 'q3', s.col_cd_exp_q3, + 'q4', s.col_cd_exp_q4, + 'rf_schedule', s.col_cd_rf_sched, + 'vat_rate', s.col_cd_vat_rate, + 'exchange_rate', s.col_cd_exchange_rate, + 'amount_foreign', s.col_cd_amount_foreign, + 'deadline', s.col_cd_deadline, + 'scheme', s.col_cd_scheme, + 'act', s.col_cd_act, + 'comment', s.col_cd_comment + ) END AS contract_data, + + CASE WHEN f.s_book THEN jsonb_build_object( + 'y2026', jsonb_build_object('q1', s.col_book_q1, 'q2', s.col_book_q2, 'q3', s.col_book_q3, 'q4', s.col_book_q4), + 'y2027', jsonb_build_object('q1', s.col_book_next_q1, 'q2', s.col_book_next_q2, 'q3', s.col_book_next_q3, 'q4', s.col_book_next_q4) + ) END AS booking_data, + + CASE WHEN f.s_cs THEN jsonb_build_object( + 'total', s.col_cs_total, + 'counterparty', s.col_cs_counterparty, + 'deadline', s.col_cs_deadline, + 'comment', s.col_cs_comment, + 'future_y1', s.col_cs_future_y1, + 'future_y2', s.col_cs_future_y2, + 'other_ssp', s.col_cs_other_ssp + ) END AS contract_summary, + + CASE WHEN f.s_al THEN jsonb_build_object( + 'contract_ref', s.col_al_contract_ref, + 'purpose', s.col_al_purpose, + 'order', s.col_al_order, + 'property', s.col_al_property + ) END AS allocation_data, + + CASE WHEN f.s_ca THEN jsonb_build_object( + 'approved', s.col_ca_approved, + 'protocol', s.col_ca_protocol, + 'note', s.col_ca_note + ) END AS collegial_data, + + CASE WHEN f.s_ckk THEN jsonb_build_object( + 'ceiling', s.col_ckk_ceiling, + 'rf_schedule', s.col_ckk_rf_sched, + 'deadline', s.col_ckk_deadline, + 'proc_plan', s.col_ckk_proc_plan, + 'proc_method', s.col_ckk_proc_meth, + 'comment', s.col_ckk_comment + ) END AS ckk_data, + + CASE WHEN f.s_q1 THEN jsonb_build_object( + 'adj_current', s.col_q1_adj_current, + 'adj_ssp', s.col_q1_adj_ssp, + 'adj_rf', s.col_q1_adj_rf, + 'adj_reserve', s.col_q1_adj_reserve, + 'adj_comment', s.col_q1_adj_comment, + 'corrected_plan', s.col_q1_corrected_plan, + 'pay_date', TO_CHAR(s.col_q1_pay_date, 'DD.MM.YYYY'), + 'pay_amount', s.col_q1_pay_amount, + 'pay_ho', s.col_q1_pay_ho, + 'pay_rf', s.col_q1_pay_rf, + 'pay_comment', s.col_q1_pay_comment, + 'pay_act', s.col_q1_pay_act, + 'booking', s.col_q1_booking, + 'actual_m1', s.col_q1_actual_m1, + 'actual_m2', s.col_q1_actual_m2, + 'actual_m3', s.col_q1_actual_m3, + 'actual_quarter', s.col_q1_actual_quarter, + 'residual_after_booking', s.col_q1_residual_after_booking, + 'residual_after_actual', s.col_q1_residual_after_actual, + 'transfer_q2', s.col_q1_transfer_q2, + 'transfer_q2_delay_acts', s.col_q1_transfer_q2_delay_acts, + 'transfer_q2_delay_procurement', s.col_q1_transfer_q2_delay_procurement, + 'transfer_q2_economy_rf', s.col_q1_transfer_q2_economy_rf, + 'transfer_next_comment', s.col_q1_transfer_next_comment, + 'transfer_q3', s.col_q1_transfer_q3, + 'transfer_q4', s.col_q1_transfer_q4, + 'transfer_far_comment', s.col_q1_transfer_far_comment, + 'transfer_econ', s.col_q1_transfer_econ, + 'total', s.col_q1_total + ) END AS q1_data, + + CASE WHEN f.s_q2 THEN jsonb_build_object( + 'target_change', s.col_q2_target_change, + 'base_correction', s.col_q2_base_correction, + 'base_correction_comment', s.col_q2_base_correction_comment, + 'revision_inc', s.col_q2_revision_inc, + 'revision_seq', s.col_q2_revision_seq, + 'revision_comment', s.col_q2_revision_comment, + 'new_plan', s.col_q2_new_plan, + 'adj_current', s.col_q2_adj_current, + 'adj_ssp', s.col_q2_adj_ssp, + 'adj_rf', s.col_q2_adj_rf, + 'adj_reserve', s.col_q2_adj_reserve, + 'adj_comment', s.col_q2_adj_comment, + 'corrected_plan', s.col_q2_corrected_plan, + 'pay_date', TO_CHAR(s.col_q2_pay_date, 'DD.MM.YYYY'), + 'pay_amount', s.col_q2_pay_amount, + 'pay_ho', s.col_q2_pay_ho, + 'pay_rf', s.col_q2_pay_rf, + 'pay_comment', s.col_q2_pay_comment, + 'pay_act', s.col_q2_pay_act, + 'booking', s.col_q2_booking, + 'actual_m1', s.col_q2_actual_m1, + 'actual_m2', s.col_q2_actual_m2, + 'actual_m3', s.col_q2_actual_m3, + 'actual_quarter', s.col_q2_actual_quarter, + 'residual_after_booking', s.col_q2_residual_after_booking, + 'residual_after_actual', s.col_q2_residual_after_actual, + 'transfer_q3', s.col_q2_transfer_q3, + 'transfer_q3_delay_acts', s.col_q2_transfer_q3_delay_acts, + 'transfer_q3_delay_procurement', s.col_q2_transfer_q3_delay_procurement, + 'transfer_q3_economy_rf', s.col_q2_transfer_q3_economy_rf, + 'transfer_next_comment', s.col_q2_transfer_next_comment, + 'transfer_q4', s.col_q2_transfer_q4, + 'transfer_far_comment', s.col_q2_transfer_far_comment, + 'transfer_econ', s.col_q2_transfer_econ, + 'total', s.col_q2_total + ) END AS q2_data, + + CASE WHEN f.s_q3 THEN jsonb_build_object( + 'target_change', s.col_q3_target_change, + 'base_correction', s.col_q3_base_correction, + 'base_correction_comment', s.col_q3_base_correction_comment, + 'revision_inc', s.col_q3_revision_inc, + 'revision_seq', s.col_q3_revision_seq, + 'revision_comment', s.col_q3_revision_comment, + 'new_plan', s.col_q3_new_plan, + 'adj_current', s.col_q3_adj_current, + 'adj_ssp', s.col_q3_adj_ssp, + 'adj_rf', s.col_q3_adj_rf, + 'adj_reserve', s.col_q3_adj_reserve, + 'adj_comment', s.col_q3_adj_comment, + 'corrected_plan', s.col_q3_corrected_plan, + 'pay_date', TO_CHAR(s.col_q3_pay_date, 'DD.MM.YYYY'), + 'pay_amount', s.col_q3_pay_amount, + 'pay_ho', s.col_q3_pay_ho, + 'pay_rf', s.col_q3_pay_rf, + 'pay_comment', s.col_q3_pay_comment, + 'pay_act', s.col_q3_pay_act, + 'booking', s.col_q3_booking, + 'actual_m1', s.col_q3_actual_m1, + 'actual_m2', s.col_q3_actual_m2, + 'actual_m3', s.col_q3_actual_m3, + 'actual_quarter', s.col_q3_actual_quarter, + 'residual_after_booking', s.col_q3_residual_after_booking, + 'residual_after_actual', s.col_q3_residual_after_actual, + 'transfer_q4', s.col_q3_transfer_q4, + 'transfer_q4_delay_acts', s.col_q3_transfer_q4_delay_acts, + 'transfer_q4_delay_procurement', s.col_q3_transfer_q4_delay_procurement, + 'transfer_q4_economy_rf', s.col_q3_transfer_q4_economy_rf, + 'transfer_next_comment', s.col_q3_transfer_next_comment, + 'transfer_econ', s.col_q3_transfer_econ, + 'total', s.col_q3_total + ) END AS q3_data, + + CASE WHEN f.s_q4 THEN jsonb_build_object( + 'target_change', s.col_q4_target_change, + 'base_correction', s.col_q4_base_correction, + 'base_correction_comment', s.col_q4_base_correction_comment, + 'revision_inc', s.col_q4_revision_inc, + 'revision_seq', s.col_q4_revision_seq, + 'revision_comment', s.col_q4_revision_comment, + 'new_plan', s.col_q4_new_plan, + 'adj_current', s.col_q4_adj_current, + 'adj_ssp', s.col_q4_adj_ssp, + 'adj_rf', s.col_q4_adj_rf, + 'adj_reserve', s.col_q4_adj_reserve, + 'adj_comment', s.col_q4_adj_comment, + 'corrected_plan', s.col_q4_corrected_plan, + 'pay_date', TO_CHAR(s.col_q4_pay_date, 'DD.MM.YYYY'), + 'pay_amount', s.col_q4_pay_amount, + 'pay_ho', s.col_q4_pay_ho, + 'pay_rf', s.col_q4_pay_rf, + 'pay_comment', s.col_q4_pay_comment, + 'pay_act', s.col_q4_pay_act, + 'booking', s.col_q4_booking, + 'actual_m1', s.col_q4_actual_m1, + 'actual_m2', s.col_q4_actual_m2, + 'actual_m3', s.col_q4_actual_m3, + 'actual_spod', s.col_q4_actual_spod, + 'actual_quarter', s.col_q4_actual_quarter, + 'residual_after_booking', s.col_q4_residual_after_booking, + 'residual_after_actual', s.col_q4_residual_after_actual, + 'transfer_econ', s.col_q4_transfer_econ, + 'total', s.col_q4_total + ) END AS q4_data, + + CASE WHEN f.s_tot THEN jsonb_build_object( + 'fact_year', s.col_fact_year, + 'pay_year', s.col_pay_year, + 'economy_year', s.col_economy_year + ) END AS totals_data, + + s._sort_path + FROM sheet_rows s + CROSS JOIN flags f + CROSS JOIN sat + LEFT JOIN v3.budget_line bl ON bl.id = s.line_id + LEFT JOIN limit_tree lim ON lim.expense_item_id = s.eid + WHERE NOT ( + p_sheet = 'AHR' + AND s.row_type = 'INPUT' + AND bl.source_sheet IS NOT NULL + AND bl.source_sheet IN ('AHR_LIMIT', 'AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY') + ) + ORDER BY s._sort_path; +$function$ +; diff --git a/api/tests/integration/test_form2_ahr_limit.py b/api/tests/integration/test_form2_ahr_limit.py index b261ccd..55812df 100644 --- a/api/tests/integration/test_form2_ahr_limit.py +++ b/api/tests/integration/test_form2_ahr_limit.py @@ -73,91 +73,128 @@ def _find_by_id(rows: list[dict], target_id: int) -> dict: raise AssertionError(f"строка с id={target_id} не найдена среди {rows!r}") -def _find_ahr_row_by_name(rows: list[dict], name: str) -> dict: +def _find_ahr_header_row(rows: list[dict], expense_item_id: int) -> dict: + # AURORA-1887: лимитные INPUT-строки на листе AHR теперь скрыты (не + # выводятся отдельными строками) — раньше этот файл проверял сумму + # напрямую на INPUT-строке по имени/expense_item_id, сейчас сравниваем + # через заголовок статьи. Заголовок — ITEM/SUB_ITEM/SUB_SUB_ITEM в + # зависимости от глубины конкретной статьи в дереве расходов (проверено + # эмпирически через v3.expense_item на тестовой БД: «ДМС» и «Мобильная + # связь» — depth=3 (SUB_ITEM), «оргтехника»/«кассовое оборудование» — + # depth=4 (SUB_SUB_ITEM)). for row in rows: - if row.get("row_type") != "INPUT": - continue - data = row.get("data") or {} - if (data.get("header") or {}).get("name") == name: - return row - raise AssertionError(f"строка АХР с именем {name!r} не найдена среди {rows!r}") - - -def _find_ahr_row_by_expense_item(rows: list[dict], expense_item_id: int) -> dict: - for row in rows: - if row.get("row_type") != "INPUT": + if row.get("row_type") not in ("ITEM", "SUB_ITEM", "SUB_SUB_ITEM"): continue data = row.get("data") or {} if (data.get("header") or {}).get("expense_item_id") == expense_item_id: return row raise AssertionError( - f"строка АХР с expense_item_id={expense_item_id} не найдена среди {rows!r}" + f"заголовок статьи expense_item_id={expense_item_id} не найден на листе AHR " + f"среди {[r for r in rows if r.get('row_type') != 'INPUT']!r}" ) def test_annual_dms_limit_divided_by_four(client, admin_tokens, form2_id): - # id=24 «- стандарт РФ»: ДМС, лимит 27858 руб/год без НДС, period=YEAR — - # 10 * 27858 / 4 = 69645 руб. в заполненный квартал (а не дублирование - # годовой суммы 278580, как было до миграции). + # id=24 «- стандарт РФ»: ДМС (expense_item_id=456), лимит 27858 руб/год + # без НДС, period=YEAR — 10 * 27858 / 4 = 69645 руб. в заполненный + # квартал (а не дублирование годовой суммы 278580, как было до + # миграции). AURORA-1887: INPUT-строка на AHR теперь скрыта — сравниваем + # приращение суммы на заголовке статьи (ITEM/SUB_ITEM/SUB_SUB_ITEM) до и + # после патча, не абсолютное значение (на статье могут быть и другие + # лимитные LEAF из сидовых данных). + before = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 456 + )["data"]["plan"]["q1"] or 0 + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 24, "qty_q1", 10) row = _find_by_id(rows, 24) assert row["data"]["amount_q1"] == 69645 assert row["data"]["amount_year"] == 69645 - ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row = _find_ahr_row_by_name(ahr_rows, "стандарт РФ") - assert ahr_row["data"]["plan"]["q1"] == pytest.approx(69.645) + after = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 456 + )["data"]["plan"]["q1"] or 0 + assert (after - before) == pytest.approx(69.645) def test_quarterly_limit_uses_amount_without_vat(client, admin_tokens, form2_id): - # id=3 «- Директор»: 1 чел/квартал, 2400 руб. с НДС / 2000 без НДС — - # сумма должна считаться от лимита БЕЗ НДС. + # id=3 «- Директор» (expense_item_id=150, «Мобильная связь»): 1 + # чел/квартал, 2400 руб. с НДС / 2000 без НДС — сумма должна считаться + # от лимита БЕЗ НДС. AURORA-1887: сравниваем приращение на заголовке + # статьи — см. комментарий в test_annual_dms_limit_divided_by_four. + before = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150 + )["data"]["plan"]["q2"] or 0 + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 3, "qty_q2", 1) row = _find_by_id(rows, 3) assert row["data"]["amount_q2"] == 2000 - ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row = _find_ahr_row_by_name(ahr_rows, "Директор") - assert ahr_row["data"]["plan"]["q2"] == pytest.approx(2.0) + after = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150 + )["data"]["plan"]["q2"] or 0 + assert (after - before) == pytest.approx(2.0) def test_sim_card_without_vat_limit_backfilled_is_not_zero(client, admin_tokens, form2_id): - # id=7 «- SIM-карта для планшета (легкий фронт)»: limit_without_vat был - # NULL до миграции — она заполнила его 375 (450 / 1.2). Сумма не должна - # стать 0 после перехода на расчёт без НДС. + # id=7 «- SIM-карта для планшета (легкий фронт)» (expense_item_id=150, + # «Мобильная связь»): limit_without_vat был NULL до миграции — она + # заполнила его 375 (450 / 1.2). Сумма не должна стать 0 после перехода + # на расчёт без НДС. AURORA-1887: сравниваем приращение на заголовке + # статьи — см. комментарий в test_annual_dms_limit_divided_by_four. + before = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150 + )["data"]["plan"]["q1"] or 0 + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 7, "qty_q1", 20) row = _find_by_id(rows, 7) assert row["data"]["amount_q1"] == 7500 - ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row = _find_ahr_row_by_name(ahr_rows, "SIM-карта для планшета (легкий фронт)") - assert ahr_row["data"]["plan"]["q1"] == pytest.approx(7.5) + after = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150 + )["data"]["plan"]["q1"] or 0 + assert (after - before) == pytest.approx(7.5) def test_printers_annual_limit_without_name_lookup(client, admin_tokens, form2_id): - # id=13 «- принтеры»: раньше считалось по захардкоженному имени + # id=13 «- принтеры» (expense_item_id=167, «ТО/экспл оргтехника», + # depth=4/SUB_SUB_ITEM): раньше считалось по захардкоженному имени # (qty * 0.57 * 10900 / 4), теперь — общей формулой через period=YEAR, # limit_without_vat=5177.5 (миграция переписала справочник). + # AURORA-1887: сравниваем приращение на заголовке статьи — см. + # комментарий в test_annual_dms_limit_divided_by_four. + before = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 167 + )["data"]["plan"]["q1"] or 0 + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 13, "qty_q1", 5) row = _find_by_id(rows, 13) assert row["data"]["amount_q1"] == pytest.approx(6471.875) - ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row = _find_ahr_row_by_name(ahr_rows, "принтеры") - assert ahr_row["data"]["plan"]["q1"] == pytest.approx(6.471875) + after = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 167 + )["data"]["plan"]["q1"] or 0 + assert (after - before) == pytest.approx(6.471875) def test_quarterly_norm_unchanged_other_than_vat_source(client, admin_tokens, form2_id): - # id=17 «- счетчики/сортировщики банкнот»: квартальный норматив (period + # id=17 «- счетчики/сортировщики банкнот» (expense_item_id=179, «ТО/ + # экспл касс обор», depth=4/SUB_SUB_ITEM): квартальный норматив (period # остаётся QUARTER по умолчанию) считается как раньше — поменялся только - # источник суммы (НДС). + # источник суммы (НДС). AURORA-1887: сравниваем приращение на заголовке + # статьи — см. комментарий в test_annual_dms_limit_divided_by_four. + before = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 179 + )["data"]["plan"]["q3"] or 0 + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 17, "qty_q3", 2) row = _find_by_id(rows, 17) assert row["data"]["amount_q3"] == 7500 - ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row = _find_ahr_row_by_name(ahr_rows, "счетчики/сортировщики банкнот") - assert ahr_row["data"]["plan"]["q3"] == pytest.approx(7.5) + after = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 179 + )["data"]["plan"]["q3"] or 0 + assert (after - before) == pytest.approx(7.5) def test_total_row_is_last_root_row_summing_all_leaves(client, admin_tokens, form2_id): @@ -189,7 +226,14 @@ def test_total_row_is_last_root_row_summing_all_leaves(client, admin_tokens, for def test_rent_plan_still_passed_through_without_division(client, admin_tokens, form2_id): # Регрессия: AURORA-1897 не должна задевать перенос аренды/коммуналки/ - # охраны — только лимитные строки АХР лимитов. + # охраны — только лимитные строки АХР лимитов. AURORA-1887: INPUT-строка + # записи аренды на AHR теперь скрыта — сравниваем приращение на + # заголовке статьи аренды (не абсолютное значение: на статье могут уже + # быть другие записи аренды из сидовых данных). + before = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), AHR_RENT_PARENT_EXPENSE_ITEM + )["data"]["plan"]["q1"] or 0 + response = client.post( f"/api/v1/form/{form2_id}/sheet/AHR_RENT/line", json={"vsp_id": 1}, @@ -207,6 +251,7 @@ def test_rent_plan_still_passed_through_without_division(client, admin_tokens, f ) assert response.status_code == 200, response.text - ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row = _find_ahr_row_by_expense_item(ahr_rows, AHR_RENT_PARENT_EXPENSE_ITEM) - assert ahr_row["data"]["plan"]["q1"] == 100 + after = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), AHR_RENT_PARENT_EXPENSE_ITEM + )["data"]["plan"]["q1"] or 0 + assert (after - before) == 100 diff --git a/api/tests/integration/test_form2_ahr_satellite_integration.py b/api/tests/integration/test_form2_ahr_satellite_integration.py index 00c611f..39ca5d7 100644 --- a/api/tests/integration/test_form2_ahr_satellite_integration.py +++ b/api/tests/integration/test_form2_ahr_satellite_integration.py @@ -48,15 +48,19 @@ def _add_manual_ahr_line(client, admin_tokens, auth_headers, form_id, expense_it return input_rows[0]["data"]["line_id"] -def _patch_ahr_cell(client, admin_tokens, auth_headers, form_id, line_id, column, value): +def _patch_cell(client, admin_tokens, auth_headers, form_id, sheet, line_id, column, value): response = client.patch( - f"/api/v1/form/{form_id}/sheet/AHR/cell", + f"/api/v1/form/{form_id}/sheet/{sheet}/cell", json={"line_id": line_id, "column": column, "value": value}, headers=auth_headers(admin_tokens), ) assert response.status_code == 200, response.text +def _patch_ahr_cell(client, admin_tokens, auth_headers, form_id, line_id, column, value): + _patch_cell(client, admin_tokens, auth_headers, form_id, "AHR", line_id, column, value) + + def _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id) -> dict: response = client.post( f"/api/v1/form/{form_id}/sheet/{sheet}/line", @@ -96,6 +100,21 @@ def _find_ahr_item_row(rows: list[dict], expense_item_id: int) -> dict: raise AssertionError(f"SUB_ITEM expense_item_id={expense_item_id} не найден на листе AHR: {[r for r in rows if r['row_type'] != 'INPUT']}") +def _find_ahr_header_row(rows: list[dict], expense_item_id: int) -> dict: + # Обобщение _find_ahr_item_row: не все статьи-"листья" дерева расходов на + # глубине 3 (SUB_ITEM) — Коммунальные услуги (expense_item_id=24) лежат на + # глубине 2 (ITEM), т.к. у этой статьи нет собственной под-ветки. Ищем по + # обоим типам (проверено эмпирически через v3.expense_item на тестовой БД: + # depth=2 для 24, depth=3 для 150/314/779/95). + for row in rows: + if row["row_type"] in ("ITEM", "SUB_ITEM") and row["data"]["header"].get("expense_item_id") == expense_item_id: + return row + raise AssertionError( + f"ITEM/SUB_ITEM expense_item_id={expense_item_id} не найден на листе AHR: " + f"{[r for r in rows if r['row_type'] != 'INPUT']}" + ) + + @pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) def test_satellite_line_add_does_not_capture_existing_manual_line( client, admin_tokens, auth_headers, sheet @@ -103,7 +122,12 @@ def test_satellite_line_add_does_not_capture_existing_manual_line( """Основной сценарий бага: у статьи уже есть ручная строка (и больше ни одной), затем на лист-сателлит добавляется запись. Ручная строка не должна быть переименована/перезаписана/привязана — запись должна - получить свою собственную строку АХР.""" + получить свою собственную строку АХР. + + AURORA-1887: эта собственная строка синка сателлита теперь скрыта на + самом листе AHR (не выводится отдельной INPUT-строкой) — проверяем её + через лист-источник (там ничего не прячем) и явно убеждаемся, что на AHR + её действительно больше нет.""" expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet] form_id = _create_form2(client, admin_tokens, auth_headers, year=2030) @@ -132,9 +156,20 @@ def test_satellite_line_add_does_not_capture_existing_manual_line( assert manual_row["data"]["header"]["name"] == "моя строка" assert manual_row["data"]["plan"]["q1"] == 500 - # у записи сателлита — отдельная строка на листе AHR (строка синка) - satellite_row = _find_ahr_row_by_line_id(ahr_rows, satellite_line_id) - assert satellite_row["data"]["line_id"] == satellite_line_id + # у записи сателлита — отдельная строка синка, но она скрыта на самом + # листе AHR (AURORA-1887); проверяем на листе-источнике (там видно всё). + rent_rows = _get_sheet_rows(client, admin_tokens, auth_headers, form_id, sheet) + satellite_row = next( + (row for row in rent_rows if row["row_type"] == "INPUT" and row["data"].get("line_id") == satellite_line_id), + None, + ) + assert satellite_row is not None, rent_rows + + hidden_on_ahr = [ + row for row in ahr_rows + if row["row_type"] == "INPUT" and row["data"].get("line_id") == satellite_line_id + ] + assert hidden_on_ahr == [], hidden_on_ahr @pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) @@ -193,9 +228,17 @@ NO_LIMIT_EXPENSE_ITEM_ID = 95 def test_fresh_form_limit_rows_have_source_ahr_limit(client, admin_tokens, auth_headers): - """GET .../sheet/AHR: у лимитных строк (созданных синком на листе лимитов - при создании формы) header.source == 'AHR_LIMIT'; у строки сателлита - аренды (после POST .../sheet/AHR_RENT/line) — 'AHR_RENT'.""" + """До AURORA-1887 эта проверка читала header.source == 'AHR_LIMIT' / + 'AHR_RENT' напрямую с GET .../sheet/AHR. AURORA-1887 прячет эти + INPUT-строки с листа AHR по тому же признаку (source_sheet) — их больше + не видно там отдельными строками, поэтому header.source для них на этом + листе больше не наблюдаем (сам признак и его корректность продолжают + проверяться: test_limit_leaf_input_rows_hidden_on_ahr_but_header_sum_correct + и test_satellite_input_row_hidden_on_ahr_but_visible_on_source_sheet ниже + — через сумму на заголовке статьи и через лист-источник соответственно). + Здесь остаётся узкая регрессия по актуальному контракту: обе строки + (лимитная и строка сателлита аренды) не выводятся отдельными INPUT на + листе AHR.""" form_id = _create_form2(client, admin_tokens, auth_headers, year=2040) ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) @@ -205,16 +248,17 @@ def test_fresh_form_limit_rows_have_source_ahr_limit(client, admin_tokens, auth_ if row["row_type"] == "INPUT" and row["data"]["header"].get("expense_item_id") == MOBILE_EXPENSE_ITEM_ID ] - assert limit_input_rows, ahr_rows - for row in limit_input_rows: - assert row["data"]["header"]["source"] == "AHR_LIMIT", row + assert limit_input_rows == [], limit_input_rows satellite_data = _add_satellite_line( client, admin_tokens, auth_headers, form_id, "AHR_RENT", vsp_id=1 ) ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) - rent_row = _find_ahr_row_by_line_id(ahr_rows, satellite_data["line_id"]) - assert rent_row["data"]["header"]["source"] == "AHR_RENT" + rent_input_rows = [ + row for row in ahr_rows + if row["row_type"] == "INPUT" and row["data"].get("line_id") == satellite_data["line_id"] + ] + assert rent_input_rows == [], rent_input_rows def test_manual_line_on_limited_item_is_over_limit(client, admin_tokens, auth_headers): @@ -496,3 +540,223 @@ async def test_backfill_limit_lines_picks_min_id_per_name(): finally: await session.rollback() await engine.dispose() + + +# --- AURORA-1887: скрыть сателлитные INPUT-строки на сводном листе АХР и +# заблокировать «Добавить строку» для статей аренды/коммуналки/охраны ------- +# +# Упрощённый дизайн (согласован в чате 2026-10-31): НЕ меняем модель +# budget_line, НЕ трогаем уникальность в form2_ahr_satellite_line_source, НЕ +# делаем миграцию слияния строк, НЕ трогаем протокол WS, НЕ трогаем +# v3.v_svod2_rf. Только v3.v_form2_sheet_jsonb (0054): при p_sheet='AHR' +# скрывает INPUT-строки с budget_line.source_sheet IN ('AHR_LIMIT', +# 'AHR_RENT','AHR_UTILITY','AHR_SECURITY') и добавляет header.can_add_row. +# +# "Коммунальные услуги" (expense_item_id=24) — на глубине 2 дерева расходов +# (row_type='ITEM'), а не 3 (SUB_ITEM), как 150/314/779/95 — проверено +# эмпирически через v3.expense_item на тестовой БД; см. _find_ahr_header_row. + + +def _get_sheet_rows(client, admin_tokens, auth_headers, form_id, sheet) -> list[dict]: + response = client.get( + f"/api/v1/form/{form_id}/sheet/{sheet}", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + return response.json()["result"] + + +def test_limit_leaf_input_rows_hidden_on_ahr_but_header_sum_correct( + client, admin_tokens, auth_headers +): + """AURORA-1887, п.1 теста: статья с лимитом (Мобильная связь, + MOBILE_EXPENSE_ITEM_ID=150, 7 LEAF в limit_template) — на листе AHR НЕТ + отдельных INPUT-строк на каждый LEAF, но заголовок статьи (SUB_ITEM) + присутствует с правильной суммой. + + Сумма проверяется НЕ прямым SQL-подсчётом по v3.plan (сессия client + работает в незакоммиченной транзакции — отдельное соединение/engine её + не увидит, см. комментарий к test_backfill_* выше про отдельный engine + для ИМИТАЦИИ состояния до миграции, а не для чтения состояния чужой + незакоммиченной транзакции), а сравнением с независимым путём расчёта + той же величины: GET .../sheet/AHR_LIMIT считает amount_year каждого LEAF + напрямую через v3._form2_ahr_limit_amount (без деления на 1000); синк в + v3.plan родительской строки АХР делит ту же величину на 1000 + (ab765f0/0031/0052) — это и есть plan.limit_year статьи на листе AHR. + Совпадение с точностью до округления подтверждает, что сумма за + скрытыми строками считается корректно независимо от того, что сами + строки в выдаче отсутствуют. + """ + form_id = _create_form2(client, admin_tokens, auth_headers, year=2050) + + limit_rows = _get_sheet_rows(client, admin_tokens, auth_headers, form_id, "AHR_LIMIT") + mobile_leaves = [ + row for row in limit_rows + if row["row_type"] == "INPUT" and row["data"].get("expense_item_code") == "R061015039" + ] + assert len(mobile_leaves) == 7, mobile_leaves + + # Патчим qty_q1 двум LEAF произвольными (разными) значениями — остальные + # остаются нулевыми/NULL. + _patch_cell( + client, admin_tokens, auth_headers, form_id, "AHR_LIMIT", + mobile_leaves[0]["data"]["id"], "qty_q1", 5, + ) + _patch_cell( + client, admin_tokens, auth_headers, form_id, "AHR_LIMIT", + mobile_leaves[1]["data"]["id"], "qty_q1", 2, + ) + + limit_rows_after = _get_sheet_rows(client, admin_tokens, auth_headers, form_id, "AHR_LIMIT") + mobile_leaves_after = [ + row for row in limit_rows_after + if row["row_type"] == "INPUT" and row["data"].get("expense_item_code") == "R061015039" + ] + expected_limit_year = sum( + (row["data"].get("amount_year") or 0) for row in mobile_leaves_after + ) + assert expected_limit_year > 0, mobile_leaves_after + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + + # Нет ни одной отдельной INPUT-строки на статье лимита — все 7 LEAF + # скрыты. + mobile_input_rows = [ + row for row in ahr_rows + if row["row_type"] == "INPUT" and row["data"]["header"].get("expense_item_id") == MOBILE_EXPENSE_ITEM_ID + ] + assert mobile_input_rows == [], mobile_input_rows + + item_row = _find_ahr_header_row(ahr_rows, MOBILE_EXPENSE_ITEM_ID) + item_plan = item_row["data"]["plan"] + assert item_plan["limit_year"] == pytest.approx(expected_limit_year / 1000, abs=1e-6) + assert item_plan["over_limit_year"] == 0 + assert item_plan["year"] == pytest.approx(item_plan["limit_year"], abs=1e-6) + + +@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) +def test_satellite_input_row_hidden_on_ahr_but_visible_on_source_sheet( + client, admin_tokens, auth_headers, sheet +): + """AURORA-1887, п.2-3 теста: запись аренды/коммуналки/охраны (POST + .../sheet/{sheet}/line + PATCH plan.q1) не видна отдельной INPUT-строкой + на листе AHR, но сумма заголовка статьи корректна (п.2); на самом + листе-источнике запись видна как обычно — регрессия на «на + листах-источниках ничего не прячем» (п.3).""" + expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet] + form_id = _create_form2(client, admin_tokens, auth_headers, year=2051) + + satellite_data = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=1) + line_id = satellite_data["line_id"] + detail_id = satellite_data["id"] + + _patch_cell(client, admin_tokens, auth_headers, form_id, sheet, detail_id, "plan.q1", 777) + + # п.2: скрыта на AHR, сумма статьи верна (на свежей форме это единственная + # строка статьи — сумма год равна ровно патченному значению). + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + hidden_input_rows = [ + row for row in ahr_rows + if row["row_type"] == "INPUT" and row["data"].get("line_id") == line_id + ] + assert hidden_input_rows == [], hidden_input_rows + + header_row = _find_ahr_header_row(ahr_rows, expense_item_id) + assert header_row["data"]["plan"]["q1"] == 777 + assert header_row["data"]["plan"]["year"] == 777 + + # п.3: на листе-источнике запись видна как обычно (регрессия). + source_rows = _get_sheet_rows(client, admin_tokens, auth_headers, form_id, sheet) + source_row = next( + (row for row in source_rows if row["row_type"] == "INPUT" and row["data"].get("id") == detail_id), + None, + ) + assert source_row is not None, source_rows + assert source_row["data"]["plan"]["q1"] == 777 + + +def test_manual_overlimit_line_visible_while_limit_input_rows_stay_hidden( + client, admin_tokens, auth_headers +): + """AURORA-1887, п.4 теста: ручная строка на лимитной статье (как в + AURORA-1907) остаётся видимой на GET AHR с header.source='MANUAL', + is_over_limit=true — регрессия на «AURORA-1907 не трогаем» — одновременно + с тем, что лимитные (source_sheet='AHR_LIMIT') INPUT-строки той же статьи + остаются скрытыми.""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2052) + + manual_line_id = _add_manual_ahr_line( + client, admin_tokens, auth_headers, form_id, MOBILE_EXPENSE_ITEM_ID + ) + _patch_ahr_cell( + client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 1000 + ) + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + + manual_row = _find_ahr_row_by_line_id(ahr_rows, manual_line_id) + assert manual_row["data"]["header"]["source"] == "MANUAL" + assert manual_row["data"]["header"]["is_over_limit"] is True + assert manual_row["data"]["plan"]["q1"] == 1000 + + limit_input_rows = [ + row for row in ahr_rows + if row["row_type"] == "INPUT" + and row["data"]["header"].get("expense_item_id") == MOBILE_EXPENSE_ITEM_ID + and row["data"]["header"].get("source") == "AHR_LIMIT" + ] + assert limit_input_rows == [], limit_input_rows + + item_row = _find_ahr_header_row(ahr_rows, MOBILE_EXPENSE_ITEM_ID) + item_plan = item_row["data"]["plan"] + assert item_plan["over_limit_year"] == 1000 + assert item_plan["year"] == item_plan["limit_year"] + item_plan["over_limit_year"] + + +@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) +def test_can_add_row_false_for_satellite_statja_on_ahr(client, admin_tokens, auth_headers, sheet): + """AURORA-1887, п.5 теста: header.can_add_row=false для статьи + аренды/коммуналки/охраны на листе AHR — блокировка «Добавить строку» + (механизм — backend-флаг, читает фронт перед вызовом API; см. PR).""" + expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet] + form_id = _create_form2(client, admin_tokens, auth_headers, year=2053) + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + header_row = _find_ahr_header_row(ahr_rows, expense_item_id) + assert header_row["data"]["header"]["can_add_row"] is False, header_row + + +def test_can_add_row_true_for_limit_and_ordinary_statja_on_ahr(client, admin_tokens, auth_headers): + """AURORA-1887: can_add_row=true для лимитной статьи (AURORA-1907 «сверх + лимита» не блокируем) и для обычной статьи без привязки к сателлитам — + регрессия (п.6 теста, часть 1: флаг читается верно до самой попытки + добавления).""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2054) + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + + limit_header_row = _find_ahr_header_row(ahr_rows, MOBILE_EXPENSE_ITEM_ID) + assert limit_header_row["data"]["header"]["can_add_row"] is True, limit_header_row + + ordinary_header_row = _find_ahr_header_row(ahr_rows, NO_LIMIT_EXPENSE_ITEM_ID) + assert ordinary_header_row["data"]["header"]["can_add_row"] is True, ordinary_header_row + + +def test_manual_line_add_still_works_for_ordinary_statja_on_ahr(client, admin_tokens, auth_headers): + """AURORA-1887, п.6 теста, часть 2: попытка добавить строку на обычной + статье без привязки к сателлитам (не лимит, не аренда/коммуналка/охрана) + — работает как раньше (регрессия, сквозной сценарий POST+PATCH+GET).""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2055) + + manual_line_id = _add_manual_ahr_line( + client, admin_tokens, auth_headers, form_id, NO_LIMIT_EXPENSE_ITEM_ID + ) + _patch_ahr_cell( + client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 500 + ) + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + manual_row = _find_ahr_row_by_line_id(ahr_rows, manual_line_id) + assert manual_row["data"]["header"]["source"] == "MANUAL" + assert manual_row["data"]["header"]["can_add_row"] is True + assert manual_row["data"]["plan"]["q1"] == 500 diff --git a/api/tests/integration/test_form2_ahr_satellite_spod_integration.py b/api/tests/integration/test_form2_ahr_satellite_spod_integration.py index f3d5186..99a867b 100644 --- a/api/tests/integration/test_form2_ahr_satellite_spod_integration.py +++ b/api/tests/integration/test_form2_ahr_satellite_spod_integration.py @@ -73,6 +73,19 @@ def _find_row(rows: list[dict], predicate) -> dict: raise AssertionError("строка не найдена") +def _find_ahr_header_row(rows: list[dict], expense_item_id: int) -> dict: + # AURORA-1887: INPUT-строка записи сателлита на листе AHR теперь скрыта + # (не выводится отдельной строкой) — заголовок статьи (ITEM/SUB_ITEM) + # остаётся видимым и несёт ту же агрегированную q4/totals-арифметику. + for row in rows: + if row.get("row_type") not in ("ITEM", "SUB_ITEM", "SUB_SUB_ITEM"): + continue + data = row.get("data") or {} + if (data.get("header") or {}).get("expense_item_id") == expense_item_id: + return row + raise AssertionError(f"заголовок статьи expense_item_id={expense_item_id} не найден: {rows}") + + @pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"]) def test_satellite_spod_written_and_included_in_q4_total( client, admin_tokens, form2_id, sheet @@ -102,15 +115,17 @@ def test_satellite_spod_written_and_included_in_q4_total( def test_satellite_spod_syncs_into_ahr_support_quarter( client, admin_tokens, form2_id, sheet ): + """AURORA-1887: INPUT-строка записи сателлита на AHR теперь скрыта — + сравниваем приращение на заголовке статьи (ITEM/SUB_ITEM), а не + абсолютные значения (на статье уже могут быть другие записи/лимитные + строки из сидовых данных, тоже дающие вклад в actual_spod/fact_year).""" line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet) + expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet] ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row_before = _find_row( - ahr_rows, - lambda d: d.get("header", {}).get("expense_item_id") - == SATELLITE_PARENT_EXPENSE_ITEM[sheet], - ) + ahr_row_before = _find_ahr_header_row(ahr_rows, expense_item_id) actual_quarter_before = ahr_row_before["data"]["q4"]["actual_quarter"] or 0 + actual_spod_before = ahr_row_before["data"]["q4"]["actual_spod"] or 0 fact_year_before = ahr_row_before["data"]["totals"]["fact_year"] or 0 response = client.patch( @@ -121,12 +136,8 @@ def test_satellite_spod_syncs_into_ahr_support_quarter( assert response.status_code == 200, response.text ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row_after = _find_row( - ahr_rows, - lambda d: d.get("header", {}).get("expense_item_id") - == SATELLITE_PARENT_EXPENSE_ITEM[sheet], - ) - assert ahr_row_after["data"]["q4"]["actual_spod"] == 5 + ahr_row_after = _find_ahr_header_row(ahr_rows, expense_item_id) + assert (ahr_row_after["data"]["q4"]["actual_spod"] or 0) - actual_spod_before == 5 assert ahr_row_after["data"]["q4"]["actual_quarter"] == actual_quarter_before + 5 assert ahr_row_after["data"]["totals"]["fact_year"] == fact_year_before + 5 diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 539e4b3..185c47d 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -18,19 +18,19 @@ import { SelectVspModal } from './Modals/SelectVspModal'; import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig'; import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles } from './constants/tableConfig'; import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext'; +import { useBudgetYear } from './hooks/useBudgetYear'; import { useColumnSettings } from './hooks/useColumnSettings'; import { useHeaderPortal } from './hooks/useHeaderPortal'; import { useTableScale } from './hooks/useTableScale'; -import { useBudgetYear } from './hooks/useBudgetYear'; import { useValidationRules } from './hooks/useValidationRules'; -import { getRowId } from './utils/rowUtils'; import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils'; +import { canAddRowForHeader, getRowId } from './utils/rowUtils'; import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; import { ROLES_NAME_ID } from '../../constants/constants'; -import { FormsNoCanAddRow } from './constants/formConfig'; import useDenomination from '../common/useDenomination'; +import { FormsNoCanAddRow } from './constants/formConfig'; const CONFIG_CACHE = new Map(); @@ -270,7 +270,16 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { console.error('Error creating columns:', error); return []; } - }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination]); + }, [ + columnsConfig, + columnsCurStage, + userRoleId, + handleCellUpdateError, + handleClickRowCell, + handleUpdateCell, + isCellInvalid, + denomination, + ]); // MRT запоминает индексы pinned-колонок до завершения асинхронной // загрузки конфигурации. Новая ссылка на columnPinning заставляет @@ -545,7 +554,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { console.log(match.columnId); handleNavigateToColumn(match.columnId); requestAnimationFrame(() => { - rowVirtualizerRef.current?.scrollToIndex?.(match.rowIndex < 5 ? match.rowIndex : match.rowIndex + 5, { align: 'auto', behavior: 'smooth' }); + rowVirtualizerRef.current?.scrollToIndex?.(match.rowIndex < 5 ? match.rowIndex : match.rowIndex + 5, { + align: 'auto', + behavior: 'smooth', + }); }); return nextIndex; @@ -614,6 +626,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { toast.error('Выделите строку для вставки'); return; } + if (!canAddRowForHeader(row.original.data.header)) { + toast.error('Добавление строк недоступно для этой статьи'); + return; + } contextAddRow({ expense_item_id: row.original.data.header.expense_item_id }); }, [contextAddRow, getSelectedRow]); diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js index af5efcb..a70236e 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.js @@ -52,3 +52,13 @@ export function isVspNewRow(rowData) { export function getParentId(data) { return data?.project_id || data?.expense_item_id || null; } + +// AURORA-1887: лист AHR отдаёт header.can_add_row=false для статей +// аренды/коммуналки/охраны (у записи сателлита нет понятия "сверх лимита" — +// ручная строка там не имеет смысла). На остальных листах и для остальных +// статей (включая лимитные — AURORA-1907 "сверх лимита" не трогаем) поле +// либо true, либо вовсе отсутствует/null — по умолчанию добавление строки +// разрешено. +export function canAddRowForHeader(header) { + return header?.can_add_row !== false; +} diff --git a/web/src/components/RealtimeTable/utils/rowUtils.test.js b/web/src/components/RealtimeTable/utils/rowUtils.test.js index 0c40434..021463c 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.test.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildRowHierarchy } from './rowUtils.js'; +import { buildRowHierarchy, canAddRowForHeader } from './rowUtils.js'; test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => { const rows = [ @@ -19,3 +19,15 @@ test('row hierarchy uses row types when depth skips a level and keeps INPUT rows assert.deepEqual(tree[0].subRows[0].subRows.map(({ id }) => id), ['input-1', 'input-2']); assert.deepEqual(tree[0].subRows[0].subRows[0].subRows, []); }); + +// AURORA-1887: лист AHR блокирует «Добавить строку» для статей +// аренды/коммуналки/охраны через header.can_add_row=false; для всех прочих +// статей (включая лимитные, не трогаем AURORA-1907) и для листов, где это +// поле вовсе не считается (не AHR), добавление остаётся разрешённым. +test('canAddRowForHeader blocks only an explicit can_add_row=false', () => { + assert.equal(canAddRowForHeader({ can_add_row: false }), false); + assert.equal(canAddRowForHeader({ can_add_row: true }), true); + assert.equal(canAddRowForHeader({ can_add_row: null }), true); + assert.equal(canAddRowForHeader({}), true); + assert.equal(canAddRowForHeader(undefined), true); +});