fix(osv): показатели итогового блока — шаг через НУ под «Итого развернутое», пропуск тождества (R-21, #59)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 13:46:34 +04:00
parent 8311808472
commit d6d1c7171c
5 changed files with 110 additions and 8 deletions

View File

@ -94,14 +94,22 @@ def balance_identity(
Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторая итоговая строка Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторая итоговая строка
(`secondary_totals`, «Итого развернутое») пропускается, только если у неё нет оборотов (обе колонки пусты) и она не (`secondary_totals`, «Итого развернутое») пропускается, только если у неё нет оборотов (обе колонки пусты) и она не
единственная TOTAL листа: «Итого развернутое» перед «Итого» несёт одни развёрнутые сальдо (#59, R-19). С оборотами единственная TOTAL листа: «Итого развернутое» перед «Итого» несёт одни развёрнутые сальдо (#59, R-19). С оборотами
или единственная — проверяется как обычная строка. или единственная — проверяется как обычная строка. Итоговые строки-показатели («НУ»), идущие блоком сразу за
пропущенной второй итоговой строкой, пропускаются на тех же условиях (R-21); в счётчик TOTAL показатели не входят.
""" """
violations: list[int] = [] violations: list[int] = []
totals = sum(1 for item in rows if item.group_type == "TOTAL") totals = sum(1 for item in rows if item.group_type == "TOTAL" and not item.indicator)
in_skipped_block = False
for item in rows: for item in rows:
if item.group_type == "TOTAL" and item.indicator:
if in_skipped_block and not _has_turnovers(item):
continue
else:
in_skipped_block = False
if _no_analytic_balance(item, exempt): if _no_analytic_balance(item, exempt):
continue continue
if _is_secondary_total(item, secondary_totals) and totals > 1 and not _has_turnovers(item): if _is_secondary_total(item, secondary_totals) and totals > 1 and not _has_turnovers(item):
in_skipped_block = True
continue continue
sums = _values(item) sums = _values(item)
expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER]) expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER])

View File

@ -201,13 +201,16 @@ def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], tot
def _leading_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int: def _leading_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int:
"""Номер самой верхней из вторых итоговых строк сразу перед основной «Итого» (она сама, если их нет). """Номер самой верхней из вторых итоговых строк сразу перед основной «Итого» (она сама, если их нет).
Плоская форма 1С (#59): «Итого развернутое» стоит перед «Итого» — вместе они итоговый блок. Плоская форма 1С (#59): «Итого развернутое» стоит перед «Итого» — вместе они итоговый блок. Строки-показатели
(«НУ» под «Итого развернутое») шагу не мешают, но в блок входят, только если выше них найдена вторая итоговая
строка (R-21): «НУ» под последней строкой данных в блок не втягивается.
""" """
first = total first = total
for row in reversed(rows[: rows.index(total)]): for row in reversed(rows[: rows.index(total)]):
if not _is_secondary_total(sheet.text_at(row, header.text_col), rules): if _is_secondary_total(sheet.text_at(row, header.text_col), rules):
break
first = row first = row
elif not is_indicator_total(sheet, row, header, rules):
break
return first return first

View File

@ -186,8 +186,8 @@ def test_flat_list_with_broken_total_needs_check(rules: Rules) -> None:
def test_flat_list_with_indicator_column_and_second_total(rules: Rules) -> None: def test_flat_list_with_indicator_column_and_second_total(rules: Rules) -> None:
"""Стык треков A и B (#58, #59): колонка «Показатели», «Итого развернутое» перед «Итого» и строка НУ после неё. """Стык треков A и B (#58, #59): колонка «Показатели», «Итого развернутое» перед «Итого» и строка НУ после неё.
Показатели не становятся контрагентами (R-6, R-18), не попадают в тождество строки (R-19) и не сбивают сверку Показатели не становятся контрагентами (R-6, R-18) и не сбивают сверку «Итого» (она идёт по основной «Итого» с
«Итого» (она идёт по основной «Итого» с меткой БУ). меткой БУ); тождество строки у них проверяется, как у всех (спека трека A) — строки НУ его соблюдают.
""" """
rows: list[list[object]] = [ rows: list[list[object]] = [
[], [],
@ -228,3 +228,87 @@ def test_flat_list_with_indicator_column_and_second_total(rules: Rules) -> None:
"TOTAL", "TOTAL",
"TOTAL", "TOTAL",
] ]
def _indicator_flat_sheet(tail: list[list[object]]) -> list[list[object]]:
"""Плоская форма 1С с колонкой «Показатели»: два контрагента с БУ/НУ, дальше — итоговый блок `tail`."""
return [
[],
["Оборотно-сальдовая ведомость по счету: 76"],
["Контрагенты"],
["за 2025 г."],
['ООО "Тест"'],
[],
[
"Субконто",
"Показатели",
M("Сальдо на начало периода", 2),
M("Обороты за период", 2),
M("Сальдо на конец периода", 2),
],
[None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
["Август - Агро ООО", "БУ", 1000, None, 500, 300, 1200, None],
[None, "НУ", 900, None, 400, 300, 1000, None],
["АВИС ООО", "БУ", None, 200, 100, 400, None, 500],
[None, "НУ", None, 150, 100, 350, None, 400],
*tail,
]
def test_indicator_under_second_total_before_total_joins_total_block(rules: Rules) -> None:
"""I-6, R-21: НУ без объединения под «Итого развернутое» перед «Итого» — итоговый блок, не фиктивный контрагент."""
rows = _indicator_flat_sheet(
[
["Итого развернутое", "БУ", 1000, 200, None, None, 1200, 500],
[None, "НУ", 900, 150, None, None, 1000, 400],
["Итого", "БУ", 1000, 200, 600, 700, 1200, 500],
[None, "НУ", 900, 150, 500, 650, 1000, 400],
]
)
document, _ = _parse(rules, rows)
assert document.status == "done", [note.message for note in document.warnings]
assert [item.row_value for item in document.rows if item.group_type == "COUNTERPARTY"] == [
"Август - Агро ООО",
"АВИС ООО",
]
secondary = next(item for item in document.rows if item.row_value == "Итого развернутое")
assert secondary.group_type == "TOTAL"
assert document.checks is not None
assert document.checks.balance_identity_violations == []
def test_indicator_under_last_data_row_is_not_pulled_into_total_block(rules: Rules) -> None:
"""R-21, негативный: НУ под последней строкой данных перед «Итого» остаётся показателем этой строки."""
rows = _indicator_flat_sheet(
[
["Итого", "БУ", 1000, 200, 600, 700, 1200, 500],
[None, "НУ", 900, 150, 500, 650, 1000, 400],
]
)
document, _ = _parse(rules, rows)
nu_under_data = [item for item in document.rows if item.data_type == "NU" and item.group_type != "TOTAL"]
assert len(nu_under_data) == NU_ROWS - 1
assert document.status == "done", [note.message for note in document.warnings]
@pytest.mark.parametrize("secondary_first", [True, False])
def test_indicators_of_second_total_without_turnovers_skip_identity(rules: Rules, secondary_first: bool) -> None:
"""I-7, R-21: «Итого развернутое» без оборотов со своим НУ не даёт нарушений тождества (до и после «Итого»)."""
secondary: list[list[object]] = [
["Итого развернутое", "БУ", 1000, 200, None, None, 1200, 500],
[None, "НУ", 900, 150, None, None, 1000, 400],
]
main: list[list[object]] = [
["Итого", "БУ", 1000, 200, 600, 700, 1200, 500],
[None, "НУ", 900, 150, 500, 650, 1000, 400],
]
document, _ = _parse(rules, _indicator_flat_sheet([*secondary, *main] if secondary_first else [*main, *secondary]))
assert document.status == "done", [note.message for note in document.warnings]
assert document.checks is not None
assert document.checks.balance_identity_violations == []

View File

@ -100,6 +100,7 @@ r11 Август - Агро ООО | … суммы … ← с
| 17 | R-17 (заменяет R-10) | 19.10.2026 | Синоним вида субконто сужен до множественного числа: `^организации\b` → COUNTERPARTY; единственное «Организация» остаётся OTHER и в схему не добавляется | «Организация» в шапке вариантов 1/2 — собственная организация учёта (измерение отчитывающейся организации); на контуре `dae56120` (сч. 83) и `02c5df51` (сч. 80) схема `[Счет, Организация]` из-за широкого паттерна стала `[ACCOUNT, COUNTERPARTY]` (финальное ревью I-1) | Прочие формы написания («Организации-контрагенты» и т. п.) не узнаются; вид во множественном числе с иным смыслом на нерасчётном счёте даст COUNTERPARTY (OQ-171) | | 17 | R-17 (заменяет R-10) | 19.10.2026 | Синоним вида субконто сужен до множественного числа: `^организации\b` → COUNTERPARTY; единственное «Организация» остаётся OTHER и в схему не добавляется | «Организация» в шапке вариантов 1/2 — собственная организация учёта (измерение отчитывающейся организации); на контуре `dae56120` (сч. 83) и `02c5df51` (сч. 80) схема `[Счет, Организация]` из-за широкого паттерна стала `[ACCOUNT, COUNTERPARTY]` (финальное ревью I-1) | Прочие формы написания («Организации-контрагенты» и т. п.) не узнаются; вид во множественном числе с иным смыслом на нерасчётном счёте даст COUNTERPARTY (OQ-171) |
| 18 | R-18 | 19.10.2026 | Правило расчётных счетов (`counterparty_accounts`) не применяется к строке, которую словарь групп явно знает как не-COUNTERPARTY; паттерн валюты расширен до `^(в\s+)?валют` | Строки «Валюта», «Валюта CNY/EUR/USD/руб», «В валюте» под 76.55 становились контрагентами (`270b41da`, `738bfcf5`; финальное ревью I-2) | Контрагент с названием на «Валют…» («Валютные решения ООО») остаётся OTHER без имени и не попадёт в сопоставление | | 18 | R-18 | 19.10.2026 | Правило расчётных счетов (`counterparty_accounts`) не применяется к строке, которую словарь групп явно знает как не-COUNTERPARTY; паттерн валюты расширен до `^(в\s+)?валют` | Строки «Валюта», «Валюта CNY/EUR/USD/руб», «В валюте» под 76.55 становились контрагентами (`270b41da`, `738bfcf5`; финальное ревью I-2) | Контрагент с названием на «Валют…» («Валютные решения ООО») остаётся OTHER без имени и не попадёт в сопоставление |
| 19 | R-19 | 19.10.2026 | Тождество у второй итоговой строки пропускается, только если обе колонки оборотов пусты и на листе есть другая TOTAL; иначе строка проверяется как обычная | На контуре «Итого развернутое» перед «Итого» — без оборотов, после «Итого» — с оборотами и сходящимся тождеством; R-16 пропускал обе (финальное ревью I-4) | Вторая итоговая без оборотов и с ошибочным сальдо не ловится; единственная итоговая без оборотов проверяется, и пустое тождество может дать нарушение | | 19 | R-19 | 19.10.2026 | Тождество у второй итоговой строки пропускается, только если обе колонки оборотов пусты и на листе есть другая TOTAL; иначе строка проверяется как обычная | На контуре «Итого развернутое» перед «Итого» — без оборотов, после «Итого» — с оборотами и сходящимся тождеством; R-16 пропускал обе (финальное ревью I-4) | Вторая итоговая без оборотов и с ошибочным сальдо не ловится; единственная итоговая без оборотов проверяется, и пустое тождество может дать нарушение |
| 20 | R-21 | 19.10.2026 | Шаг вверх от «Итого» проходит строки-показатели, но включает их в итоговый блок, только если выше найдена вторая итоговая строка; показатели второй итоговой строки без оборотов наследуют пропуск тождества (R-19); счётчик TOTAL в тождестве не считает показатели | 1С штатно даёт «Показатели БУ, НУ» + «Итого развернутое»: без шага «Итого развернутое» оставалась в теле и становилась фиктивным контрагентом, а НУ давала нарушение тождества (ревью интеграции I-6, I-7, M-13) | Ошибка сальдо показателя второй итоговой строки не ловится |
## Risks / Trade-offs ## Risks / Trade-offs

View File

@ -96,7 +96,7 @@
пробел или его отсутствие, R-18; цифры — только 0–9; название после запятой может занимать несколько строк ячейки — M-1); пробел или его отсутствие, R-18; цифры — только 0–9; название после запятой может занимать несколько строк ячейки — M-1);
`account_code_raw` — код как в файле, без названия после запятой. Не-счёт — группировка из `group_schema` по позиции `account_code_raw` — код как в файле, без названия после запятой. Не-счёт — группировка из `group_schema` по позиции
(число не-счётных предков после последнего ACCOUNT + 1), при выходе за схему — OTHER, кроме **расчётных счетов**: если позиция 1 (первая не-счётная) выходит за схему, а счёт ветки — из списка `counterparty_accounts` `rules/osv_groups.yaml` (начальный состав 58, 60, 62, 66, 67, 75, 76, 80; запись `76` покрывает `76`, `76.2`, `76.Н.1`, но не `760`), строка SHALL получить COUNTERPARTY без имени группировки, а документ — предупреждение `OSV_ROWS` «вид субконто в шапке не назван: строки аналитики счёта <код> приняты за контрагентов по плану счетов — проверьте» (один раз на счёт); счёт ветки строк до первой строки-счёта — код счёта шапки; строка ИНН без пары — OTHER; (число не-счётных предков после последнего ACCOUNT + 1), при выходе за схему — OTHER, кроме **расчётных счетов**: если позиция 1 (первая не-счётная) выходит за схему, а счёт ветки — из списка `counterparty_accounts` `rules/osv_groups.yaml` (начальный состав 58, 60, 62, 66, 67, 75, 76, 80; запись `76` покрывает `76`, `76.2`, `76.Н.1`, но не `760`), строка SHALL получить COUNTERPARTY без имени группировки, а документ — предупреждение `OSV_ROWS` «вид субконто в шапке не назван: строки аналитики счёта <код> приняты за контрагентов по плану счетов — проверьте» (один раз на счёт); счёт ветки строк до первой строки-счёта — код счёта шапки; строка ИНН без пары — OTHER;
строка «Итого» и вторые итоговые строки («Итого развернутое») сразу перед ней или сразу за ней — TOTAL. строка «Итого» и вторые итоговые строки («Итого развернутое») в итоговом блоке сразу перед ней или сразу за ней, включая строки-показатели итоговых строк, — TOTAL; строки-показатели входят в блок перед «Итого», только если выше них стоит вторая итоговая строка.
Для COUNTERPARTY `counterparty_name` SHALL быть текстом строки без ИНН в конце и разделителя перед ним (`row_value` — Для COUNTERPARTY `counterparty_name` SHALL быть текстом строки без ИНН в конце и разделителя перед ним (`row_value` —
исходный текст; OQ-100), а `counterparty_inn` — первым найденным из: исходный текст; OQ-100), а `counterparty_inn` — первым найденным из:
@ -254,6 +254,12 @@ balance_identity_violations, parent_sum_violations}`. Результат про
- **WHEN** «Итого развернутое» стоит после «Итого», у неё заполнены обороты, и сальдо на конец не сходится с началом и оборотами - **WHEN** «Итого развернутое» стоит после «Итого», у неё заполнены обороты, и сальдо на конец не сходится с началом и оборотами
- **THEN** её `row_number` попадает в `balance_identity_violations`; при сходящемся тождестве нарушения нет - **THEN** её `row_number` попадает в `balance_identity_violations`; при сходящемся тождестве нарушения нет
#### Scenario: Показатели второй итоговой строки без оборотов
- **WHEN** колонка «Показатели», «Итого развернутое» без оборотов (до или после «Итого») и под ней строка «НУ» без оборотов,
на листе есть основная «Итого»
- **THEN** строка «НУ» блока пропускает тождество строки так же, как «Итого развернутое»; `balance_identity_violations`
пуст, `status = done`; строка «НУ» под последней строкой данных в итоговый блок не входит
#### Scenario: «Итого» плоского списка #### Scenario: «Итого» плоского списка
- **WHEN** разбирается синтетическая ОСВ `s40` («Итого» = сумма контрагентов, сальдо «Итого» свёрнуто) - **WHEN** разбирается синтетическая ОСВ `s40` («Итого» = сумма контрагентов, сальдо «Итого» свёрнуто)
- **THEN** `total_row_matches_root = true`, `status = done`; если сумма одного контрагента испорчена — `false`, - **THEN** `total_row_matches_root = true`, `status = done`; если сумма одного контрагента испорчена — `false`,