diff --git a/README.md b/README.md index d08787c..be87fd5 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо **«Итого».** Основная итоговая строка — первая TOTAL, не являющаяся показателем; она сверяется с первым корнем ACCOUNT либо, если корней уровня 0 несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03), с суммой строк уровня 0 (OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя выше). Вторая итоговая -строка сразу за «Итого» («Итого развернутое», `total_row_secondary`) — тоже `TOTAL`, в сверку не входит. Строка сумм +строка сразу за «Итого» («Итого развернутое», `total_row_secondary`) — тоже `TOTAL`, в сверку «Итого» и в тождество строки не входит (в 1С у неё только сальдо, без оборотов). Строка сумм без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением. **ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 11e0bf9..31260be 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -75,14 +75,24 @@ def _no_analytic_balance(item: ClassifiedRow, exempt: OsvCheckRules | None) -> b return exempt is not None and exempt.has_no_analytic_balance(item.account_code_raw) -def balance_identity(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> list[int]: +def _is_secondary_total(item: ClassifiedRow, secondary: Sequence[Pattern]) -> bool: + return item.group_type == "TOTAL" and any(p.search(item.body.text) for p in secondary) + + +def balance_identity( + rows: Sequence[ClassifiedRow], + exempt: OsvCheckRules | None = None, + secondary_totals: Sequence[Pattern] = (), +) -> list[int]: """Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого». - Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. + Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторые итоговые строки + (`secondary_totals`, «Итого развернутое») не проверяются: в 1С они несут только развёрнутые сальдо, без оборотов + (#59, R-16); основная «Итого» проверяется. """ violations: list[int] = [] for item in rows: - if _no_analytic_balance(item, exempt): + if _no_analytic_balance(item, exempt) or _is_secondary_total(item, secondary_totals): continue sums = _values(item) expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER]) @@ -240,9 +250,10 @@ def run_checks( """Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось). `exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью. - `secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они (#59). + `secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они, и тождество + строки на них не проверяется (#59, R-16). """ - identity = balance_identity(rows, exempt) + identity = balance_identity(rows, exempt, secondary_totals) parents = parent_sums(rows, exempt) matches = total_matches_root(rows, secondary_totals) warnings: list[OsvNote] = [] diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 2771051..7893503 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -902,7 +902,7 @@ def _s40() -> list[SheetSpec]: ("Август - Агро ООО", ("1000.00", None, "500.00", "300.00", "1200.00", None), 0), ("АВИС ООО", (None, "200.00", "100.00", "400.00", None, "500.00"), 0), ("Агроаспект ООО", (None, None, "50.00", "50.00", None, None), 0), - ("Итого развернутое", ("1000.00", "200.00", "650.00", "750.00", "1200.00", "500.00"), 0), + ("Итого развернутое", ("1000.00", "200.00", None, None, "1200.00", "500.00"), 0), ("Итого", ("800.00", None, "650.00", "750.00", "700.00", None), 0), ] return [_flat_sheet(texts, 9, rows)] @@ -918,7 +918,7 @@ def _s41() -> list[SheetSpec]: rows: list[FlatRow] = [ ("Автодор-Платные Дороги ООО", ("820.21", None, "5000.00", "4991.00", "829.21", None), 0), ("АГРОПРОФИ ООО", (None, "2763.79", None, None, None, "2763.79"), 0), - ("Итого развернутое", ("820.21", "2763.79", "5000.00", "4991.00", "829.21", "2763.79"), 0), + ("Итого развернутое", ("820.21", "2763.79", None, None, "829.21", "2763.79"), 0), ("Итого", (None, "1943.58", "5000.00", "4991.00", None, "1934.58"), 0), ] return [_flat_sheet(texts, 5, rows)] @@ -938,7 +938,7 @@ def _s42() -> list[SheetSpec]: ("76.5", (None, "300.00", "700.00", "600.00", None, "200.00"), 0), ("76.Н", (None, None, "5000.00", "5000.00", None, None), 0), ("76.Н.1", (None, None, "5000.00", "5000.00", None, None), 0), - ("Итого развернутое", ("1000.00", "300.00", "5700.00", "6600.00", None, "200.00"), 0), + ("Итого развернутое", ("1000.00", "300.00", None, None, None, "200.00"), 0), ("Итого", ("700.00", None, "5700.00", "6600.00", None, "200.00"), 0), ] return [_flat_sheet(texts, 8, rows)] diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index e5d1d72..9464495 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -257,6 +257,39 @@ def test_total_check_uses_main_total_when_secondary_comes_first() -> None: assert not any(note.code == "OSV_TOTAL" for note in notes) +SECONDARY = [Pattern.compile(r"^итого\s+развернут")] +# «Итого развернутое» в 1С несёт только развёрнутые сальдо, без оборотов: тождество строки на ней не выполняется +SECONDARY_ROW: Sums = ("100", "10", None, None, "120", "5") + + +def _secondary(number: int, sums: Sums) -> ClassifiedRow: + return row(number, sums, group_type="TOTAL", text="Итого развернутое") + + +def test_balance_identity_skips_secondary_total_without_turnovers() -> None: + rows = [*tree(), _secondary(11, SECONDARY_ROW)] + + assert balance_identity(rows, secondary_totals=SECONDARY) == [] + checks, notes = run_checks(rows, secondary_totals=SECONDARY) + assert checks.balance_identity_violations == [] + assert not any(note.code == "OSV_BALANCE_IDENTITY" for note in notes) + + +def test_balance_identity_still_checks_main_total_with_secondary_present() -> None: + broken: Sums = ("100", None, "50", "30", "121", None) + rows = [account(7, ROOT), total(8, broken), _secondary(9, SECONDARY_ROW)] + + assert balance_identity(rows, secondary_totals=SECONDARY) == [8] + + +def test_balance_identity_without_secondary_patterns_checks_every_total() -> None: + rows = [*tree(), _secondary(11, SECONDARY_ROW)] + + assert balance_identity(rows) == [11] + checks, _ = run_checks(rows) + assert checks.balance_identity_violations == [11] + + def test_total_matches_first_root_account() -> None: rows = tree() diff --git a/api/tests/unit/parser/test_osv_flat_form.py b/api/tests/unit/parser/test_osv_flat_form.py index f6e049b..aac74c5 100644 --- a/api/tests/unit/parser/test_osv_flat_form.py +++ b/api/tests/unit/parser/test_osv_flat_form.py @@ -47,7 +47,7 @@ def test_flat_list_s40(rules: Rules) -> None: ["Август - Агро ООО", 1000, None, 500, 300, 1200, None], ["АВИС ООО", None, 200, 100, 400, None, 500], ["Агроаспект ООО", None, None, 50, 50, None, None], - ["Итого развернутое", 1000, 200, 650, 750, 1200, 500], + ["Итого развернутое", 1000, 200, None, None, 1200, 500], ["Итого", 800, None, 650, 750, 700, None], ] @@ -73,7 +73,7 @@ def test_flat_list_s41(rules: Rules) -> None: *_table_head(), ["Автодор-Платные Дороги ООО", 820, None, 5000, 4991, 829, None], ["АГРОПРОФИ ООО", None, 2764, None, None, None, 2764], - ["Итого развернутое", 820, 2764, 5000, 4991, 829, 2764], + ["Итого развернутое", 820, 2764, None, None, 829, 2764], ["Итого", None, 1944, 5000, 4991, None, 1935], ] diff --git a/openspec/changes/osv-flat-form/design.md b/openspec/changes/osv-flat-form/design.md index b4970bf..1f2f188 100644 --- a/openspec/changes/osv-flat-form/design.md +++ b/openspec/changes/osv-flat-form/design.md @@ -89,11 +89,12 @@ r11 Август - Агро ООО | … суммы … ← с | 8 | R-8 | 19.10.2026 | «Учредители» на счёте 80 не трогаем: именованная группировка остаётся OTHER (R-6 сохраняется) | Принято по рекомендации сессии при одобрении запуска владельцем; именованные группировки правило расчётных счетов не переопределяет | Учредители не станут COUNTERPARTY автоматически — гудвилл (§4 п. 2) потребует ручного сопоставления | | 9 | R-9 | 19.10.2026 | Счета 71 и 73 исключены из `counterparty_accounts`; итог: 58, 60, 62, 66, 67, 75, 76, 80 | Подотчётные и персонал — физлица, а не контрагенты (OQ-158); принято по рекомендации сессии | Контрагенты на 71/73 без названия вида останутся OTHER и не попадут в сопоставление | | 10 | R-10 | 19.10.2026 | «Организации» — синоним COUNTERPARTY в `osv_groups.yaml` (`^организаци`) | Вид субконто письма (сч. 60) — организации-контрагенты; принято по рекомендации сессии | Другой смысл вида «Организации» на иных счетах даст COUNTERPARTY; видно в golden/предупреждениях | -| 11 | R-11 | 19.10.2026 | Данные синтетики s40, s41, s42 исправлены: у «Итого развернутое» добавлены обороты, равные суммам строк (s40: 650/750, s41: 5000/4991, s42: 5700/6600) | В плане у этой строки обороты пусты, поэтому тождество строки (начало + обороты = конец) не сходилось, и файлы не могли стать `done` независимо от кода; бриф плана ошибочен | s40–s42 расходятся с исходным текстом плана. **Приёмка Task 6 показала:** в реальных файлах контура у «Итого развернутое» обороты тоже пусты (`45e06f7d`, `638ecb3d`, `7e49f8b5`, `f2e0e04e`, `270b41da`, `738bfcf5`, письмо), тождество на этой строке остаётся нарушенным, и файл уходит в `needs_check`: правка данных синтетики скрыла дефект кода, а не данных; решается отдельно (см. отчёт task-6) | +| 11 | R-11 (отменён R-16) | 19.10.2026 | Данные синтетики s40, s41, s42 исправлены: у «Итого развернутое» добавлены обороты, равные суммам строк (s40: 650/750, s41: 5000/4991, s42: 5700/6600) | В плане у этой строки обороты пусты, поэтому тождество строки (начало + обороты = конец) не сходилось, и файлы не могли стать `done` независимо от кода; бриф плана ошибочен | s40–s42 расходятся с исходным текстом плана. **Приёмка Task 6 показала:** в реальных файлах контура у «Итого развернутое» обороты тоже пусты (`45e06f7d`, `638ecb3d`, `7e49f8b5`, `f2e0e04e`, `270b41da`, `738bfcf5`, письмо), тождество на этой строке остаётся нарушенным, и файл уходит в `needs_check`: правка данных синтетики скрыла дефект кода, а не данных; решается отдельно (см. отчёт task-6). **Отменён рулингом R-16:** данные s40–s42 возвращены к исходному тексту плана | | 12 | R-12 | 19.10.2026 | Строка вида субконто не даёт группировку из ячеек-меток шапки («Субконто», «Счет»), меток валюты («Валюта БУ, НУ») и ячеек, на которые срабатывает паттерн `currency` | Ячейка между названием и периодом может быть меткой шапки, а не видом субконто; иначе в схему попали бы ложные группировки (T2.2, ревью задачи 2) | Метка, совпавшая с настоящим видом субконто, не станет группировкой; вид «Организации» уже учтён (R-10) | | 13 | R-13 | 19.10.2026 | Предупреждение `OSV_ROWS` «вид субконто в шапке не назван…» — одно на каждый различный `account_code_raw` строк, принятых по плану счетов | План требовал «один раз на документ»; у листа с несколькими субсчетами (62.01, 62.02) заметка с точным кодом полезнее одной общей | Больше заметок на листах с несколькими субсчетами (лист `67 сч.` — четыре); на статус не влияют (`info`) | | 14 | R-14 | 19.10.2026 | `Expect.data_type` в `test_synthetic_files.py` стал `str \| None`: у s40–s44 ожидается `None` | В шапке нет БУ/НУ; умолчание BU — зона трека A (`data_type`) | При слиянии трека A ожидания s40–s44 придётся поправить на `BU` | | 15 | R-15 | 19.10.2026 | Предупреждение `OSV_TOTAL` при несходимости «Итого» без корня ACCOUNT, но со строками уровня 0, говорит «≠ сумме N строк уровня 0» (T4.1) | Прежний текст «корневого счёта нет» вводил в заблуждение для плоского списка | Текст в `metrics.warnings` изменился; снимков с прежним текстом нет | +| 16 | R-16 | 19.10.2026 | Вторые итоговые строки (`total_row_secondary`, «Итого развернутое») исключены из проверки тождества строки; сверка по основной «Итого» сохранена; R-11 отменён | В 1С «Итого развернутое» содержит только развёрнутые сальдо, без оборотов (подтверждено 7 реальными файлами контура: `638ecb3d`, `45e06f7d`, `f2e0e04e`, `7e49f8b5`, `270b41da`, `738bfcf5`, письмо); решение владельца: «Итого развернутое» — итоговая строка в любом порядке | Тождество на вторых итогах не проверяется: ошибка в сальдо этой строки не будет поймана; основная «Итого» по-прежнему проверяется тождеством и сверкой с корнем/суммой уровня 0 | ## 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 9a6616e..641f97f 100644 --- a/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-flat-form/specs/osv-parsing/spec.md @@ -161,6 +161,9 @@ balance_identity_violations, parent_sum_violations}`. Результат про `OSV_XLSB_NO_OUTLINE`); строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Парсер MUST NOT подгонять структуру под сходимость. +Вторые итоговые строки (`total_row_secondary`, «Итого развернутое») SHALL NOT участвовать в проверке тождества строки: +в 1С они несут только развёрнутые сальдо, без оборотов (R-16); основная «Итого» проверяется тождеством как прежде. + Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 91 и 99 строки аналитики несут только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml` (`accounts_without_analytic_balance`, начальный состав `91`, `99`; 90 не входит). Строка относится к такому счёту, если @@ -228,6 +231,11 @@ balance_identity_violations, parent_sum_violations}`. Результат про - **THEN** обе итоговые строки — TOTAL уровня 0 без родителя, «Итого развернутое» не ребёнок «76.Н.1»; `total_row_matches_root = true` (сверка по «Итого»), нарушений нет, `status = done` +#### Scenario: «Итого развернутое» без оборотов +- **WHEN** у «Итого развернутое» заполнены только сальдо (обороты пусты), а у основной «Итого» тождество строки выполняется +- **THEN** `balance_identity_violations` не содержит «Итого развернутое», `status = done`; при нарушении тождества у основной + «Итого» её `row_number` в списке остаётся + #### Scenario: «Итого» плоского списка - **WHEN** разбирается синтетическая ОСВ `s40` («Итого» = сумма контрагентов, сальдо «Итого» свёрнуто) - **THEN** `total_row_matches_root = true`, `status = done`; если сумма одного контрагента испорчена — `false`,