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}")
|
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:
|
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("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":
|
|
||||||
continue
|
continue
|
||||||
data = row.get("data") or {}
|
data = row.get("data") or {}
|
||||||
if (data.get("header") or {}).get("expense_item_id") == expense_item_id:
|
if (data.get("header") or {}).get("expense_item_id") == expense_item_id:
|
||||||
return row
|
return row
|
||||||
raise AssertionError(
|
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):
|
def test_annual_dms_limit_divided_by_four(client, admin_tokens, form2_id):
|
||||||
# id=24 «- стандарт РФ»: ДМС, лимит 27858 руб/год без НДС, period=YEAR —
|
# id=24 «- стандарт РФ»: ДМС (expense_item_id=456), лимит 27858 руб/год
|
||||||
# 10 * 27858 / 4 = 69645 руб. в заполненный квартал (а не дублирование
|
# без НДС, period=YEAR — 10 * 27858 / 4 = 69645 руб. в заполненный
|
||||||
# годовой суммы 278580, как было до миграции).
|
# квартал (а не дублирование годовой суммы 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)
|
rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 24, "qty_q1", 10)
|
||||||
row = _find_by_id(rows, 24)
|
row = _find_by_id(rows, 24)
|
||||||
assert row["data"]["amount_q1"] == 69645
|
assert row["data"]["amount_q1"] == 69645
|
||||||
assert row["data"]["amount_year"] == 69645
|
assert row["data"]["amount_year"] == 69645
|
||||||
|
|
||||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
after = _find_ahr_header_row(
|
||||||
ahr_row = _find_ahr_row_by_name(ahr_rows, "стандарт РФ")
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 456
|
||||||
assert ahr_row["data"]["plan"]["q1"] == pytest.approx(69.645)
|
)["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):
|
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)
|
rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 3, "qty_q2", 1)
|
||||||
row = _find_by_id(rows, 3)
|
row = _find_by_id(rows, 3)
|
||||||
assert row["data"]["amount_q2"] == 2000
|
assert row["data"]["amount_q2"] == 2000
|
||||||
|
|
||||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
after = _find_ahr_header_row(
|
||||||
ahr_row = _find_ahr_row_by_name(ahr_rows, "Директор")
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150
|
||||||
assert ahr_row["data"]["plan"]["q2"] == pytest.approx(2.0)
|
)["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):
|
def test_sim_card_without_vat_limit_backfilled_is_not_zero(client, admin_tokens, form2_id):
|
||||||
# id=7 «- SIM-карта для планшета (легкий фронт)»: limit_without_vat был
|
# id=7 «- SIM-карта для планшета (легкий фронт)» (expense_item_id=150,
|
||||||
# NULL до миграции — она заполнила его 375 (450 / 1.2). Сумма не должна
|
# «Мобильная связь»): limit_without_vat был NULL до миграции — она
|
||||||
# стать 0 после перехода на расчёт без НДС.
|
# заполнила его 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)
|
rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 7, "qty_q1", 20)
|
||||||
row = _find_by_id(rows, 7)
|
row = _find_by_id(rows, 7)
|
||||||
assert row["data"]["amount_q1"] == 7500
|
assert row["data"]["amount_q1"] == 7500
|
||||||
|
|
||||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
after = _find_ahr_header_row(
|
||||||
ahr_row = _find_ahr_row_by_name(ahr_rows, "SIM-карта для планшета (легкий фронт)")
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150
|
||||||
assert ahr_row["data"]["plan"]["q1"] == pytest.approx(7.5)
|
)["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):
|
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,
|
# (qty * 0.57 * 10900 / 4), теперь — общей формулой через period=YEAR,
|
||||||
# limit_without_vat=5177.5 (миграция переписала справочник).
|
# 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)
|
rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 13, "qty_q1", 5)
|
||||||
row = _find_by_id(rows, 13)
|
row = _find_by_id(rows, 13)
|
||||||
assert row["data"]["amount_q1"] == pytest.approx(6471.875)
|
assert row["data"]["amount_q1"] == pytest.approx(6471.875)
|
||||||
|
|
||||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
after = _find_ahr_header_row(
|
||||||
ahr_row = _find_ahr_row_by_name(ahr_rows, "принтеры")
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 167
|
||||||
assert ahr_row["data"]["plan"]["q1"] == pytest.approx(6.471875)
|
)["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):
|
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 по умолчанию) считается как раньше — поменялся только
|
# остаётся 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)
|
rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 17, "qty_q3", 2)
|
||||||
row = _find_by_id(rows, 17)
|
row = _find_by_id(rows, 17)
|
||||||
assert row["data"]["amount_q3"] == 7500
|
assert row["data"]["amount_q3"] == 7500
|
||||||
|
|
||||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
after = _find_ahr_header_row(
|
||||||
ahr_row = _find_ahr_row_by_name(ahr_rows, "счетчики/сортировщики банкнот")
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 179
|
||||||
assert ahr_row["data"]["plan"]["q3"] == pytest.approx(7.5)
|
)["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):
|
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):
|
def test_rent_plan_still_passed_through_without_division(client, admin_tokens, form2_id):
|
||||||
# Регрессия: AURORA-1897 не должна задевать перенос аренды/коммуналки/
|
# Регрессия: 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(
|
response = client.post(
|
||||||
f"/api/v1/form/{form2_id}/sheet/AHR_RENT/line",
|
f"/api/v1/form/{form2_id}/sheet/AHR_RENT/line",
|
||||||
json={"vsp_id": 1},
|
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
|
assert response.status_code == 200, response.text
|
||||||
|
|
||||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
after = _find_ahr_header_row(
|
||||||
ahr_row = _find_ahr_row_by_expense_item(ahr_rows, AHR_RENT_PARENT_EXPENSE_ITEM)
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), AHR_RENT_PARENT_EXPENSE_ITEM
|
||||||
assert ahr_row["data"]["plan"]["q1"] == 100
|
)["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"]
|
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(
|
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},
|
json={"line_id": line_id, "column": column, "value": value},
|
||||||
headers=auth_headers(admin_tokens),
|
headers=auth_headers(admin_tokens),
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, response.text
|
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:
|
def _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id) -> dict:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1/form/{form_id}/sheet/{sheet}/line",
|
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']}")
|
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))
|
@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM))
|
||||||
def test_satellite_line_add_does_not_capture_existing_manual_line(
|
def test_satellite_line_add_does_not_capture_existing_manual_line(
|
||||||
client, admin_tokens, auth_headers, sheet
|
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]
|
expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet]
|
||||||
form_id = _create_form2(client, admin_tokens, auth_headers, year=2030)
|
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"]["header"]["name"] == "моя строка"
|
||||||
assert manual_row["data"]["plan"]["q1"] == 500
|
assert manual_row["data"]["plan"]["q1"] == 500
|
||||||
|
|
||||||
# у записи сателлита — отдельная строка на листе AHR (строка синка)
|
# у записи сателлита — отдельная строка синка, но она скрыта на самом
|
||||||
satellite_row = _find_ahr_row_by_line_id(ahr_rows, satellite_line_id)
|
# листе AHR (AURORA-1887); проверяем на листе-источнике (там видно всё).
|
||||||
assert satellite_row["data"]["line_id"] == satellite_line_id
|
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))
|
@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):
|
def test_fresh_form_limit_rows_have_source_ahr_limit(client, admin_tokens, auth_headers):
|
||||||
"""GET .../sheet/AHR: у лимитных строк (созданных синком на листе лимитов
|
"""До AURORA-1887 эта проверка читала header.source == 'AHR_LIMIT' /
|
||||||
при создании формы) header.source == 'AHR_LIMIT'; у строки сателлита
|
'AHR_RENT' напрямую с GET .../sheet/AHR. AURORA-1887 прячет эти
|
||||||
аренды (после POST .../sheet/AHR_RENT/line) — 'AHR_RENT'."""
|
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)
|
form_id = _create_form2(client, admin_tokens, auth_headers, year=2040)
|
||||||
|
|
||||||
ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id)
|
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"
|
if row["row_type"] == "INPUT"
|
||||||
and row["data"]["header"].get("expense_item_id") == MOBILE_EXPENSE_ITEM_ID
|
and row["data"]["header"].get("expense_item_id") == MOBILE_EXPENSE_ITEM_ID
|
||||||
]
|
]
|
||||||
assert limit_input_rows, ahr_rows
|
assert limit_input_rows == [], limit_input_rows
|
||||||
for row in limit_input_rows:
|
|
||||||
assert row["data"]["header"]["source"] == "AHR_LIMIT", row
|
|
||||||
|
|
||||||
satellite_data = _add_satellite_line(
|
satellite_data = _add_satellite_line(
|
||||||
client, admin_tokens, auth_headers, form_id, "AHR_RENT", vsp_id=1
|
client, admin_tokens, auth_headers, form_id, "AHR_RENT", vsp_id=1
|
||||||
)
|
)
|
||||||
ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id)
|
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"])
|
rent_input_rows = [
|
||||||
assert rent_row["data"]["header"]["source"] == "AHR_RENT"
|
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):
|
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:
|
finally:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
await engine.dispose()
|
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("строка не найдена")
|
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"])
|
@pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"])
|
||||||
def test_satellite_spod_written_and_included_in_q4_total(
|
def test_satellite_spod_written_and_included_in_q4_total(
|
||||||
client, admin_tokens, form2_id, sheet
|
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(
|
def test_satellite_spod_syncs_into_ahr_support_quarter(
|
||||||
client, admin_tokens, form2_id, sheet
|
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)
|
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_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
||||||
ahr_row_before = _find_row(
|
ahr_row_before = _find_ahr_header_row(ahr_rows, expense_item_id)
|
||||||
ahr_rows,
|
|
||||||
lambda d: d.get("header", {}).get("expense_item_id")
|
|
||||||
== SATELLITE_PARENT_EXPENSE_ITEM[sheet],
|
|
||||||
)
|
|
||||||
actual_quarter_before = ahr_row_before["data"]["q4"]["actual_quarter"] or 0
|
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
|
fact_year_before = ahr_row_before["data"]["totals"]["fact_year"] or 0
|
||||||
|
|
||||||
response = client.patch(
|
response = client.patch(
|
||||||
@ -121,12 +136,8 @@ def test_satellite_spod_syncs_into_ahr_support_quarter(
|
|||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
|
|
||||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
||||||
ahr_row_after = _find_row(
|
ahr_row_after = _find_ahr_header_row(ahr_rows, expense_item_id)
|
||||||
ahr_rows,
|
assert (ahr_row_after["data"]["q4"]["actual_spod"] or 0) - actual_spod_before == 5
|
||||||
lambda d: d.get("header", {}).get("expense_item_id")
|
|
||||||
== SATELLITE_PARENT_EXPENSE_ITEM[sheet],
|
|
||||||
)
|
|
||||||
assert ahr_row_after["data"]["q4"]["actual_spod"] == 5
|
|
||||||
assert ahr_row_after["data"]["q4"]["actual_quarter"] == actual_quarter_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
|
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 { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
||||||
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles } from './constants/tableConfig';
|
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles } from './constants/tableConfig';
|
||||||
import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
|
import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
|
||||||
|
import { useBudgetYear } from './hooks/useBudgetYear';
|
||||||
import { useColumnSettings } from './hooks/useColumnSettings';
|
import { useColumnSettings } from './hooks/useColumnSettings';
|
||||||
import { useHeaderPortal } from './hooks/useHeaderPortal';
|
import { useHeaderPortal } from './hooks/useHeaderPortal';
|
||||||
import { useTableScale } from './hooks/useTableScale';
|
import { useTableScale } from './hooks/useTableScale';
|
||||||
import { useBudgetYear } from './hooks/useBudgetYear';
|
|
||||||
import { useValidationRules } from './hooks/useValidationRules';
|
import { useValidationRules } from './hooks/useValidationRules';
|
||||||
import { getRowId } from './utils/rowUtils';
|
|
||||||
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
|
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
|
||||||
|
import { canAddRowForHeader, getRowId } from './utils/rowUtils';
|
||||||
|
|
||||||
import { DictVspApi } from '../../api/dict-vsp';
|
import { DictVspApi } from '../../api/dict-vsp';
|
||||||
import { useAuth } from '../../app/context/AuthProvider';
|
import { useAuth } from '../../app/context/AuthProvider';
|
||||||
import { ROLES_NAME_ID } from '../../constants/constants';
|
import { ROLES_NAME_ID } from '../../constants/constants';
|
||||||
import { FormsNoCanAddRow } from './constants/formConfig';
|
|
||||||
import useDenomination from '../common/useDenomination';
|
import useDenomination from '../common/useDenomination';
|
||||||
|
import { FormsNoCanAddRow } from './constants/formConfig';
|
||||||
|
|
||||||
const CONFIG_CACHE = new Map();
|
const CONFIG_CACHE = new Map();
|
||||||
|
|
||||||
@ -270,7 +270,16 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
console.error('Error creating columns:', error);
|
console.error('Error creating columns:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination]);
|
}, [
|
||||||
|
columnsConfig,
|
||||||
|
columnsCurStage,
|
||||||
|
userRoleId,
|
||||||
|
handleCellUpdateError,
|
||||||
|
handleClickRowCell,
|
||||||
|
handleUpdateCell,
|
||||||
|
isCellInvalid,
|
||||||
|
denomination,
|
||||||
|
]);
|
||||||
|
|
||||||
// MRT запоминает индексы pinned-колонок до завершения асинхронной
|
// MRT запоминает индексы pinned-колонок до завершения асинхронной
|
||||||
// загрузки конфигурации. Новая ссылка на columnPinning заставляет
|
// загрузки конфигурации. Новая ссылка на columnPinning заставляет
|
||||||
@ -545,7 +554,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
console.log(match.columnId);
|
console.log(match.columnId);
|
||||||
handleNavigateToColumn(match.columnId);
|
handleNavigateToColumn(match.columnId);
|
||||||
requestAnimationFrame(() => {
|
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;
|
return nextIndex;
|
||||||
@ -614,6 +626,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
toast.error('Выделите строку для вставки');
|
toast.error('Выделите строку для вставки');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canAddRowForHeader(row.original.data.header)) {
|
||||||
|
toast.error('Добавление строк недоступно для этой статьи');
|
||||||
|
return;
|
||||||
|
}
|
||||||
contextAddRow({ expense_item_id: row.original.data.header.expense_item_id });
|
contextAddRow({ expense_item_id: row.original.data.header.expense_item_id });
|
||||||
}, [contextAddRow, getSelectedRow]);
|
}, [contextAddRow, getSelectedRow]);
|
||||||
|
|
||||||
|
|||||||
@ -52,3 +52,13 @@ export function isVspNewRow(rowData) {
|
|||||||
export function getParentId(data) {
|
export function getParentId(data) {
|
||||||
return data?.project_id || data?.expense_item_id || null;
|
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 assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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', () => {
|
test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => {
|
||||||
const rows = [
|
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.map(({ id }) => id), ['input-1', 'input-2']);
|
||||||
assert.deepEqual(tree[0].subRows[0].subRows[0].subRows, []);
|
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