Merge remote-tracking branch 'origin/test' into feat/276-aurora-1892-negative-amounts
This commit is contained in:
commit
f61647d16c
15
api/alembic/versions/0058_form2_ahr_delta_depth4.py
Normal file
15
api/alembic/versions/0058_form2_ahr_delta_depth4.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from sql_migration import run_sql_file_migration
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0058"
|
||||||
|
down_revision = "0057"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
run_sql_file_migration("0058_form2_ahr_delta_depth4.sql")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
86
api/alembic/versions/sql/0058_form2_ahr_delta_depth4.sql
Normal file
86
api/alembic/versions/sql/0058_form2_ahr_delta_depth4.sql
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
-- AURORA-1850 / issue #296: дельта АХР после правки лимита пуста для статей
|
||||||
|
-- глубины 4 (принтеры / МФУ / копировальные аппараты): открытый АХР не
|
||||||
|
-- обновляет сумму до перезагрузки.
|
||||||
|
--
|
||||||
|
-- Причина: 0053 пересоздала v3.get_form2_ahr_satellite_delta по телу из 0031 —
|
||||||
|
-- с сопоставлением лимитного шаблона «ei.depth = 3 AND (item_id = code OR
|
||||||
|
-- num_group_id = code)», то есть откатила правку 0037. По 0037 (и как во всех
|
||||||
|
-- остальных функциях синхронизации, в том числе в 0052/0053) по item_id
|
||||||
|
-- сопоставляется только статья глубины 3, а по num_group_id — статья любой
|
||||||
|
-- глубины. Лимиты с кодом num_group_id статьи глубины 4 в target_lines не
|
||||||
|
-- попадали, и дельта была пустой.
|
||||||
|
--
|
||||||
|
-- Тело — из 0053 (с SUB_SUB_ITEM в заголовках, issue #294) + матчер 0037.
|
||||||
|
-- Позже 0053 функцию никто не переопределял (проверено по 0054–0057).
|
||||||
|
CREATE OR REPLACE FUNCTION v3.get_form2_ahr_satellite_delta(
|
||||||
|
p_form_id integer,
|
||||||
|
p_source_sheet character varying,
|
||||||
|
p_source_row_id integer
|
||||||
|
)
|
||||||
|
RETURNS TABLE(
|
||||||
|
row_type character varying,
|
||||||
|
depth integer,
|
||||||
|
sort_order bigint,
|
||||||
|
data jsonb
|
||||||
|
)
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $function$
|
||||||
|
WITH target_lines AS (
|
||||||
|
SELECT m.target_line_id AS line_id
|
||||||
|
FROM v3.form2_ahr_satellite_line_source m
|
||||||
|
JOIN v3.budget_line bl ON bl.id = m.target_line_id
|
||||||
|
WHERE m.source_sheet = p_source_sheet
|
||||||
|
AND m.source_id = p_source_row_id
|
||||||
|
AND bl.budget_form_id = p_form_id
|
||||||
|
AND p_source_sheet IN ('AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY')
|
||||||
|
|
||||||
|
UNION
|
||||||
|
|
||||||
|
SELECT bl.id AS line_id
|
||||||
|
FROM v3.limit_template lt
|
||||||
|
JOIN v3.expense_item ei
|
||||||
|
ON ei.sheet = 'AHR'
|
||||||
|
AND ei.direction = 'Support'
|
||||||
|
AND (
|
||||||
|
(ei.depth = 3 AND ei.item_id = lt.expense_item_code)
|
||||||
|
OR ei.num_group_id = lt.expense_item_code
|
||||||
|
)
|
||||||
|
JOIN v3.budget_line bl
|
||||||
|
ON bl.budget_form_id = p_form_id
|
||||||
|
AND bl.expense_item_id = ei.id
|
||||||
|
AND bl.source_sheet = 'AHR_LIMIT'
|
||||||
|
AND bl.name = regexp_replace(
|
||||||
|
lt.name,
|
||||||
|
'^[[:space:]]*-[[:space:]]*',
|
||||||
|
''
|
||||||
|
)
|
||||||
|
WHERE p_source_sheet = 'AHR_LIMIT'
|
||||||
|
AND lt.id = p_source_row_id
|
||||||
|
AND lt.row_type = 'LEAF'
|
||||||
|
),
|
||||||
|
target_path AS (
|
||||||
|
SELECT unnest(
|
||||||
|
v3._expense_item_path_set(
|
||||||
|
COALESCE(array_agg(bl.expense_item_id), ARRAY[]::integer[])
|
||||||
|
)
|
||||||
|
) AS expense_item_id
|
||||||
|
FROM target_lines tl
|
||||||
|
JOIN v3.budget_line bl ON bl.id = tl.line_id
|
||||||
|
)
|
||||||
|
SELECT v.row_type, v.depth, v.sort_order, v.data
|
||||||
|
FROM v3.v_form_view(p_form_id, 'AHR', NULL, 'Support') v
|
||||||
|
WHERE (
|
||||||
|
v.row_type IN ('ROOT', 'GROUP', 'ITEM', 'SUB_ITEM', 'SUB_SUB_ITEM')
|
||||||
|
AND (v.data->'header'->>'expense_item_id')::INT IN (
|
||||||
|
SELECT expense_item_id FROM target_path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
v.row_type = 'INPUT'
|
||||||
|
AND (v.data->>'line_id')::INT IN (
|
||||||
|
SELECT line_id FROM target_lines
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY v.sort_order;
|
||||||
|
$function$;
|
||||||
@ -104,7 +104,7 @@ def test_annual_dms_limit_divided_by_four(client, admin_tokens, form2_id):
|
|||||||
# лимитные LEAF из сидовых данных).
|
# лимитные LEAF из сидовых данных).
|
||||||
before = _find_ahr_header_row(
|
before = _find_ahr_header_row(
|
||||||
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 456
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 456
|
||||||
)["data"]["plan"]["q1"] or 0
|
)["data"]["plan"].get("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)
|
||||||
@ -113,7 +113,7 @@ def test_annual_dms_limit_divided_by_four(client, admin_tokens, form2_id):
|
|||||||
|
|
||||||
after = _find_ahr_header_row(
|
after = _find_ahr_header_row(
|
||||||
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 456
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 456
|
||||||
)["data"]["plan"]["q1"] or 0
|
)["data"]["plan"].get("q1") or 0
|
||||||
assert (after - before) == pytest.approx(69.645)
|
assert (after - before) == pytest.approx(69.645)
|
||||||
|
|
||||||
|
|
||||||
@ -124,7 +124,7 @@ def test_quarterly_limit_uses_amount_without_vat(client, admin_tokens, form2_id)
|
|||||||
# статьи — см. комментарий в test_annual_dms_limit_divided_by_four.
|
# статьи — см. комментарий в test_annual_dms_limit_divided_by_four.
|
||||||
before = _find_ahr_header_row(
|
before = _find_ahr_header_row(
|
||||||
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150
|
||||||
)["data"]["plan"]["q2"] or 0
|
)["data"]["plan"].get("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)
|
||||||
@ -132,7 +132,7 @@ def test_quarterly_limit_uses_amount_without_vat(client, admin_tokens, form2_id)
|
|||||||
|
|
||||||
after = _find_ahr_header_row(
|
after = _find_ahr_header_row(
|
||||||
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150
|
||||||
)["data"]["plan"]["q2"] or 0
|
)["data"]["plan"].get("q2") or 0
|
||||||
assert (after - before) == pytest.approx(2.0)
|
assert (after - before) == pytest.approx(2.0)
|
||||||
|
|
||||||
|
|
||||||
@ -144,7 +144,7 @@ def test_sim_card_without_vat_limit_backfilled_is_not_zero(client, admin_tokens,
|
|||||||
# статьи — см. комментарий в test_annual_dms_limit_divided_by_four.
|
# статьи — см. комментарий в test_annual_dms_limit_divided_by_four.
|
||||||
before = _find_ahr_header_row(
|
before = _find_ahr_header_row(
|
||||||
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150
|
||||||
)["data"]["plan"]["q1"] or 0
|
)["data"]["plan"].get("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)
|
||||||
@ -152,7 +152,7 @@ def test_sim_card_without_vat_limit_backfilled_is_not_zero(client, admin_tokens,
|
|||||||
|
|
||||||
after = _find_ahr_header_row(
|
after = _find_ahr_header_row(
|
||||||
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150
|
||||||
)["data"]["plan"]["q1"] or 0
|
)["data"]["plan"].get("q1") or 0
|
||||||
assert (after - before) == pytest.approx(7.5)
|
assert (after - before) == pytest.approx(7.5)
|
||||||
|
|
||||||
|
|
||||||
@ -165,7 +165,7 @@ def test_printers_annual_limit_without_name_lookup(client, admin_tokens, form2_i
|
|||||||
# комментарий в test_annual_dms_limit_divided_by_four.
|
# комментарий в test_annual_dms_limit_divided_by_four.
|
||||||
before = _find_ahr_header_row(
|
before = _find_ahr_header_row(
|
||||||
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 167
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 167
|
||||||
)["data"]["plan"]["q1"] or 0
|
)["data"]["plan"].get("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)
|
||||||
@ -173,7 +173,7 @@ def test_printers_annual_limit_without_name_lookup(client, admin_tokens, form2_i
|
|||||||
|
|
||||||
after = _find_ahr_header_row(
|
after = _find_ahr_header_row(
|
||||||
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 167
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 167
|
||||||
)["data"]["plan"]["q1"] or 0
|
)["data"]["plan"].get("q1") or 0
|
||||||
assert (after - before) == pytest.approx(6.471875)
|
assert (after - before) == pytest.approx(6.471875)
|
||||||
|
|
||||||
|
|
||||||
@ -185,7 +185,7 @@ def test_quarterly_norm_unchanged_other_than_vat_source(client, admin_tokens, fo
|
|||||||
# статьи — см. комментарий в test_annual_dms_limit_divided_by_four.
|
# статьи — см. комментарий в test_annual_dms_limit_divided_by_four.
|
||||||
before = _find_ahr_header_row(
|
before = _find_ahr_header_row(
|
||||||
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 179
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 179
|
||||||
)["data"]["plan"]["q3"] or 0
|
)["data"]["plan"].get("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)
|
||||||
@ -193,7 +193,7 @@ def test_quarterly_norm_unchanged_other_than_vat_source(client, admin_tokens, fo
|
|||||||
|
|
||||||
after = _find_ahr_header_row(
|
after = _find_ahr_header_row(
|
||||||
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 179
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 179
|
||||||
)["data"]["plan"]["q3"] or 0
|
)["data"]["plan"].get("q3") or 0
|
||||||
assert (after - before) == pytest.approx(7.5)
|
assert (after - before) == pytest.approx(7.5)
|
||||||
|
|
||||||
|
|
||||||
@ -232,7 +232,7 @@ def test_rent_plan_still_passed_through_without_division(client, admin_tokens, f
|
|||||||
# быть другие записи аренды из сидовых данных).
|
# быть другие записи аренды из сидовых данных).
|
||||||
before = _find_ahr_header_row(
|
before = _find_ahr_header_row(
|
||||||
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), AHR_RENT_PARENT_EXPENSE_ITEM
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), AHR_RENT_PARENT_EXPENSE_ITEM
|
||||||
)["data"]["plan"]["q1"] or 0
|
)["data"]["plan"].get("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",
|
||||||
@ -253,5 +253,5 @@ def test_rent_plan_still_passed_through_without_division(client, admin_tokens, f
|
|||||||
|
|
||||||
after = _find_ahr_header_row(
|
after = _find_ahr_header_row(
|
||||||
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), AHR_RENT_PARENT_EXPENSE_ITEM
|
_get_sheet_rows(client, admin_tokens, form2_id, "AHR"), AHR_RENT_PARENT_EXPENSE_ITEM
|
||||||
)["data"]["plan"]["q1"] or 0
|
)["data"]["plan"].get("q1") or 0
|
||||||
assert (after - before) == 100
|
assert (after - before) == 100
|
||||||
|
|||||||
@ -31,16 +31,23 @@ OLD_DEPTH3_MATCHER = re.compile(r"ei\.depth\s*=\s*3\s+AND\s*\(") # вид до
|
|||||||
|
|
||||||
def test_ahr_delta_keeps_0037_num_group_matching(client, headers, db_exec):
|
def test_ahr_delta_keeps_0037_num_group_matching(client, headers, db_exec):
|
||||||
found = db_exec(
|
found = db_exec(
|
||||||
"""SELECT lt.id FROM v3.limit_template lt
|
"""SELECT lt.id, ei.id FROM v3.limit_template lt
|
||||||
JOIN v3.expense_item ei ON ei.num_group_id = lt.expense_item_code
|
JOIN v3.expense_item ei ON ei.num_group_id = lt.expense_item_code
|
||||||
AND ei.sheet = 'AHR' AND ei.direction = 'Support' AND ei.depth = 4
|
AND ei.sheet = 'AHR' AND ei.direction = 'Support' AND ei.depth = 4
|
||||||
WHERE lt.row_type = 'LEAF' ORDER BY lt.id LIMIT 1""")
|
WHERE lt.row_type = 'LEAF' ORDER BY lt.id LIMIT 1""")
|
||||||
if not found:
|
if not found:
|
||||||
pytest.skip("нет LEAF шаблона с кодом = num_group_id статьи AHR глубины 4")
|
pytest.skip("нет LEAF шаблона с кодом = num_group_id статьи AHR глубины 4")
|
||||||
lt_id = found[0][0]
|
lt_id, ei_id = found[0]
|
||||||
_patch_cell(client, headers, 4, "AHR_LIMIT", lt_id, "qty_q1", 4)
|
_patch_cell(client, headers, 4, "AHR_LIMIT", lt_id, "qty_q1", 4)
|
||||||
delta = db_exec("SELECT row_type FROM v3.get_form2_ahr_satellite_delta(4, 'AHR_LIMIT', :i)", i=lt_id)
|
delta = db_exec(
|
||||||
assert any(r[0] == "INPUT" for r in delta), "дельта АХР пуста — откат сопоставления 0037"
|
"SELECT row_type, data->'header'->>'expense_item_id' FROM v3.get_form2_ahr_satellite_delta(4, 'AHR_LIMIT', :i)",
|
||||||
|
i=lt_id,
|
||||||
|
)
|
||||||
|
# AURORA-1887 скрыл лимитные INPUT-строки на листе AHR — в дельте остаются
|
||||||
|
# заголовки статьи и её предков; для статьи глубины 4 это SUB_SUB_ITEM.
|
||||||
|
assert any(r[0] == "SUB_SUB_ITEM" and int(r[1]) == ei_id for r in delta), (
|
||||||
|
f"в дельте АХР нет заголовка статьи глубины 4 — откат сопоставления 0037: {delta}"
|
||||||
|
)
|
||||||
src = db_exec("SELECT pg_get_functiondef(CAST(:s AS regprocedure))",
|
src = db_exec("SELECT pg_get_functiondef(CAST(:s AS regprocedure))",
|
||||||
s="v3.get_form2_ahr_satellite_delta(integer,character varying,integer)")[0][0]
|
s="v3.get_form2_ahr_satellite_delta(integer,character varying,integer)")[0][0]
|
||||||
assert "ei.num_group_id = lt.expense_item_code" in src
|
assert "ei.num_group_id = lt.expense_item_code" in src
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import { isCellEditable } from './utils/cellEditability';
|
|||||||
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
||||||
import { canAddRowForHeader, getRowId } from './utils/rowUtils';
|
import { canAddRowForHeader, getRowId } from './utils/rowUtils';
|
||||||
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
|
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
|
||||||
|
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
|
||||||
|
|
||||||
import { DictVspApi } from '../../api/dict-vsp';
|
import { DictVspApi } from '../../api/dict-vsp';
|
||||||
import { useAuth } from '../../app/context/AuthProvider';
|
import { useAuth } from '../../app/context/AuthProvider';
|
||||||
@ -78,6 +79,21 @@ const loadingOverlayStyle = {
|
|||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AURORA-1893 (возврат 06.11): после scrollToIndex строка появляется в DOM не сразу
|
||||||
|
// (виртуализатор перерисовывает диапазон по событию scroll) — ждём до нескольких кадров
|
||||||
|
// и прокручиваем к самой ячейке: scrollIntoView учитывает реальную раскладку, включая шапку.
|
||||||
|
const SCROLL_CELL_MAX_FRAMES = 10;
|
||||||
|
const scrollCellIntoViewWhenRendered = (container, rowId, columnId, framesLeft = SCROLL_CELL_MAX_FRAMES) => {
|
||||||
|
if (!container || framesLeft <= 0) return;
|
||||||
|
const escapeIdent = globalThis.CSS?.escape ?? String;
|
||||||
|
const cell = container.querySelector(`tbody tr[data-row-id="${escapeIdent(rowId)}"] > td[data-col-id="${escapeIdent(columnId)}"]`);
|
||||||
|
if (cell) {
|
||||||
|
cell.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => scrollCellIntoViewWhenRendered(container, rowId, columnId, framesLeft - 1));
|
||||||
|
};
|
||||||
|
|
||||||
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||||
const budgetYear = useBudgetYear(formId, formType, year);
|
const budgetYear = useBudgetYear(formId, formType, year);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@ -116,7 +132,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
// -> cell_edit_start следующей.
|
// -> cell_edit_start следующей.
|
||||||
const pendingEditTargetRef = useRef(null);
|
const pendingEditTargetRef = useRef(null);
|
||||||
const [columnsConfig, setColumnsConfig] = useState(null);
|
const [columnsConfig, setColumnsConfig] = useState(null);
|
||||||
const { errors: validationErrors, getCellErrors, headerPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
|
const { errors: validationErrors, getCellErrors, headerPaths: rawHeaderPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
|
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
|
||||||
@ -221,6 +237,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale();
|
const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale();
|
||||||
const denominationStorageKey = `${formId}_${formType}_${sheetName}_${direction}_denomination_v2`;
|
const denominationStorageKey = `${formId}_${formType}_${sheetName}_${direction}_denomination_v2`;
|
||||||
const { denomination, setDenomination: setTableDenomination } = useDenomination(denominationStorageKey);
|
const { denomination, setDenomination: setTableDenomination } = useDenomination(denominationStorageKey);
|
||||||
|
// AURORA-1893 (возврат 06.11): названия колонок для «Контроля ошибок» и тоста EditCell —
|
||||||
|
// с подставленными годом и единицами, как в шапке таблицы.
|
||||||
|
const headerPaths = useMemo(
|
||||||
|
() => formatHeaderPaths(rawHeaderPaths, budgetYear, denomination),
|
||||||
|
[rawHeaderPaths, budgetYear, denomination],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
rowVirtualizerRef.current?.measure?.();
|
rowVirtualizerRef.current?.measure?.();
|
||||||
@ -686,10 +708,13 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
// (expandRows() в @tanstack/table-core отфильтровывает именно .rows, сохраняя flatRows
|
// (expandRows() в @tanstack/table-core отфильтровывает именно .rows, сохраняя flatRows
|
||||||
// целиком), иначе ошибку в свёрнутой группе было бы невозможно найти, чтобы её раскрыть.
|
// целиком), иначе ошибку в свёрнутой группе было бы невозможно найти, чтобы её раскрыть.
|
||||||
const handleNavigateToError = useCallback(
|
const handleNavigateToError = useCallback(
|
||||||
({ lineId, columnId } = {}) => {
|
({ rowKey, columnId } = {}) => {
|
||||||
if (lineId === undefined || lineId === null || !columnId) return;
|
if (rowKey === undefined || rowKey === null || !columnId) return;
|
||||||
|
|
||||||
const row = table.getRowModel().flatRows.find((candidate) => getRowId(candidate) === lineId);
|
// AURORA-1893 (возврат 06.11): строку ищем по тому же ключу, что и ошибку
|
||||||
|
// (getValidationRowKey): у агрегатных строк нет line id, раньше переход к ним
|
||||||
|
// молча прерывался и выделение оставалось на предыдущей ячейке.
|
||||||
|
const row = table.getRowModel().flatRows.find((candidate) => getValidationRowKey(candidate.original) === rowKey);
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
|
|
||||||
const parentRows = row.getParentRows();
|
const parentRows = row.getParentRows();
|
||||||
@ -723,10 +748,15 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const rowIndex = table.getRowModel().rows.findIndex((visibleRow) => visibleRow.id === row.id);
|
const rowIndex = table.getRowModel().rows.findIndex((visibleRow) => visibleRow.id === row.id);
|
||||||
if (rowIndex < 0) return;
|
if (rowIndex < 0) return;
|
||||||
rowVirtualizerRef.current?.scrollToIndex?.(rowIndex < 5 ? rowIndex : rowIndex + 5, { align: 'auto', behavior: 'smooth' });
|
// AURORA-1893 (возврат 06.11): раньше было scrollToIndex(rowIndex + 5, align 'auto') —
|
||||||
|
// у последних строк листа запас +5 обрезался, а шапка внутри контейнера прокрутки
|
||||||
|
// виртуализатором не учитывается, и строка оставалась за нижним краем. Центрируем
|
||||||
|
// строку, а после отрисовки доводим до видимой области саму ячейку.
|
||||||
|
rowVirtualizerRef.current?.scrollToIndex?.(rowIndex, { align: 'center' });
|
||||||
|
scrollCellIntoViewWhenRendered(containerRef.current, row.id, columnId);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[table, columnVisibility, handleSetColumnsVisibility, handleNavigateToColumn],
|
[table, columnVisibility, handleSetColumnsVisibility, handleNavigateToColumn, containerRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchMatches = useMemo(() => {
|
const searchMatches = useMemo(() => {
|
||||||
|
|||||||
@ -52,7 +52,7 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToError, headerPaths })
|
|||||||
const error = errors[index];
|
const error = errors[index];
|
||||||
if (!error) return;
|
if (!error) return;
|
||||||
setActiveIndex(index);
|
setActiveIndex(index);
|
||||||
onNavigateToError?.({ lineId: error.lineId, columnId: error.columnId });
|
onNavigateToError?.({ rowKey: error.rowKey, columnId: error.columnId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStep = (direction) => {
|
const handleStep = (direction) => {
|
||||||
|
|||||||
@ -36,7 +36,21 @@ const escapeCssIdentFallback = (value) => {
|
|||||||
const defaultEscape = (value) =>
|
const defaultEscape = (value) =>
|
||||||
typeof globalThis.CSS?.escape === 'function' ? globalThis.CSS.escape(value) : escapeCssIdentFallback(value);
|
typeof globalThis.CSS?.escape === 'function' ? globalThis.CSS.escape(value) : escapeCssIdentFallback(value);
|
||||||
|
|
||||||
export const ACTIVE_CELL_STYLE = { zIndex: 7, boxShadow: 'inset 0 0 0 3px #1976d2' };
|
// AURORA-1893 (возврат 06.11): одной inset-тени td мало — у ошибочной ячейки её перекрывает
|
||||||
|
// красная рамка вложенного div (INVALID_STYLES), и выбранная ячейка не выделялась.
|
||||||
|
// Рамка дублируется псевдоэлементом поверх содержимого; td и так position relative/sticky.
|
||||||
|
export const ACTIVE_CELL_STYLE = {
|
||||||
|
zIndex: 7,
|
||||||
|
boxShadow: 'inset 0 0 0 3px #1976d2',
|
||||||
|
'&::after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
border: '3px solid #1976d2',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Правило sx для контейнера таблицы: подсветка текущего совпадения поиска.
|
// Правило sx для контейнера таблицы: подсветка текущего совпадения поиска.
|
||||||
// Вместо sx на каждой ячейке перерисовывается один селектор. null/неполное совпадение -> {}.
|
// Вместо sx на каждой ячейке перерисовывается один селектор. null/неполное совпадение -> {}.
|
||||||
|
|||||||
@ -29,3 +29,16 @@ test('highlight keeps 3px frame and z-index 7', () => {
|
|||||||
assert.equal(ACTIVE_CELL_STYLE.zIndex, 7);
|
assert.equal(ACTIVE_CELL_STYLE.zIndex, 7);
|
||||||
assert.equal(ACTIVE_CELL_STYLE.boxShadow, 'inset 0 0 0 3px #1976d2');
|
assert.equal(ACTIVE_CELL_STYLE.boxShadow, 'inset 0 0 0 3px #1976d2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AURORA-1893 (возврат 06.11): inset box-shadow у td перекрывается красной рамкой
|
||||||
|
// ошибочной ячейки (INVALID_STYLES на вложенном div) — выбранный агрегат с ошибкой
|
||||||
|
// не выделялся. Рамка рисуется псевдоэлементом поверх содержимого ячейки.
|
||||||
|
test('active cell frame is drawn by an ::after overlay above the cell content', () => {
|
||||||
|
const overlay = ACTIVE_CELL_STYLE['&::after'];
|
||||||
|
assert.ok(overlay, 'ACTIVE_CELL_STYLE must define an ::after overlay');
|
||||||
|
assert.equal(overlay.content, '""');
|
||||||
|
assert.equal(overlay.position, 'absolute');
|
||||||
|
assert.equal(overlay.inset, 0);
|
||||||
|
assert.equal(overlay.pointerEvents, 'none');
|
||||||
|
assert.match(overlay.border, /3px solid #1976d2/);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { formatHeaderUnit, formatHeaderYear } from './headerYearUtils.js';
|
||||||
import { getDataRowId } from './rowUtils.js';
|
import { getDataRowId } from './rowUtils.js';
|
||||||
|
|
||||||
// Достаёт вложенное поле из объекта строки таблицы
|
// Достаёт вложенное поле из объекта строки таблицы
|
||||||
@ -33,6 +34,28 @@ export const buildColumnHeaderPaths = (columns = [], path = [], map = {}) => {
|
|||||||
return map;
|
return map;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AURORA-1893 (возврат 06.11): заголовки колонок в конфигах содержат плейсхолдеры
|
||||||
|
// {year}/{year:short}/{unit} — подставляем их так же, как TableHead, чтобы в «Контроле
|
||||||
|
// ошибок» и в тосте EditCell не было «План {year} ({unit})».
|
||||||
|
export const formatHeaderPaths = (headerPaths, budgetYear, denomination) => {
|
||||||
|
const formatted = {};
|
||||||
|
for (const [columnId, path] of Object.entries(headerPaths || {})) {
|
||||||
|
formatted[columnId] = path.map((segment) => formatHeaderUnit(formatHeaderYear(segment, budgetYear), denomination));
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
};
|
||||||
|
|
||||||
|
// AURORA-1893 (возврат 06.11): ключ строки для ошибки и перехода к ней. У строк ввода
|
||||||
|
// это line id, а у агрегатных строк (ROOT/GROUP/ITEM/SUB_ITEM) data.id нет — раньше ключ
|
||||||
|
// у всех агрегатов одной колонки был null, они схлопывались в одну запись и переход к ним
|
||||||
|
// не срабатывал. sort_order — глобальный ROW_NUMBER листа, уникален для каждой строки.
|
||||||
|
export const getValidationRowKey = (row) => {
|
||||||
|
const lineId = getDataRowId(row?.data);
|
||||||
|
if (lineId !== null && lineId !== undefined) return lineId;
|
||||||
|
if (row?.sort_order !== undefined && row?.sort_order !== null) return `sort:${row.sort_order}`;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// AURORA-1893: человекочитаемая подпись строки для меню плашки ошибок —
|
// AURORA-1893: человекочитаемая подпись строки для меню плашки ошибок —
|
||||||
// название статьи/ВСП, если есть, иначе номер строки (колонка «№»).
|
// название статьи/ВСП, если есть, иначе номер строки (колонка «№»).
|
||||||
const getRowLabel = (row) => {
|
const getRowLabel = (row) => {
|
||||||
@ -53,7 +76,8 @@ export const collectInvalidErrors = (rows, rule, errors, seen) => {
|
|||||||
if (!rule.isInvalid(getValueByPath(row, columnKey), row, columnKey)) continue;
|
if (!rule.isInvalid(getValueByPath(row, columnKey), row, columnKey)) continue;
|
||||||
|
|
||||||
const lineId = getDataRowId(row.data);
|
const lineId = getDataRowId(row.data);
|
||||||
const errorId = `${rule.id}:${columnKey}:${lineId}`;
|
const rowKey = getValidationRowKey(row);
|
||||||
|
const errorId = `${rule.id}:${columnKey}:${rowKey}`;
|
||||||
if (seen.has(errorId)) continue;
|
if (seen.has(errorId)) continue;
|
||||||
seen.add(errorId);
|
seen.add(errorId);
|
||||||
|
|
||||||
@ -62,6 +86,7 @@ export const collectInvalidErrors = (rows, rule, errors, seen) => {
|
|||||||
ruleId: rule.id,
|
ruleId: rule.id,
|
||||||
columnId: columnKey,
|
columnId: columnKey,
|
||||||
lineId,
|
lineId,
|
||||||
|
rowKey,
|
||||||
rowLabel: getRowLabel(row),
|
rowLabel: getRowLabel(row),
|
||||||
message: rule.message,
|
message: rule.message,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { nonNegativeColumnKeys, nonNegativeRule, validationRules } from '../constants/validation.js';
|
import { nonNegativeColumnKeys, nonNegativeRule, validationRules } from '../constants/validation.js';
|
||||||
import { collectInvalidErrors, groupValidationErrors, isValidationRuleEnabled } from './validationUtils.js';
|
import {
|
||||||
|
collectInvalidErrors,
|
||||||
|
formatHeaderPaths,
|
||||||
|
getValidationRowKey,
|
||||||
|
groupValidationErrors,
|
||||||
|
isValidationRuleEnabled,
|
||||||
|
} from './validationUtils.js';
|
||||||
|
|
||||||
test('nonNegativeRule flags negative numbers as invalid', () => {
|
test('nonNegativeRule flags negative numbers as invalid', () => {
|
||||||
const rule = nonNegativeRule({
|
const rule = nonNegativeRule({
|
||||||
@ -264,3 +270,66 @@ test('booking-less-balance only reports its errorColumnKeys (booking columns), n
|
|||||||
assert.equal(errors[0].columnId, 'data.q1.booking');
|
assert.equal(errors[0].columnId, 'data.q1.booking');
|
||||||
assert.equal(errors[0].lineId, 5);
|
assert.equal(errors[0].lineId, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AURORA-1893 (возврат 06.11): у агрегатных строк (ROOT/GROUP/ITEM/SUB_ITEM) нет
|
||||||
|
// data.id/line_id — ключ строки берётся из глобального sort_order.
|
||||||
|
test('getValidationRowKey uses the line id, else sort_order for aggregate rows', () => {
|
||||||
|
assert.equal(getValidationRowKey({ data: { id: 15 }, sort_order: 53 }), 15);
|
||||||
|
assert.equal(getValidationRowKey({ data: { line_id: 7 } }), 7);
|
||||||
|
assert.equal(getValidationRowKey({ row_type: 'GROUP', data: { header: { name: 'Г' } }, sort_order: 2 }), 'sort:2');
|
||||||
|
assert.equal(getValidationRowKey({ row_type: 'ROOT', data: {}, sort_order: 0 }), 'sort:0');
|
||||||
|
assert.equal(getValidationRowKey({ data: {} }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectInvalidErrors reports every invalid aggregate row, not only the first one per column', () => {
|
||||||
|
const rule = { id: 'neg', columnKeys: ['data.v'], message: 'm', isInvalid: (value) => Number(value) < 0 };
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
row_type: 'ROOT',
|
||||||
|
sort_order: 1,
|
||||||
|
data: { header: { name: 'Корень' }, v: -200 },
|
||||||
|
subRows: [
|
||||||
|
{
|
||||||
|
row_type: 'GROUP',
|
||||||
|
sort_order: 2,
|
||||||
|
data: { header: { name: 'Группа 1' }, v: -100 },
|
||||||
|
subRows: [{ row_type: 'INPUT', sort_order: 3, data: { id: 9, v: -100 }, subRows: [] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
row_type: 'GROUP',
|
||||||
|
sort_order: 7,
|
||||||
|
data: { header: { name: 'Группа 2' }, v: -100 },
|
||||||
|
subRows: [{ row_type: 'INPUT', sort_order: 8, data: { id: 10, v: -100 }, subRows: [] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
collectInvalidErrors(rows, rule, errors, new Set());
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
errors.map((error) => [error.rowKey, error.rowLabel]),
|
||||||
|
[
|
||||||
|
['sort:1', 'Корень'],
|
||||||
|
['sort:2', 'Группа 1'],
|
||||||
|
[9, '3'],
|
||||||
|
['sort:7', 'Группа 2'],
|
||||||
|
[10, '8'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(new Set(errors.map((error) => error.id)).size, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatHeaderPaths substitutes year and unit placeholders in column titles', () => {
|
||||||
|
const headerPaths = {
|
||||||
|
'data.plan.q1': ['Планируемые показатели', 'План {year} ({unit}) с учётом НДС', '1 Кв. {year:short}'],
|
||||||
|
'data.name': ['Наименование'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatted = formatHeaderPaths(headerPaths, 2027, 'thousand');
|
||||||
|
|
||||||
|
assert.deepEqual(formatted['data.plan.q1'], ['Планируемые показатели', 'План 2027 (тыс. руб.) с учётом НДС', '1 Кв. 27']);
|
||||||
|
assert.deepEqual(formatted['data.name'], ['Наименование']);
|
||||||
|
assert.deepEqual(headerPaths['data.plan.q1'][1], 'План {year} ({unit}) с учётом НДС', 'исходные пути не мутируются');
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user