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:
parent
fe9dfbc7e1
commit
234103606b
15
api/alembic/versions/0054_form2_ahr_hide_satellite_rows.py
Normal file
15
api/alembic/versions/0054_form2_ahr_hide_satellite_rows.py
Normal 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
|
||||
418
api/alembic/versions/sql/0054_form2_ahr_hide_satellite_rows.sql
Normal file
418
api/alembic/versions/sql/0054_form2_ahr_hide_satellite_rows.sql
Normal 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$
|
||||
;
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user