feat(form2): скрыть сателлитные строки на листе АХР и заблокировать добавление для аренды/коммуналки/охраны (AURORA-1887)

Упрощённый дизайн (согласован в чате 2026-10-31, расходится с архитектурным
предложением из тикета): без слияния budget_line, без миграции данных, без
изменения протокола WS, без изменения 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'); ручные строки (включая «сверх лимита», AURORA-1907)
  остаются видимыми. Листы-источники не затронуты. Суммы на заголовках статей
  считаются независимо от видимости строк — не меняются.
- header.can_add_row (новое поле, только p_sheet='AHR'): false для статей
  аренды/коммуналки/охраны (v3._form2_satellite_expense_item, новый helper),
  true для лимитных и обычных статей. Фронт (RealtimeTable.jsx handleAddRow,
  utils/rowUtils.js canAddRowForHeader) блокирует «Добавить строку» по этому
  флагу — тот же паттерн, что у FormsNoCanAddRow/isVspAdditionRow (без
  серверной валидации в add_budget_line).
- Обновлены тесты, которые проверяли суммы/источник через теперь скрытые
  INPUT-строки на AHR (test_form2_ahr_limit.py,
  test_form2_ahr_satellite_spod_integration.py,
  test_form2_ahr_satellite_integration.py) — сравнение переведено на
  заголовок статьи (до/после патча) и на лист-источник.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-31 21:59:04 +04:00
parent fe9dfbc7e1
commit 234103606b
8 changed files with 865 additions and 74 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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