feat(statements): парсер ББ и ОФР из Excel, контракт v1.2 (Срез 2b) #16
13
README.md
13
README.md
@ -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 |
|
||||
|
||||
@ -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, не код) |
|
||||
|
||||
|
||||
@ -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|годов'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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":
|
||||
|
||||
3
api/tests/fixtures/test_rules_coverage.py
vendored
3
api/tests/fixtures/test_rules_coverage.py
vendored
@ -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`, поэтому новый паттерн в любом словаре попадает под проверку автоматически.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -557,7 +557,7 @@ def _s13() -> list[SheetSpec]:
|
||||
|
||||
|
||||
def _s14() -> list[SheetSpec]:
|
||||
"""Три листа форм, опознаваемые каждый одним своим признаком (покрытие `statement_markers`), и лист ОСВ."""
|
||||
"""Три листа форм, опознаваемые каждый одним своим признаком (заголовки и ОКУД словаря форм), и лист ОСВ."""
|
||||
return [
|
||||
form_sheet("Форма 1", "Форма по ОКУД 0710001"),
|
||||
form_sheet("ОФР", "Отчет о финансовых результатах"),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -9,6 +9,7 @@ EXPECTED = (
|
||||
"st04_mixed_book.xlsx",
|
||||
"st05_continuation_only.xlsx",
|
||||
"st06_header_variants.xlsx",
|
||||
"st07_single_code_continuation.xlsx",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# --- Валидация контракта ---
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
[
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user