feat(statements): парсер ББ и ОФР из Excel, контракт v1.2 (Срез 2b) #16

Merged
aep merged 43 commits from feat/14-slice-2b-statement-parser into main 2026-10-06 21:38:00 +03:00
23 changed files with 427 additions and 85 deletions
Showing only changes of commit 1abe212524 - Show all commits

View File

@ -147,7 +147,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») |
В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы
(`skipped: нет признаков ОСВ`), несовпадение `file_type` с содержимым, свёрнутое
(`skipped: нет признаков ОСВ или формы`), несовпадение `file_type` с содержимым, свёрнутое
сальдо, нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ).
### Контрольные суммы (§1.4)
@ -275,11 +275,14 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63
### Ограничения
- Единицы не нормализуются: `amount` как в ячейке, `units` — текст шапки. При «тыс. руб.» и значениях с копейками
выдаётся предупреждение; такие файлы дают `needs_check` (OQ-138).
- Единицы не нормализуются: `amount` как в ячейке, `units` — текст шапки. При «тыс. руб.» и доле значений с дробной
частью не меньше `fractional_share_percent` выдаётся одно предупреждение на документ; само оно на статус не влияет —
`needs_check` задают нарушенные соотношения (при рублях с копейками итоги расходятся, OQ-138).
- Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся
одиноким продолжением и даёт `needs_check`.
- Лист ОФР с одной строкой кода (O-9, меньше `min_codes`) пропускается как не форма.
- Лист с 1–2 кодами форм (меньше `min_codes`) формой не считается, если идёт первым; после листа формы он берётся
как продолжение (`min_codes_continuation` словаря) и присоединяется, если совпало число колонок сумм и тип формы;
иначе не присоединён, книга `needs_check`, предупреждение «лист с кодами форм (N < min_codes) не присоединён: …».
### Реальные формы: файл → статус
@ -298,7 +301,7 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63
| `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` | BALANCE `needs_check` | страница 2 без шапки |
| `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls` | INCOME `needs_check` | рубли при шапке «тыс. руб.»: расхождения 2100/2200/2300 (OQ-138) |
| `unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls` | INCOME `needs_check` | страница 2 без шапки |
| `npz/2025-Q3/Бухгалтерская отчетность … (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx` | BALANCE `needs_check`, INCOME `needs_check` | рубли при шапке «тыс. руб.»: `1600 = 1700` расходится на 1.94/9.02/2.48 руб.; ОФР — 2100/2200/2300 (OQ-138); одностроечный лист с 2400 пропущен |
| `npz/2025-Q3/Бухгалтерская отчетность … (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx` | BALANCE `needs_check`, INCOME `needs_check` | рубли при шапке «тыс. руб.»: `1600 = 1700` расходится на 1.94/9.02/2.48 руб.; ОФР — 2100/2200/2300 (OQ-138); лист с единственной строкой 2400 присоединён (O-9) |
| `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` | BALANCE `done` | все соотношения сходятся |
| `mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx` | INCOME `done` | все соотношения сходятся |
| `templates/ББ до 01.01.2025.xlsx` | BALANCE `needs_check`, DISCLOSURE `done` | шаблон без сумм и шапки; «Примечания» шаблона — пустой DISCLOSURE |

View File

@ -12,9 +12,9 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
|---|---|---|
| `osv_headers.yaml` | `sheet_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `counterparty_inn_suffix` / `inn_only_row` / `inn_kpp_cell` (ИНН в тексте названия, строкой выше, «ИНН/КПП» в ячейке; обязательные именованные группы `tail`, `inn`), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки |
| `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `not_single_month` (тексты, где один месяц не задаёт границы периода: дата на число, квартал, «N месяцев»), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
| `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |
| `statement_headers.yaml` | `form_titles` (`BALANCE` / `INCOME` / `DISCLOSURE`), `okud` (коды ОКУД по типам), `continuation` (маркер страницы-продолжения), `code_caption`, `explanations_caption`, `column_numbering`, `labels` (метки шапки документа: `organization`, `inn`, `okpo`, `okopf`, `okfs`, `okopf_okfs`, `okei`, `units`, `address`, `date`), `auditor` (строка блока аудитора), `legal_form` (признак названия организации), `line_codes` (диапазоны кодов строк, доля и минимум кодов в колонке) | заголовок и тип формы ББ/ОФР (баланс / отчет о финансовых результатах / пояснения и расшифровки); ОКУД -> тип; признак продолжения страницы; подписи колонок таблицы; метки и структура шапки документа; диапазоны четырёхзначных кодов строк (§12) |
| `statement_headers.yaml` | `form_titles` (`BALANCE` / `INCOME` / `DISCLOSURE`), `okud` (коды ОКУД по типам), `continuation` (маркер страницы-продолжения), `code_caption`, `explanations_caption`, `column_numbering`, `labels` (метки шапки документа: `organization`, `inn`, `okpo`, `okopf`, `okfs`, `okopf_okfs`, `okei`, `units`, `address`, `date`), `auditor` (строка блока аудитора), `legal_form` (признак названия организации), `line_codes` (диапазоны кодов строк, доля и минимум кодов в колонке `min_codes`, ослабленный минимум для листа-продолжения после формы `min_codes_continuation`), `okud_code` (код ОКУД внутри ячейки), `units_thousands` (единицы в тысячах), `fractional_share_percent` (порог доли значений с дробной частью при «тыс. руб.», процент 1..100: при превышении — одно предупреждение на документ; на статус не влияет) | заголовок и тип формы ББ/ОФР (баланс / отчет о финансовых результатах / пояснения и расшифровки); ОКУД -> тип; признак продолжения страницы; подписи колонок таблицы; метки и структура шапки документа; диапазоны четырёхзначных кодов строк (§12) |
| `statement_periods.yaml` | `months` (названия месяцев, группы `m01…m12`), `on_date` (дата вида «на 31 декабря 2024 г.», группы `d`, `m`, `y`), `numeric_date` (дата вида «31.12.2024», группы `d`, `m`, `y`), `for_months` (период вида «за 9 месяцев 2025 г.», группы `n`, `y`), `for_range` (диапазон месяцев вида «за январь–сентябрь 2025 г.», группы `m`, `y`), `for_year` (год вида «за 2024 г.» или «2024») | текст заголовка колонки или подзаголовка формы ББ/ОФР -> дата конца периода (§12 п. 4) |
| `statement_checks.yaml` | `tolerance` (допуск в единицах формы), `subline_check` (проверка суммы пятизначных подстрок = родитель), `relations` (список соотношений: `form`, `relation` («код = код [+|-] код …»), `strict`, `abs_terms`) | соотношения между кодами строк баланса и отчета о финансовых результатах (§7), сумма баланса = итого активы = итого пассивы и т.д., как данные (новое соотношение = строка в YAML, не код) |

View File

@ -38,3 +38,8 @@ range: '(?P<m1>(январ|феврал|март|апрел|ма[йя]|июн|и
# ячейка целиком — период с годом; ищется, только если периода по меткам нет
standalone:
- '^(\d{1,2}|[ivx]+)\s*(й|я|ый|ой)?\s*(квартал\w*|кв\b\.?|мес\w*)\s*(19|20)\d{2}\s*(г\b\.?|года?)?$'
# Один месяц с годом («Февраль 2024 г.») — период этого месяца, но не когда это дата на число («на 31 декабря 2024 г.»,
# «по состоянию на 31 марта») или названа длительность («1 квартал», «полугодие», «9 месяцев», «год»): тогда границы —
# по кварталу словаря
not_single_month: '\b\d{1,2}\s*(январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)|кв\b|квартал|полугоди|\d\s*мес|\bгод\b|годов'

View File

@ -25,6 +25,12 @@ okud:
DISCLOSURE:
- '\b07100(0(3|4|5|6|7|8|9)|(1|2|3|4|5|6|7|8|9)\d)\b'
# Сам код ОКУД формы внутри ячейки («Форма по ОКУД 0710001» -> «0710001»)
okud_code: '0710\d{3}'
# Единицы измерения в тысячах: «тыс. руб.», «в тыс. рублей» (для предупреждения о дробных значениях)
units_thousands: 'тыс'
# Маркер страницы-продолжения в 1С-выгрузке: «Форма 0710001 с.2»
continuation:
- '\bформа\s+0710\d{3}\s+с\.?\s*\d+'
@ -82,6 +88,8 @@ line_codes:
DISCLOSURE: [5000, 5999]
min_share: 0.6
min_codes: 3
# лист, идущий после листа формы: хватает одного кода (лист-продолжение с единственной строкой, напр. 2400 у НПЗ)
min_codes_continuation: 1
# Порог для предупреждения о дробных значениях при единицах в тыс. руб. (процент; 1..100)
fractional_share_percent: 10

View File

@ -23,6 +23,7 @@ from src.parser.readers import read_book
from src.parser.readers._common import one_line_reason
from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR
from src.parser.rules import Rules
from src.parser.statements.detect import is_weak_continuation
__all__ = ["BookParser", "StatementSheetParser"]
@ -105,6 +106,10 @@ class BookParser:
if kind in STATEMENT_KINDS:
statement_sheets.append((sheet, kind))
continue
if kind is SheetKind.OTHER and statement_sheets and is_weak_continuation(sheet, self._rules):
kind = SheetKind.STATEMENT_CONTINUATION # O-9: лист с 1–2 кодами после листа формы
statement_sheets.append((sheet, kind))
continue
if kind is SheetKind.OTHER:
warnings.append(sheet_line(sheet, "skipped: нет признаков ОСВ или формы"))
continue

View File

@ -249,6 +249,8 @@ class PeriodRules(_Model):
months: Pattern
range: Pattern
standalone: list[Pattern]
# Текст, в котором один месяц не задаёт границы периода: дата на число, квартал, полугодие, «N месяцев»
not_single_month: Pattern
@model_validator(mode="after")
def _check_month_patterns(self) -> "PeriodRules":
@ -299,6 +301,8 @@ class PeriodRules(_Model):
end_year = int(found.group("y2"))
return MonthRange(1, end_year, end, end_year)
return None
if self.not_single_month.search(text):
return None
month, year = self.month_number(text), self.year.search(text)
if month is None or year is None:
return None
@ -344,6 +348,7 @@ class _LineCodeRules(_Model):
ranges: dict[FormType, tuple[int, int]]
min_share: float
min_codes: int
min_codes_continuation: int
@model_validator(mode="after")
def _check(self) -> "_LineCodeRules":
@ -351,12 +356,16 @@ class _LineCodeRules(_Model):
raise ValueError(f"line_codes.ranges: нужны все формы {get_args(FormType)}")
if not 0 < self.min_share <= 1 or self.min_codes < 1:
raise ValueError("line_codes: min_share в (0, 1], min_codes ≥ 1")
if not 1 <= self.min_codes_continuation <= self.min_codes:
raise ValueError("line_codes: min_codes_continuation в [1, min_codes]")
return self
class StatementHeaderRules(_Model):
form_titles: dict[FormType, list[Pattern]]
okud: dict[FormType, list[Pattern]]
okud_code: Pattern
units_thousands: Pattern
continuation: list[Pattern]
code_caption: list[Pattern]
explanations_caption: list[Pattern]
@ -391,6 +400,9 @@ class StatementHeaderRules(_Model):
entries = ((p, k) for k, patterns in self.okud.items() for p in patterns)
return first_match(entries, text)
def is_thousands(self, text: str) -> bool:
return self.units_thousands.search(text) is not None
def is_continuation(self, text: str) -> bool:
return any(pattern.search(text) for pattern in self.continuation)
@ -425,6 +437,10 @@ class StatementHeaderRules(_Model):
def min_codes(self) -> int:
return self.line_codes.min_codes
@property
def min_codes_continuation(self) -> int:
return self.line_codes.min_codes_continuation
class StatementPeriodRules(_Model):
months: Pattern

View File

@ -53,6 +53,7 @@ class TableLayout:
periods: tuple[PeriodColumn, ...]
skipped_labels: tuple[str, ...] # заголовки правее кодов без периода
first_data_row: int
note_cols: tuple[int, ...] = () # колонки «Пояснения» левее кодов: названием строки не служат
def cell_label(sheet: Sheet, row: int, col: int) -> str:
@ -70,8 +71,11 @@ def line_code(cell: Cell, rules: Rules) -> str | None:
return code if any(low <= head <= high for low, high in ranges) else None
def find_code_column(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> int | None:
"""Колонка с наибольшим числом кодов, если кодов ≥ `min_codes` и их доля среди непустых ≥ `min_share`."""
def find_code_column(sheet: Sheet, rules: Rules, first_row: int, last_row: int, *, relaxed: bool = False) -> int | None:
"""Колонка с наибольшим числом кодов, если кодов ≥ `min_codes` и их доля среди непустых ≥ `min_share`.
`relaxed` — порог листа-продолжения (`min_codes_continuation`): лист после листа формы может нести одну строку.
"""
counts: dict[int, list[int]] = {}
for row in range(first_row, last_row + 1):
for col, cell in sheet.row_cells(row):
@ -80,10 +84,11 @@ def find_code_column(sheet: Sheet, rules: Rules, first_row: int, last_row: int)
if line_code(cell, rules) is not None:
tally[0] += 1
headers = rules.statement_headers
min_codes = headers.min_codes_continuation if relaxed else headers.min_codes
best: int | None = None
for col in sorted(counts):
codes, nonempty = counts[col]
if codes < headers.min_codes or codes / nonempty < headers.code_min_share:
if codes < min_codes or codes / nonempty < headers.code_min_share:
continue
if best is None or codes > counts[best][0]:
best = col
@ -147,14 +152,20 @@ def _upper_header_row(sheet: Sheet, rules: Rules, row: int, code_col: int, first
cells = sheet.row_cells(above)
if any(cell.kind == "number" for _, cell in cells) or not any(col > code_col for col, _ in cells):
return row
glued, foreign = False, False
for col, _ in sheet.row_cells(row):
upper, lower = cell_label(sheet, above, col), cell_label(sheet, row, col)
# подпись над датой должна склеиваться с ней в период («На 31 декабря» + «2024 г.»); иначе это шапка документа
# («Единица измерения: тыс. руб.»), а не заголовок колонки
joins_into_period = rules.statement_periods.period_end(f"{upper} {lower}") is not None
if upper and col > code_col and not joins_into_period:
return row
return above
if not upper or col <= code_col:
continue
# подпись над датой склеивается с ней в период («На 31 декабря» + «2024 г.»); дата, которая уже полна без
# подписи, и подпись шапки документа над ней («Единица измерения: тыс. руб.») — не заголовок колонки
joined = rules.statement_periods.period_end(f"{upper} {lower}") is not None
complete = _is_period_label(lower, rules, allow_bare_year=False)
if joined and not complete:
glued = True
else:
foreign = True
return above if glued or not foreign else row
def _header_last_row(sheet: Sheet, rules: Rules, header_row: int, code_col: int, first_code_row: int) -> int:
@ -256,8 +267,17 @@ def _name_column( # noqa: PLR0913, PLR0917 - сигнатура соответ
return max(candidates, key=lambda col: (texts[col], col))
def find_layout(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> TableLayout:
code_col = find_code_column(sheet, rules, first_row, last_row)
def _note_columns(sheet: Sheet, rules: Rules, code_col: int, header_row: int, header_last_row: int) -> tuple[int, ...]:
return tuple(
col
for row in range(header_row, header_last_row + 1)
for col, _ in sheet.row_cells(row)
if col < code_col and rules.statement_headers.is_explanations_caption(cell_label(sheet, row, col))
)
def find_layout(sheet: Sheet, rules: Rules, first_row: int, last_row: int, *, relaxed: bool = False) -> TableLayout:
code_col = find_code_column(sheet, rules, first_row, last_row, relaxed=relaxed)
if code_col is None:
raise LayoutNotFoundError(NO_CODE_COLUMN)
first_code_row = _first_code_row(sheet, rules, code_col, first_row, last_row)
@ -269,4 +289,5 @@ def find_layout(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> Ta
header_last_row = _header_last_row(sheet, rules, header_row, code_col, first_code_row)
periods, skipped = _period_columns(sheet, rules, header_row, header_last_row, code_col)
name_col = _name_column(sheet, rules, code_col, header_row, header_last_row + 1, last_row)
return TableLayout(header_row, header_last_row, code_col, name_col, periods, skipped, header_last_row + 1)
notes = _note_columns(sheet, rules, code_col, header_row, header_last_row)
return TableLayout(header_row, header_last_row, code_col, name_col, periods, skipped, header_last_row + 1, notes)

View File

@ -6,7 +6,6 @@
колонкой кодов — продолжение.
"""
import re
from dataclasses import dataclass
from typing import Literal
@ -17,7 +16,6 @@ from src.parser.statements.columns import cell_label, find_code_column, line_cod
__all__ = ["FormBlock", "StatementSheetKind", "classify_statement_sheet", "continuation_form", "find_forms"]
StatementSheetKind = Literal["statement", "continuation", "other"]
_OKUD_CODE = re.compile(r"0710\d{3}")
@dataclass(frozen=True)
@ -49,11 +47,11 @@ def _candidates(sheet: Sheet, rules: Rules) -> list[_Candidate]:
kind = headers.form_type(label)
if kind is not None and title is None:
title, title_kind = label, kind
# Don't treat continuation markers as regular OKUD blocks
# маркер продолжения («Форма 0710001 с.2») — не заголовок нового блока по ОКУД
if not headers.is_continuation(label):
by_okud = headers.okud_type(label)
if by_okud is not None and okud is None:
code = _OKUD_CODE.search(label)
code = headers.okud_code.search(label)
okud, okud_kind = (code.group(0) if code else label), by_okud
if title is not None or okud is not None:
found.append(_Candidate(row, title, title_kind, okud, okud_kind))
@ -85,7 +83,9 @@ def find_forms(sheet: Sheet, rules: Rules) -> tuple[list[FormBlock], list[str]]:
for index, group in enumerate(groups):
title = next((item for item in group if item.title is not None), None)
okud = next((item for item in group if item.okud is not None), None)
kind = okud.okud_kind if okud is not None else title.title_kind # type: ignore[union-attr]
kind = (okud.okud_kind if okud is not None else None) or (title.title_kind if title is not None else None)
if kind is None: # группа всегда несёт заголовок или ОКУД с типом; защита от пустой группы
continue
if title is not None and okud is not None and title.title_kind != okud.okud_kind:
warnings.append(
f"лист «{sheet.name}»: заголовок «{title.title}» и ОКУД {okud.okud} противоречат, тип по ОКУД"
@ -94,7 +94,7 @@ def find_forms(sheet: Sheet, rules: Rules) -> tuple[list[FormBlock], list[str]]:
last_row = sheet.n_rows if index == len(groups) - 1 else groups[index + 1][0].row - 1
blocks.append(
FormBlock(
kind=kind, # type: ignore[arg-type]
kind=kind,
form_name=title.title if title is not None else None,
okud=okud.okud if okud is not None else None,
title_row=group[0].row,
@ -128,6 +128,12 @@ def _has_table_header(sheet: Sheet, rules: Rules) -> bool:
return False
def is_weak_continuation(sheet: Sheet, rules: Rules) -> bool:
"""Лист с кодами форм, но меньше `min_codes` (одна строка-продолжение). Годится лишь после листа формы: решает
вызывающий (`BookParser`), первая страница формы с таким числом кодов формой не считается."""
return find_code_column(sheet, rules, 1, sheet.n_rows, relaxed=True) is not None
def classify_statement_sheet(sheet: Sheet, rules: Rules) -> StatementSheetKind:
if find_forms(sheet, rules)[0]:
return "statement"

View File

@ -3,21 +3,21 @@
Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них
есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа — `numbers.to_money`
(Decimal, скобки → минус, прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не
нормализуются: при «тыс.» в единицах и доле значений с дробной частью ≥ порогу (из словаря) — предупреждение.
нормализуются; счётчики дробных значений нужны предупреждению документа (`statement.py`: одно на документ).
"""
from dataclasses import dataclass, field
from decimal import Decimal
from src.contract.result import StatementLine, StatementValue
from src.parser.grid import Sheet
from src.parser.numbers import to_money
from src.parser.rules import Rules, normalize_text
from src.parser.statements.columns import TableLayout, cell_label, line_code
from src.parser.rules import Rules
from src.parser.statements.columns import TableLayout, cell_label, is_numbering_row, line_code
__all__ = ["LinesResult", "collect_lines"]
_FULL_CONFIDENCE = 100
_THOUSANDS = "тыс"
@dataclass
@ -61,6 +61,27 @@ def _value(sheet: Sheet, row: int, col: int, column_index: int) -> tuple[Stateme
return value, False
def _is_sum(value: Decimal | None) -> bool:
"""Сумма — ненулевое число: нули в строках-заголовках разделов суммами не считаются."""
return value is not None and value != 0
def _line_name(sheet: Sheet, row: int, layout: TableLayout) -> str:
"""Название строки: ячейка колонки названий, а если она пуста («в том числе», итоги с отступом) — ближайшая
непустая текстовая ячейка левее колонки кодов, кроме колонки «Пояснения»."""
if layout.name_col is not None:
name = cell_label(sheet, row, layout.name_col)
if name:
return name
for col in range(layout.code_col - 1, 0, -1):
if col in layout.note_cols or sheet.cell(row, col).kind != "text":
continue
name = cell_label(sheet, row, col)
if name:
return name
return ""
def collect_lines( # noqa: PLR0913
sheet: Sheet,
rules: Rules,
@ -77,7 +98,9 @@ def collect_lines( # noqa: PLR0913
continue
code = line_code(sheet.cell(row, layout.code_col), rules)
if code is None:
if any(to_money(sheet.cell(row, column.col)).value is not None for column in layout.periods):
if is_numbering_row(sheet, row, rules): # «1 2 3 4 5», повторённая на разрыве страницы
continue
if any(_is_sum(to_money(sheet.cell(row, column.col)).value) for column in layout.periods):
result.skipped_with_sums += 1
continue
if code in seen:
@ -91,25 +114,16 @@ def collect_lines( # noqa: PLR0913
if value.amount is not None:
result.numeric_values += 1
result.fractional_values += int(fractional)
name = cell_label(sheet, row, layout.name_col) if layout.name_col is not None else ""
name = _line_name(sheet, row, layout)
result.lines.append(
StatementLine(
line_code=code,
line_code_confidence=_FULL_CONFIDENCE,
line_name=name,
line_name_confidence=_FULL_CONFIDENCE,
line_name_confidence=_FULL_CONFIDENCE if name else None,
values=values,
)
)
if result.skipped_with_sums:
result.warnings.append(f"строк без кода с суммами пропущено: {result.skipped_with_sums}")
if (
units
and _THOUSANDS in normalize_text(units)
and result.numeric_values
and result.fractional_values * 100 >= result.numeric_values * rules.statement_headers.fractional_share_percent
):
result.warnings.append(
f"значений с дробной частью при единице тыс. руб.: {result.fractional_values} из {result.numeric_values}"
)
return result

View File

@ -6,7 +6,7 @@ from src.contract.result import StatementHeader, StatementLine
from src.parser.rules import FormType
from src.parser.statements.columns import PeriodColumn, TableLayout
__all__ = ["Draft", "can_join", "join"]
__all__ = ["Draft", "can_join", "join", "join_blocker"]
@dataclass
@ -28,9 +28,22 @@ class Draft:
return {line.line_code for line in self.lines}
def can_join(draft: Draft, layout: TableLayout) -> bool:
"""Продолжение присоединяется, если число колонок-сумм совпало с открытым документом."""
return draft.has_code_column and len(layout.periods) == len(draft.periods)
def join_blocker(draft: Draft, layout: TableLayout, kind: FormType | None = None) -> str | None:
"""Почему продолжение нельзя присоединить к открытому документу (`None` — можно): у документа нет колонки кодов,
тип формы в маркере «Форма 0710002 с.2» отличается от типа документа, число колонок-сумм не совпало."""
if not draft.has_code_column:
return "у открытого документа нет колонки кодов"
if kind is not None and draft.kind is not None and kind != draft.kind:
return f"тип формы продолжения {kind} не совпадает с типом открытого документа {draft.kind}"
if len(layout.periods) != len(draft.periods):
return f"число колонок сумм {len(layout.periods)} не совпадает с открытым документом ({len(draft.periods)})"
return None
def can_join(draft: Draft, layout: TableLayout, kind: FormType | None = None) -> bool:
"""Продолжение присоединяется, если число колонок-сумм совпало с открытым документом, а тип формы из маркера (если
он есть) совпал с типом документа."""
return join_blocker(draft, layout, kind) is None
def join(draft: Draft, sheet_name: str, lines: list[StatementLine], duplicates: list[str]) -> None:

View File

@ -12,11 +12,11 @@ from src.parser.detect import SheetKind
from src.parser.grid import Sheet
from src.parser.rules import Rules
from src.parser.statements.checks import run_statement_checks
from src.parser.statements.columns import NO_CODE_COLUMN, LayoutNotFoundError, TableLayout, find_layout
from src.parser.statements.columns import NO_CODE_COLUMN, LayoutNotFoundError, TableLayout, find_layout, line_code
from src.parser.statements.detect import FormBlock, continuation_form, find_forms
from src.parser.statements.header import parse_header
from src.parser.statements.lines import collect_lines
from src.parser.statements.merge import Draft, can_join, join
from src.parser.statements.merge import Draft, can_join, join, join_blocker
__all__ = ["CONTINUATION_WARNING", "LOCATOR_PREFIX", "LOCATOR_SEPARATOR", "StatementParser"]
@ -77,13 +77,29 @@ class StatementParser:
def _continuation(self, sheet: Sheet, drafts: list[Draft], warnings: list[str]) -> bool:
"""`False` — лист потерян (нет колонки кодов): строки не попали ни в один документ, статус не `done`."""
weak = False
try:
layout = find_layout(sheet, self._rules, 1, sheet.n_rows)
except LayoutNotFoundError:
warnings.append(_line(sheet, NO_CODE_COLUMN))
return False
try: # лист с 1–2 кодами (O-9): допустим только как присоединяемое продолжение
layout = find_layout(sheet, self._rules, 1, sheet.n_rows, relaxed=True)
weak = True
except LayoutNotFoundError:
warnings.append(_line(sheet, NO_CODE_COLUMN))
return False
marker_kind, _ = continuation_form(sheet, self._rules)
open_draft = next((draft for draft in reversed(drafts) if draft.kind != "DISCLOSURE"), None)
if open_draft is not None and can_join(open_draft, layout):
if weak and (open_draft is None or not can_join(open_draft, layout, marker_kind)):
blocker = (
"нет открытого документа формы" if open_draft is None else join_blocker(open_draft, layout, marker_kind)
)
codes = sum(
1 for row in range(1, sheet.n_rows + 1) if line_code(sheet.cell(row, layout.code_col), self._rules)
)
minimum = self._rules.statement_headers.min_codes
warnings.append(_line(sheet, f"лист с кодами форм ({codes} < {minimum}) не присоединён: {blocker}"))
return False
if open_draft is not None and can_join(open_draft, layout, marker_kind):
result = collect_lines(
sheet, self._rules, layout, layout.first_data_row, sheet.n_rows, units=open_draft.header.units
)
@ -92,7 +108,7 @@ class StatementParser:
open_draft.numeric_values += result.numeric_values
warnings.extend(_line(sheet, text) for text in result.warnings)
return True
kind, _ = continuation_form(sheet, self._rules)
kind = marker_kind
header, _ = parse_header(sheet, self._rules, 1, (layout.header_row or layout.first_data_row) - 1, None)
draft = Draft(
kind=kind, header=header, periods=layout.periods, sheet_names=[sheet.name], lonely_continuation=True
@ -138,6 +154,7 @@ class StatementParser:
columns=columns if draft.has_code_column else [],
lines=[],
)
draft.warnings.extend(self._fractional_warning(draft))
checks, check_warnings = run_statement_checks(
draft.lines, len(columns), kind if kind != "OTHER" else "BALANCE", self._rules
)
@ -147,6 +164,18 @@ class StatementParser:
document_type=kind, locator=locator, header=draft.header, columns=columns, lines=draft.lines, checks=checks
)
def _fractional_warning(self, draft: Draft) -> list[str]:
"""Одно предупреждение на документ (все его листы): при «тыс.» в единицах доля значений с дробной частью ≥
порога словаря — вероятно, значения в рублях (OQ-137)."""
headers = self._rules.statement_headers
units = draft.header.units
if not (units and headers.is_thousands(units) and draft.numeric_values):
return []
if draft.fractional_values * 100 < draft.numeric_values * headers.fractional_share_percent:
return []
text = f"значений с дробной частью при единице тыс. руб.: {draft.fractional_values} из {draft.numeric_values}"
return [f"sheet={draft.sheet_names[0]} {text}"]
@staticmethod
def _is_ok(draft: Draft, document: StatementDocument) -> bool:
if document.document_type == "DISCLOSURE":

View File

@ -1,7 +1,8 @@
"""Покрытие словарей `rules/*.yaml` (design D-10, ruling R-22): каждая запись распознавания срабатывает хотя бы раз на
реальных ОСВ и синтетике; мёртвый синоним — повод удалить его или добавить файл синтетики `s14_*`.
Запись — один `Pattern` словаря (`sheet_markers`, `statement_markers`, `groups`, `debit`, `credit`, `service_columns`,
Запись — один `Pattern` словаря (`sheet_markers`, `form_titles`/`okud` форм, `groups`, `debit`, `credit`,
`service_columns`,
`total_row`, `header_markers`, `mapping`, правила периодов, `year`, `end`, `header`/`row` типов данных,
`currency_aliases`). Перечень кодов `currency_codes` — список значений, не синонимы: в покрытие не входит. Записи
находятся обходом моделей `Rules`, поэтому новый паттерн в любом словаре попадает под проверку автоматически.

View File

@ -347,5 +347,11 @@ async def test_real_statements_through_worker_give_needs_check_with_two_document
balance, income = stored["documents"]
assert [balance["document_type"], income["document_type"]] == ["BALANCE", "INCOME"]
assert {item["relation"] for item in balance["checks"]["relations_failed"]} == {"1600 = 1700"}
assert income["checks"]["relations_failed"] != []
assert {item["relation"] for item in income["checks"]["relations_failed"]} == {
"2100 = 2110 - 2120",
"2200 = 2100 - 2210 - 2220",
"2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350",
}
# лист с единственной строкой 2400 присоединён (O-9)
assert "2400" in {line["line_code"] for line in income["lines"]}
await core.assert_quiet(request)

View File

@ -43,7 +43,7 @@ files:
inn_optional_for: [INCOME]
documents:
- {type: BALANCE, status: needs_check, complete: false, sheets: 5, reason: "1600 = 1700 расходится на 1.94/9.02/2.48 руб.: значения в рублях при шапке тыс. руб. (OQ-138)", failed_relations: ["1600 = 1700"]}
- {type: INCOME, status: needs_check, complete: false, sheets: 2, reason: "по факту: рубли с копейками при шапке тыс. руб., 2100/2200/2300 расходятся с округлёнными итогами (OQ-138); одностроечный лист продолжения с 2400 пропущен: меньше min_codes", failed_relations: ["2100 = 2110 - 2120", "2200 = 2100 - 2210 - 2220", "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350"]}
- {type: INCOME, status: needs_check, complete: false, sheets: 3, reason: "по факту: рубли с копейками при шапке тыс. руб., 2100/2200/2300 расходятся с округлёнными итогами (OQ-138); лист продолжения с единственной строкой 2400 присоединён (O-9)", failed_relations: ["2100 = 2110 - 2120", "2200 = 2100 - 2210 - 2220", "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350"]}
"mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx":
organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"'
inn: "7016006635"

View File

@ -557,7 +557,7 @@ def _s13() -> list[SheetSpec]:
def _s14() -> list[SheetSpec]:
"""Три листа форм, опознаваемые каждый одним своим признаком (покрытие `statement_markers`), и лист ОСВ."""
"""Три листа форм, опознаваемые каждый одним своим признаком (заголовки и ОКУД словаря форм), и лист ОСВ."""
return [
form_sheet("Форма 1", "Форма по ОКУД 0710001"),
form_sheet("ОФР", "Отчет о финансовых результатах"),

View File

@ -4,7 +4,7 @@ st01 — ББ и ОФР на одном листе; st02 — «csv-подобн
строки, колонка «Изменение, %», отрицательные в скобках; st03 — форма без колонки кодов; st04 — книга с листом ОСВ
и листом баланса; st05 — одинокая страница 2; st06 — варианты заголовков, шапки и подписей, которых нет в реальных
файлах (покрытие словарей: старое название ОФР, расшифровки, ОДДС, ОИК, целевое использование, «Пояснения»).
Детерминирован (см. `generate.py`).
st07 — лист-продолжение ОФР с одной кодовой строкой (O-9). Детерминирован (см. `generate.py`).
"""
import argparse
@ -176,6 +176,19 @@ def _st06() -> list[SheetSpec]:
return [*sheets, okud_only]
def _st07() -> list[SheetSpec]:
"""ОФР без строки 2400 на первом листе и лист-продолжение с единственной кодовой строкой 2400 (O-9, как у НПЗ)."""
first = SheetSpec("ОФР")
row = _head(first, 1, "Отчет о финансовых результатах", "за 2024 г.", "0710002")
_table(first, row, ["За 2024 г.", "За 2023 г."], INCOME_LINES[:-1])
tail = SheetSpec("ОФР_2")
tail.put(1, 2, INCOME_LINES[-1][0])
tail.put(1, 4, INCOME_LINES[-1][1])
tail.put(1, 5, INCOME_LINES[-1][2])
tail.put(1, 6, INCOME_LINES[-1][3])
return [first, tail]
FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSpec]], bytes]]] = {
"st01_both_forms_one_sheet.xlsx": (_st01, to_xlsx),
"st02_csv_like.xlsx": (_st02, to_xlsx),
@ -183,6 +196,7 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
"st04_mixed_book.xlsx": (_st04, to_xlsx),
"st05_continuation_only.xlsx": (_st05, to_xlsx),
"st06_header_variants.xlsx": (_st06, to_xlsx),
"st07_single_code_continuation.xlsx": (_st07, to_xlsx),
}
FILE_NAMES = tuple(FILES)
@ -199,7 +213,7 @@ def generate_all(directory: Path) -> list[Path]:
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m tests.synthetic.generate_statements", description="Синтетические формы st01…st06."
prog="python -m tests.synthetic.generate_statements", description="Синтетические формы st01…st07."
)
parser.add_argument(
"directory", type=Path, help="каталог назначения, например ../../fixtures/statements-xlsx/synthetic"

View File

@ -9,6 +9,7 @@ EXPECTED = (
"st04_mixed_book.xlsx",
"st05_continuation_only.xlsx",
"st06_header_variants.xlsx",
"st07_single_code_continuation.xlsx",
)

View File

@ -237,6 +237,18 @@ def test_st06_header_variants_documents_and_header(fixtures_dir: Path) -> None:
assert income.checks.relations_failed == []
def test_st07_single_code_continuation_joins_the_income_form(fixtures_dir: Path) -> None:
"""st07 (O-9): лист с единственной кодовой строкой 2400 присоединяется к ОФР; без него 2400 терялась бы."""
outcome = parse_named(fixtures_dir, "st07_single_code_continuation.xlsx")
document = one_statement(outcome)
assert document.document_type == "INCOME"
assert document.locator == "sheet:ОФР,ОФР_2"
assert [line.line_code for line in document.lines][-1] == "2400"
assert document.checks is not None and document.checks.relations_failed == []
assert outcome.status is ResultStatus.DONE
# --- Валидация контракта ---

View File

@ -227,6 +227,47 @@ def test_layout_document_header_above_dates_is_not_upper_header_row(rules: Rules
assert len(layout.periods) == 2 # noqa: PLR2004
def test_layout_upper_row_kept_when_a_foreign_label_sits_above_another_column(rules: Rules) -> None:
"""T13.5: верхняя строка берётся, если хоть одна колонка в ней склеилась в период, хотя над соседней — подпись
без периода («Примечание»)."""
sheet = sheet_from_rows(
[
["Показатель", None, "На 31 декабря", "На 31 декабря", "Примечание"],
[None, None, "2024 г.", "2023 г.", "Изм."],
["Основные средства", "1150", 100, 90, 5],
["Итого по разделу I", "1100", 100, 90, 5],
["Запасы", "1210", 50, 40, 5],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
assert layout.header_row == 1
assert [column.period_date for column in layout.periods] == [
datetime.date(2024, 12, 31),
datetime.date(2023, 12, 31),
]
assert layout.skipped_labels == ("Примечание Изм.",)
def test_layout_units_label_above_a_complete_date_is_not_glued(rules: Rules) -> None:
"""T13.3/T13.5 (в обратную сторону): «Единица измерения» над уже полной датой в той же колонке — не заголовок."""
sheet = sheet_from_rows(
[
[None, None, "Единица измерения: тыс. руб."],
[None, None, "на 31 декабря 2024 г."],
["Основные средства", "1150", 100],
["Итого по разделу I", "1100", 100],
["Запасы", "1210", 50],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
assert layout.header_row == 2 # noqa: PLR2004
assert [column.label for column in layout.periods] == ["на 31 декабря 2024 г."]
def test_layout_continuation_without_header(rules: Rules) -> None:
sheet = sheet_from_rows(
[

View File

@ -12,7 +12,7 @@ from src.parser.statements.lines import collect_lines
_FULL_CONFIDENCE = 100
_FRACTIONAL_VALUES_COUNT = 4
_NUMERIC_VALUES_COUNT = 9
_SKIPPED_WITH_SUMS_COUNT = 1
_SKIPPED_WITH_SUMS_COUNT = 0
_BOUNDARY_NUMERIC_VALUES = 20
_BOUNDARY_FRACTIONAL_VALUES = 2
_BELOW_THRESHOLD_NUMERIC_VALUES = 12
@ -58,35 +58,13 @@ def test_lines_values_and_skips(rules: Rules) -> None:
assert all(
line.line_code_confidence == _FULL_CONFIDENCE and line.line_name_confidence == _FULL_CONFIDENCE
for line in result.lines
)
) # у всех строк название есть
assert [value.column_index for value in by_code["1110"].values] == [0, 1]
assert result.duplicates == ["1210"] and by_code["1210"].values[1].amount == Decimal("5.00")
assert result.skipped_with_sums == _SKIPPED_WITH_SUMS_COUNT # строка нулей без кода
assert result.skipped_with_sums == _SKIPPED_WITH_SUMS_COUNT # строка нулей без кода суммой не считается (M-8)
assert result.fractional_values == _FRACTIONAL_VALUES_COUNT and result.numeric_values == _NUMERIC_VALUES_COUNT
def test_fractional_warning_when_units_thousands(rules: Rules) -> None:
sheet = sheet_from_rows(ROWS)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows, units="в тыс. рублей")
expected_warning = (
f"значений с дробной частью при единице тыс. руб.: {_FRACTIONAL_VALUES_COUNT} из {_NUMERIC_VALUES_COUNT}"
)
assert any(warning.startswith(expected_warning) for warning in result.warnings)
assert any("строк без кода с суммами пропущено: 1" in warning for warning in result.warnings)
def test_no_fractional_warning_without_thousands(rules: Rules) -> None:
sheet = sheet_from_rows(ROWS)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows, units="руб.")
assert not any("дробной частью" in warning for warning in result.warnings)
def test_line_without_name_column(rules: Rules) -> None:
sheet = sheet_from_rows([["Код", "На 31.12.2024"], ["1150", 5], ["1210", 6], ["1600", 11]])
layout = find_layout(sheet, rules, 1, 4)
@ -120,7 +98,7 @@ def test_fractional_warning_at_boundary_10_percent(rules: Rules) -> None:
# Expect 20 numeric values and 2 fractional (10%)
assert result.numeric_values == _BOUNDARY_NUMERIC_VALUES and result.fractional_values == _BOUNDARY_FRACTIONAL_VALUES
assert any("дробной частью" in warning for warning in result.warnings)
assert not result.warnings # предупреждение о дробных — на уровне документа (statement.py), здесь только счётчики
def test_no_fractional_warning_below_10_percent(rules: Rules) -> None:
@ -146,7 +124,6 @@ def test_no_fractional_warning_below_10_percent(rules: Rules) -> None:
result.numeric_values == _BELOW_THRESHOLD_NUMERIC_VALUES
and result.fractional_values == _BELOW_THRESHOLD_FRACTIONAL_VALUES
)
assert not any("дробной частью" in warning for warning in result.warnings)
def test_no_crash_with_zero_numeric_values(rules: Rules) -> None:
@ -165,4 +142,42 @@ def test_no_crash_with_zero_numeric_values(rules: Rules) -> None:
assert result.numeric_values == 0
assert result.fractional_values == 0
assert not any("дробной частью" in warning for warning in result.warnings)
def test_numbering_row_and_zero_row_are_not_skipped_sums(rules: Rules) -> None:
"""M-8: «1 2 3 4 5» на разрыве страницы и строка нулей без кода не считаются «строкой без кода с суммами»."""
rows = [
["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."],
["Основные средства", "1150", 100, 90],
["1", "2", "3", "4"],
["Раздел", None, 0, 0],
["Запасы", "1210", 50, 40],
["Итого", "1200", 50, 40],
["Без кода", None, 7, 8],
]
sheet = sheet_from_rows(rows)
layout = find_layout(sheet, rules, 1, len(rows))
result = collect_lines(sheet, rules, layout, layout.first_data_row, len(rows))
assert result.skipped_with_sums == 1 # только «Без кода»
assert result.warnings == ["строк без кода с суммами пропущено: 1"]
def test_empty_name_cell_takes_the_nearest_text_to_the_left_of_codes(rules: Rules) -> None:
"""I-3: название итога стоит в соседней колонке с отступом; пустое имя — не с уверенностью 100."""
rows = [
["Пояснения", "Наименование показателя", None, "Код", "На 31 декабря 2024 г."],
["1", "Основные средства", None, "1150", 100],
[None, None, "Итого по разделу I", "1100", 100],
[None, None, None, "1210", 5],
]
sheet = sheet_from_rows(rows)
layout = find_layout(sheet, rules, 1, len(rows))
result = collect_lines(sheet, rules, layout, layout.first_data_row, len(rows))
by_code = {line.line_code: line for line in result.lines}
assert by_code["1100"].line_name == "Итого по разделу I"
assert by_code["1100"].line_name_confidence == _FULL_CONFIDENCE
assert by_code["1210"].line_name == "" and by_code["1210"].line_name_confidence is None

View File

@ -301,3 +301,91 @@ def test_block_with_okud_but_without_title_has_no_form_name(parser: StatementPar
documents, _, _ = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline())
assert documents[0].header.form_name is None and documents[0].header.okud == "0710001"
def _fractional_sheet(name: str, units: bool, rows: list[list[object]]) -> tuple:
head = [["Бухгалтерский баланс"], ["Организация", None, 'ООО "Пример"']]
if units:
head.append(["Единица измерения:", None, "тыс. руб."])
return sheet_from_rows([*head, ["Наименование показателя", "Код", "На 31 декабря 2024 г."], *rows], name)
def test_fractional_warning_is_one_per_document_over_all_sheets(parser: StatementParser) -> None:
"""M-11: две страницы документа — одно предупреждение с суммарными счётчиками."""
first = _fractional_sheet(
"ББ1", True, [["Основные средства", "1150", Decimal("100.5")], ["Итого", "1100", 100], ["Запасы", "1210", 7]]
)
second = sheet_from_rows(
[
["Форма 0710001 с.2"],
["Наименование показателя", "Код", "На 31 декабря 2024 г."],
["Уставный капитал", "1310", Decimal("50.25")],
["Итого по разделу III", "1300", 50],
["БАЛАНС", "1700", 50],
],
"ББ2",
)
_, warnings, _ = parser.parse_sheets(
[(first, SheetKind.STATEMENT), (second, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
fractional = [text for text in warnings if "дробной частью" in text]
assert fractional == ["sheet=ББ1 значений с дробной частью при единице тыс. руб.: 2 из 6"]
def test_no_fractional_warning_without_thousands_units(parser: StatementParser) -> None:
sheet = _fractional_sheet("ББ", False, [["Основные средства", "1150", Decimal("100.5")], ["Итого", "1100", 100]])
_, warnings, _ = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline())
assert not any("дробной частью" in text for text in warnings)
def test_single_code_sheet_after_a_form_joins_as_continuation(parser: StatementParser) -> None:
"""O-9 (I-1): лист с одной кодовой строкой (меньше min_codes), идущий после формы, присоединяется."""
single = sheet_from_rows([["Результат прочих операций", "2520", 0]], "ОФР_2")
documents, warnings, _ = parser.parse_sheets(
[(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert len(documents) == 1
assert documents[0].locator == "sheet:ОФР,ОФР_2"
assert [line.line_code for line in documents[0].lines][-1] == "2520"
def test_single_code_sheet_that_cannot_join_is_dropped_with_exact_warning(parser: StatementParser) -> None:
"""O-9: число колонок сумм не совпало — лист не присоединён, книга needs_check, предупреждение точное."""
single = sheet_from_rows([["Чистая прибыль (убыток)", "2400", 160, 150]], "ОФР_2")
documents, warnings, ok = parser.parse_sheets(
[(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert not ok and len(documents) == 1
assert (
"sheet=ОФР_2 лист с кодами форм (1 < 3) не присоединён: "
"число колонок сумм 2 не совпадает с открытым документом (1)" in warnings
)
def test_continuation_of_another_form_type_does_not_join(parser: StatementParser) -> None:
"""M-10: маркер «Форма 0710001 с.2» (ББ) не присоединяет лист к открытому ОФР."""
income = sheet_from_rows(INCOME, "ОФР")
balance_page_2 = sheet_from_rows(
[
["Форма 0710001 с.2"],
["Наименование показателя", "Код", "За 2024 г."],
["Уставный капитал", "1310", 150],
["Итого по разделу III", "1300", 150],
["БАЛАНС", "1700", 150],
],
"ББ2",
)
documents, warnings, ok = parser.parse_sheets(
[(income, SheetKind.STATEMENT), (balance_page_2, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert [document.document_type for document in documents] == ["INCOME", "BALANCE"]
assert not ok and CONTINUATION_WARNING in " ".join(warnings)

View File

@ -549,6 +549,10 @@ def test_variant2_without_inn_column_leaves_has_inn_unset(rules: Rules) -> None:
("Январь - Февраль 2024 г.", datetime.date(2024, 1, 1), datetime.date(2024, 2, 29), True),
("Январь - Октябрь 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 10, 31), True),
("Февраль 2024 г.", datetime.date(2024, 2, 1), datetime.date(2024, 2, 29), False),
# I-2: дата на число и квартал с датой в скобках — не «одиночный месяц»
("на 31 декабря 2024 г.", datetime.date(2024, 1, 1), datetime.date(2024, 12, 31), True),
("1 квартал 2025 г. (по состоянию на 31 марта)", datetime.date(2025, 1, 1), datetime.date(2025, 3, 31), True),
("за 3 квартал 2025 г. на 30 сентября", datetime.date(2025, 7, 1), datetime.date(2025, 9, 30), False),
],
)
def test_period_bounds(

View File

@ -11,6 +11,7 @@ from src.parser.rules import DEFAULT_RULES_DIR, STATEMENT_CHECKS_FILE, Rules, lo
_CODE_MIN_SHARE = 0.6
_MIN_CODES = 3
_FRACTIONAL_SHARE_PERCENT = 10
@pytest.fixture(scope="module")
@ -136,3 +137,42 @@ def test_explicit_rules_reject_five_digit_codes(tmp_path: Path) -> None:
(tmp_path / STATEMENT_CHECKS_FILE).write_text(yaml.safe_dump(broken, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match="пятизначные коды допустимы только в сгенерированных подстроках"):
load_rules(tmp_path)
def _broken_headers(tmp_path: Path, **changes: object) -> None:
for name in DEFAULT_RULES_DIR.glob("*.yaml"):
(tmp_path / name.name).write_bytes(name.read_bytes())
path = tmp_path / "statement_headers.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
for key, value in changes.items():
if key == "min_codes_continuation":
data["line_codes"][key] = value
else:
data[key] = value
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
def test_fractional_share_percent_loaded_and_bounded(rules: Rules, tmp_path: Path) -> None:
"""M-7: порог предупреждения о дробных значениях — из словаря, диапазон 1..100."""
assert rules.statement_headers.fractional_share_percent == _FRACTIONAL_SHARE_PERCENT
for bad in (0, 101):
_broken_headers(tmp_path, fractional_share_percent=bad)
with pytest.raises(RuntimeError, match="fractional_share_percent"):
load_rules(tmp_path)
def test_min_codes_continuation_loaded_and_bounded(rules: Rules, tmp_path: Path) -> None:
"""I-1: ослабленный порог листа-продолжения — из словаря, 1 ≤ значение ≤ min_codes."""
assert rules.statement_headers.min_codes_continuation == 1
for bad in (0, _MIN_CODES + 1):
_broken_headers(tmp_path, min_codes_continuation=bad)
with pytest.raises(RuntimeError, match="min_codes_continuation"):
load_rules(tmp_path)
def test_units_thousands_and_okud_code_from_dictionary(rules: Rules) -> None:
"""M-5: слово «тыс» и код ОКУД берутся из словаря, а не из литералов кода."""
headers = rules.statement_headers
assert headers.is_thousands("в тыс. рублей") and not headers.is_thousands("руб.")
found = headers.okud_code.search("Форма по ОКУД 0710002")
assert found is not None and found.group(0) == "0710002"