Плоская форма ОСВ 1С: счёт из шапки, «Итого развернутое», плоский список, контрагенты расчётных счетов (#59) #63

Merged
aep merged 16 commits from feat/59-osv-flat-form into main 2026-10-19 13:21:55 +03:00
5 changed files with 110 additions and 8 deletions
Showing only changes of commit d6d1c7171c - Show all commits

View File

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

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:
"""Номер самой верхней из вторых итоговых строк сразу перед основной «Итого» (она сама, если их нет).
Плоская форма 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

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:
"""Стык треков 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 == []

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

View File

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