From d6d1c7171c9e3824d3ead1c6b058de4b2e623294 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 13:46:34 +0400 Subject: [PATCH] =?UTF-8?q?fix(osv):=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B8=20=D0=B8=D1=82=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0=20?= =?UTF-8?q?=E2=80=94=20=D1=88=D0=B0=D0=B3=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=9D=D0=A3=20=D0=BF=D0=BE=D0=B4=20=C2=AB=D0=98=D1=82=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=80=D0=B0=D0=B7=D0=B2=D0=B5=D1=80=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D0=B5=C2=BB,=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA=20=D1=82=D0=BE=D0=B6=D0=B4=D0=B5=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B0=20(R-21,=20#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/src/parser/checks.py | 12 ++- api/src/parser/classify.py | 9 +- api/tests/unit/parser/test_osv_flat_form.py | 88 ++++++++++++++++++- openspec/changes/osv-flat-form/design.md | 1 + .../osv-flat-form/specs/osv-parsing/spec.md | 8 +- 5 files changed, 110 insertions(+), 8 deletions(-) diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 493a149..88d77a3 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -94,14 +94,22 @@ def balance_identity( Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторая итоговая строка (`secondary_totals`, «Итого развернутое») пропускается, только если у неё нет оборотов (обе колонки пусты) и она не единственная TOTAL листа: «Итого развернутое» перед «Итого» несёт одни развёрнутые сальдо (#59, R-19). С оборотами - или единственная — проверяется как обычная строка. + или единственная — проверяется как обычная строка. Итоговые строки-показатели («НУ»), идущие блоком сразу за + пропущенной второй итоговой строкой, пропускаются на тех же условиях (R-21); в счётчик TOTAL показатели не входят. """ 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: + 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): continue if _is_secondary_total(item, secondary_totals) and totals > 1 and not _has_turnovers(item): + in_skipped_block = True continue sums = _values(item) expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER]) diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 4055ab5..aa35132 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -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: """Номер самой верхней из вторых итоговых строк сразу перед основной «Итого» (она сама, если их нет). - Плоская форма 1С (#59): «Итого развернутое» стоит перед «Итого» — вместе они итоговый блок. + Плоская форма 1С (#59): «Итого развернутое» стоит перед «Итого» — вместе они итоговый блок. Строки-показатели + («НУ» под «Итого развернутое») шагу не мешают, но в блок входят, только если выше них найдена вторая итоговая + строка (R-21): «НУ» под последней строкой данных в блок не втягивается. """ first = 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): + first = row + elif not is_indicator_total(sheet, row, header, rules): break - first = row return first diff --git a/api/tests/unit/parser/test_osv_flat_form.py b/api/tests/unit/parser/test_osv_flat_form.py index 47c4710..22e7d69 100644 --- a/api/tests/unit/parser/test_osv_flat_form.py +++ b/api/tests/unit/parser/test_osv_flat_form.py @@ -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: """Стык треков A и B (#58, #59): колонка «Показатели», «Итого развернутое» перед «Итого» и строка НУ после неё. - Показатели не становятся контрагентами (R-6, R-18), не попадают в тождество строки (R-19) и не сбивают сверку - «Итого» (она идёт по основной «Итого» с меткой БУ). + Показатели не становятся контрагентами (R-6, R-18) и не сбивают сверку «Итого» (она идёт по основной «Итого» с + меткой БУ); тождество строки у них проверяется, как у всех (спека трека A) — строки НУ его соблюдают. """ rows: list[list[object]] = [ [], @@ -228,3 +228,87 @@ def test_flat_list_with_indicator_column_and_second_total(rules: Rules) -> None: "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 == [] diff --git a/openspec/changes/osv-flat-form/design.md b/openspec/changes/osv-flat-form/design.md index 6b027dc..bcafaa3 100644 --- a/openspec/changes/osv-flat-form/design.md +++ b/openspec/changes/osv-flat-form/design.md @@ -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) | | 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) | Вторая итоговая без оборотов и с ошибочным сальдо не ловится; единственная итоговая без оборотов проверяется, и пустое тождество может дать нарушение | +| 20 | R-21 | 19.10.2026 | Шаг вверх от «Итого» проходит строки-показатели, но включает их в итоговый блок, только если выше найдена вторая итоговая строка; показатели второй итоговой строки без оборотов наследуют пропуск тождества (R-19); счётчик TOTAL в тождестве не считает показатели | 1С штатно даёт «Показатели БУ, НУ» + «Итого развернутое»: без шага «Итого развернутое» оставалась в теле и становилась фиктивным контрагентом, а НУ давала нарушение тождества (ревью интеграции I-6, I-7, M-13) | Ошибка сальдо показателя второй итоговой строки не ловится | ## Risks / Trade-offs diff --git a/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md index 7afcd4b..93a7556 100644 --- a/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md @@ -96,7 +96,7 @@ пробел или его отсутствие, R-18; цифры — только 0–9; название после запятой может занимать несколько строк ячейки — M-1); `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; -строка «Итого» и вторые итоговые строки («Итого развернутое») сразу перед ней или сразу за ней — TOTAL. +строка «Итого» и вторые итоговые строки («Итого развернутое») в итоговом блоке сразу перед ней или сразу за ней, включая строки-показатели итоговых строк, — TOTAL; строки-показатели входят в блок перед «Итого», только если выше них стоит вторая итоговая строка. Для COUNTERPARTY `counterparty_name` SHALL быть текстом строки без ИНН в конце и разделителя перед ним (`row_value` — исходный текст; OQ-100), а `counterparty_inn` — первым найденным из: @@ -254,6 +254,12 @@ balance_identity_violations, parent_sum_violations}`. Результат про - **WHEN** «Итого развернутое» стоит после «Итого», у неё заполнены обороты, и сальдо на конец не сходится с началом и оборотами - **THEN** её `row_number` попадает в `balance_identity_violations`; при сходящемся тождестве нарушения нет +#### Scenario: Показатели второй итоговой строки без оборотов +- **WHEN** колонка «Показатели», «Итого развернутое» без оборотов (до или после «Итого») и под ней строка «НУ» без оборотов, + на листе есть основная «Итого» +- **THEN** строка «НУ» блока пропускает тождество строки так же, как «Итого развернутое»; `balance_identity_violations` + пуст, `status = done`; строка «НУ» под последней строкой данных в итоговый блок не входит + #### Scenario: «Итого» плоского списка - **WHEN** разбирается синтетическая ОСВ `s40` («Итого» = сумма контрагентов, сальдо «Итого» свёрнуто) - **THEN** `total_row_matches_root = true`, `status = done`; если сумма одного контрагента испорчена — `false`,