fix(osv): вторые итоговые строки («Итого развернутое») вне тождества строки, R-11 отменён R-16 (#59)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 12:47:31 +04:00
parent 69564ffcbd
commit 835083ca4d
7 changed files with 65 additions and 12 deletions

View File

@ -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` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из

View File

@ -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] = []

View File

@ -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)]

View File

@ -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()

View File

@ -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],
]

View File

@ -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

View File

@ -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`,