feat(statements): парсер ББ и ОФР из Excel, контракт v1.2 (Срез 2b) #16
13
README.md
13
README.md
@ -147,7 +147,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») |
|
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») |
|
||||||
|
|
||||||
В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы
|
В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы
|
||||||
(`skipped: нет признаков ОСВ`), несовпадение `file_type` с содержимым, свёрнутое
|
(`skipped: нет признаков ОСВ или формы`), несовпадение `file_type` с содержимым, свёрнутое
|
||||||
сальдо, нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ).
|
сальдо, нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ).
|
||||||
|
|
||||||
### Контрольные суммы (§1.4)
|
### Контрольные суммы (§1.4)
|
||||||
@ -275,11 +275,14 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63
|
|||||||
|
|
||||||
### Ограничения
|
### Ограничения
|
||||||
|
|
||||||
- Единицы не нормализуются: `amount` как в ячейке, `units` — текст шапки. При «тыс. руб.» и значениях с копейками
|
- Единицы не нормализуются: `amount` как в ячейке, `units` — текст шапки. При «тыс. руб.» и доле значений с дробной
|
||||||
выдаётся предупреждение; такие файлы дают `needs_check` (OQ-138).
|
частью не меньше `fractional_share_percent` выдаётся одно предупреждение на документ; само оно на статус не влияет —
|
||||||
|
`needs_check` задают нарушенные соотношения (при рублях с копейками итоги расходятся, OQ-138).
|
||||||
- Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся
|
- Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся
|
||||||
одиноким продолжением и даёт `needs_check`.
|
одиноким продолжением и даёт `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/ББ. Лист 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/ОФР. Лист 1 3кв.25.xls` | INCOME `needs_check` | рубли при шапке «тыс. руб.»: расхождения 2100/2200/2300 (OQ-138) |
|
||||||
| `unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls` | INCOME `needs_check` | страница 2 без шапки |
|
| `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_Форма1_бух_09.xlsx` | BALANCE `done` | все соотношения сходятся |
|
||||||
| `mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx` | INCOME `done` | все соотношения сходятся |
|
| `mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx` | INCOME `done` | все соотношения сходятся |
|
||||||
| `templates/ББ до 01.01.2025.xlsx` | BALANCE `needs_check`, DISCLOSURE `done` | шаблон без сумм и шапки; «Примечания» шаблона — пустой DISCLOSURE |
|
| `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_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` — для всего прочего) |
|
| `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`) |
|
| `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_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, не код) |
|
| `statement_checks.yaml` | `tolerance` (допуск в единицах формы), `subline_check` (проверка суммы пятизначных подстрок = родитель), `relations` (список соотношений: `form`, `relation` («код = код [+|-] код …»), `strict`, `abs_terms`) | соотношения между кодами строк баланса и отчета о финансовых результатах (§7), сумма баланса = итого активы = итого пассивы и т.д., как данные (новое соотношение = строка в YAML, не код) |
|
||||||
|
|
||||||
|
|||||||
@ -38,3 +38,8 @@ range: '(?P<m1>(январ|феврал|март|апрел|ма[йя]|июн|и
|
|||||||
# ячейка целиком — период с годом; ищется, только если периода по меткам нет
|
# ячейка целиком — период с годом; ищется, только если периода по меткам нет
|
||||||
standalone:
|
standalone:
|
||||||
- '^(\d{1,2}|[ivx]+)\s*(й|я|ый|ой)?\s*(квартал\w*|кв\b\.?|мес\w*)\s*(19|20)\d{2}\s*(г\b\.?|года?)?$'
|
- '^(\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:
|
DISCLOSURE:
|
||||||
- '\b07100(0(3|4|5|6|7|8|9)|(1|2|3|4|5|6|7|8|9)\d)\b'
|
- '\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»
|
# Маркер страницы-продолжения в 1С-выгрузке: «Форма 0710001 с.2»
|
||||||
continuation:
|
continuation:
|
||||||
- '\bформа\s+0710\d{3}\s+с\.?\s*\d+'
|
- '\bформа\s+0710\d{3}\s+с\.?\s*\d+'
|
||||||
@ -82,6 +88,8 @@ line_codes:
|
|||||||
DISCLOSURE: [5000, 5999]
|
DISCLOSURE: [5000, 5999]
|
||||||
min_share: 0.6
|
min_share: 0.6
|
||||||
min_codes: 3
|
min_codes: 3
|
||||||
|
# лист, идущий после листа формы: хватает одного кода (лист-продолжение с единственной строкой, напр. 2400 у НПЗ)
|
||||||
|
min_codes_continuation: 1
|
||||||
|
|
||||||
# Порог для предупреждения о дробных значениях при единицах в тыс. руб. (процент; 1..100)
|
# Порог для предупреждения о дробных значениях при единицах в тыс. руб. (процент; 1..100)
|
||||||
fractional_share_percent: 10
|
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._common import one_line_reason
|
||||||
from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR
|
from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR
|
||||||
from src.parser.rules import Rules
|
from src.parser.rules import Rules
|
||||||
|
from src.parser.statements.detect import is_weak_continuation
|
||||||
|
|
||||||
__all__ = ["BookParser", "StatementSheetParser"]
|
__all__ = ["BookParser", "StatementSheetParser"]
|
||||||
|
|
||||||
@ -105,6 +106,10 @@ class BookParser:
|
|||||||
if kind in STATEMENT_KINDS:
|
if kind in STATEMENT_KINDS:
|
||||||
statement_sheets.append((sheet, kind))
|
statement_sheets.append((sheet, kind))
|
||||||
continue
|
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:
|
if kind is SheetKind.OTHER:
|
||||||
warnings.append(sheet_line(sheet, "skipped: нет признаков ОСВ или формы"))
|
warnings.append(sheet_line(sheet, "skipped: нет признаков ОСВ или формы"))
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -249,6 +249,8 @@ class PeriodRules(_Model):
|
|||||||
months: Pattern
|
months: Pattern
|
||||||
range: Pattern
|
range: Pattern
|
||||||
standalone: list[Pattern]
|
standalone: list[Pattern]
|
||||||
|
# Текст, в котором один месяц не задаёт границы периода: дата на число, квартал, полугодие, «N месяцев»
|
||||||
|
not_single_month: Pattern
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_month_patterns(self) -> "PeriodRules":
|
def _check_month_patterns(self) -> "PeriodRules":
|
||||||
@ -299,6 +301,8 @@ class PeriodRules(_Model):
|
|||||||
end_year = int(found.group("y2"))
|
end_year = int(found.group("y2"))
|
||||||
return MonthRange(1, end_year, end, end_year)
|
return MonthRange(1, end_year, end, end_year)
|
||||||
return None
|
return None
|
||||||
|
if self.not_single_month.search(text):
|
||||||
|
return None
|
||||||
month, year = self.month_number(text), self.year.search(text)
|
month, year = self.month_number(text), self.year.search(text)
|
||||||
if month is None or year is None:
|
if month is None or year is None:
|
||||||
return None
|
return None
|
||||||
@ -344,6 +348,7 @@ class _LineCodeRules(_Model):
|
|||||||
ranges: dict[FormType, tuple[int, int]]
|
ranges: dict[FormType, tuple[int, int]]
|
||||||
min_share: float
|
min_share: float
|
||||||
min_codes: int
|
min_codes: int
|
||||||
|
min_codes_continuation: int
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check(self) -> "_LineCodeRules":
|
def _check(self) -> "_LineCodeRules":
|
||||||
@ -351,12 +356,16 @@ class _LineCodeRules(_Model):
|
|||||||
raise ValueError(f"line_codes.ranges: нужны все формы {get_args(FormType)}")
|
raise ValueError(f"line_codes.ranges: нужны все формы {get_args(FormType)}")
|
||||||
if not 0 < self.min_share <= 1 or self.min_codes < 1:
|
if not 0 < self.min_share <= 1 or self.min_codes < 1:
|
||||||
raise ValueError("line_codes: min_share в (0, 1], 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
|
return self
|
||||||
|
|
||||||
|
|
||||||
class StatementHeaderRules(_Model):
|
class StatementHeaderRules(_Model):
|
||||||
form_titles: dict[FormType, list[Pattern]]
|
form_titles: dict[FormType, list[Pattern]]
|
||||||
okud: dict[FormType, list[Pattern]]
|
okud: dict[FormType, list[Pattern]]
|
||||||
|
okud_code: Pattern
|
||||||
|
units_thousands: Pattern
|
||||||
continuation: list[Pattern]
|
continuation: list[Pattern]
|
||||||
code_caption: list[Pattern]
|
code_caption: list[Pattern]
|
||||||
explanations_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)
|
entries = ((p, k) for k, patterns in self.okud.items() for p in patterns)
|
||||||
return first_match(entries, text)
|
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:
|
def is_continuation(self, text: str) -> bool:
|
||||||
return any(pattern.search(text) for pattern in self.continuation)
|
return any(pattern.search(text) for pattern in self.continuation)
|
||||||
|
|
||||||
@ -425,6 +437,10 @@ class StatementHeaderRules(_Model):
|
|||||||
def min_codes(self) -> int:
|
def min_codes(self) -> int:
|
||||||
return self.line_codes.min_codes
|
return self.line_codes.min_codes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def min_codes_continuation(self) -> int:
|
||||||
|
return self.line_codes.min_codes_continuation
|
||||||
|
|
||||||
|
|
||||||
class StatementPeriodRules(_Model):
|
class StatementPeriodRules(_Model):
|
||||||
months: Pattern
|
months: Pattern
|
||||||
|
|||||||
@ -53,6 +53,7 @@ class TableLayout:
|
|||||||
periods: tuple[PeriodColumn, ...]
|
periods: tuple[PeriodColumn, ...]
|
||||||
skipped_labels: tuple[str, ...] # заголовки правее кодов без периода
|
skipped_labels: tuple[str, ...] # заголовки правее кодов без периода
|
||||||
first_data_row: int
|
first_data_row: int
|
||||||
|
note_cols: tuple[int, ...] = () # колонки «Пояснения» левее кодов: названием строки не служат
|
||||||
|
|
||||||
|
|
||||||
def cell_label(sheet: Sheet, row: int, col: int) -> str:
|
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
|
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:
|
def find_code_column(sheet: Sheet, rules: Rules, first_row: int, last_row: int, *, relaxed: bool = False) -> int | None:
|
||||||
"""Колонка с наибольшим числом кодов, если кодов ≥ `min_codes` и их доля среди непустых ≥ `min_share`."""
|
"""Колонка с наибольшим числом кодов, если кодов ≥ `min_codes` и их доля среди непустых ≥ `min_share`.
|
||||||
|
|
||||||
|
`relaxed` — порог листа-продолжения (`min_codes_continuation`): лист после листа формы может нести одну строку.
|
||||||
|
"""
|
||||||
counts: dict[int, list[int]] = {}
|
counts: dict[int, list[int]] = {}
|
||||||
for row in range(first_row, last_row + 1):
|
for row in range(first_row, last_row + 1):
|
||||||
for col, cell in sheet.row_cells(row):
|
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:
|
if line_code(cell, rules) is not None:
|
||||||
tally[0] += 1
|
tally[0] += 1
|
||||||
headers = rules.statement_headers
|
headers = rules.statement_headers
|
||||||
|
min_codes = headers.min_codes_continuation if relaxed else headers.min_codes
|
||||||
best: int | None = None
|
best: int | None = None
|
||||||
for col in sorted(counts):
|
for col in sorted(counts):
|
||||||
codes, nonempty = counts[col]
|
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
|
continue
|
||||||
if best is None or codes > counts[best][0]:
|
if best is None or codes > counts[best][0]:
|
||||||
best = col
|
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)
|
cells = sheet.row_cells(above)
|
||||||
if any(cell.kind == "number" for _, cell in cells) or not any(col > code_col for col, _ in cells):
|
if any(cell.kind == "number" for _, cell in cells) or not any(col > code_col for col, _ in cells):
|
||||||
return row
|
return row
|
||||||
|
glued, foreign = False, False
|
||||||
for col, _ in sheet.row_cells(row):
|
for col, _ in sheet.row_cells(row):
|
||||||
upper, lower = cell_label(sheet, above, col), cell_label(sheet, row, col)
|
upper, lower = cell_label(sheet, above, col), cell_label(sheet, row, col)
|
||||||
# подпись над датой должна склеиваться с ней в период («На 31 декабря» + «2024 г.»); иначе это шапка документа
|
if not upper or col <= code_col:
|
||||||
# («Единица измерения: тыс. руб.»), а не заголовок колонки
|
continue
|
||||||
joins_into_period = rules.statement_periods.period_end(f"{upper} {lower}") is not None
|
# подпись над датой склеивается с ней в период («На 31 декабря» + «2024 г.»); дата, которая уже полна без
|
||||||
if upper and col > code_col and not joins_into_period:
|
# подписи, и подпись шапки документа над ней («Единица измерения: тыс. руб.») — не заголовок колонки
|
||||||
return row
|
joined = rules.statement_periods.period_end(f"{upper} {lower}") is not None
|
||||||
return above
|
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:
|
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))
|
return max(candidates, key=lambda col: (texts[col], col))
|
||||||
|
|
||||||
|
|
||||||
def find_layout(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> TableLayout:
|
def _note_columns(sheet: Sheet, rules: Rules, code_col: int, header_row: int, header_last_row: int) -> tuple[int, ...]:
|
||||||
code_col = find_code_column(sheet, rules, first_row, last_row)
|
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:
|
if code_col is None:
|
||||||
raise LayoutNotFoundError(NO_CODE_COLUMN)
|
raise LayoutNotFoundError(NO_CODE_COLUMN)
|
||||||
first_code_row = _first_code_row(sheet, rules, code_col, first_row, last_row)
|
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)
|
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)
|
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)
|
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 dataclasses import dataclass
|
||||||
from typing import Literal
|
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"]
|
__all__ = ["FormBlock", "StatementSheetKind", "classify_statement_sheet", "continuation_form", "find_forms"]
|
||||||
|
|
||||||
StatementSheetKind = Literal["statement", "continuation", "other"]
|
StatementSheetKind = Literal["statement", "continuation", "other"]
|
||||||
_OKUD_CODE = re.compile(r"0710\d{3}")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -49,11 +47,11 @@ def _candidates(sheet: Sheet, rules: Rules) -> list[_Candidate]:
|
|||||||
kind = headers.form_type(label)
|
kind = headers.form_type(label)
|
||||||
if kind is not None and title is None:
|
if kind is not None and title is None:
|
||||||
title, title_kind = label, kind
|
title, title_kind = label, kind
|
||||||
# Don't treat continuation markers as regular OKUD blocks
|
# маркер продолжения («Форма 0710001 с.2») — не заголовок нового блока по ОКУД
|
||||||
if not headers.is_continuation(label):
|
if not headers.is_continuation(label):
|
||||||
by_okud = headers.okud_type(label)
|
by_okud = headers.okud_type(label)
|
||||||
if by_okud is not None and okud is None:
|
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
|
okud, okud_kind = (code.group(0) if code else label), by_okud
|
||||||
if title is not None or okud is not None:
|
if title is not None or okud is not None:
|
||||||
found.append(_Candidate(row, title, title_kind, okud, okud_kind))
|
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):
|
for index, group in enumerate(groups):
|
||||||
title = next((item for item in group if item.title is not None), None)
|
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)
|
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:
|
if title is not None and okud is not None and title.title_kind != okud.okud_kind:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"лист «{sheet.name}»: заголовок «{title.title}» и ОКУД {okud.okud} противоречат, тип по ОКУД"
|
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
|
last_row = sheet.n_rows if index == len(groups) - 1 else groups[index + 1][0].row - 1
|
||||||
blocks.append(
|
blocks.append(
|
||||||
FormBlock(
|
FormBlock(
|
||||||
kind=kind, # type: ignore[arg-type]
|
kind=kind,
|
||||||
form_name=title.title if title is not None else None,
|
form_name=title.title if title is not None else None,
|
||||||
okud=okud.okud if okud is not None else None,
|
okud=okud.okud if okud is not None else None,
|
||||||
title_row=group[0].row,
|
title_row=group[0].row,
|
||||||
@ -128,6 +128,12 @@ def _has_table_header(sheet: Sheet, rules: Rules) -> bool:
|
|||||||
return False
|
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:
|
def classify_statement_sheet(sheet: Sheet, rules: Rules) -> StatementSheetKind:
|
||||||
if find_forms(sheet, rules)[0]:
|
if find_forms(sheet, rules)[0]:
|
||||||
return "statement"
|
return "statement"
|
||||||
|
|||||||
@ -3,21 +3,21 @@
|
|||||||
Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них
|
Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них
|
||||||
есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа — `numbers.to_money`
|
есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа — `numbers.to_money`
|
||||||
(Decimal, скобки → минус, прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не
|
(Decimal, скобки → минус, прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не
|
||||||
нормализуются: при «тыс.» в единицах и доле значений с дробной частью ≥ порогу (из словаря) — предупреждение.
|
нормализуются; счётчики дробных значений нужны предупреждению документа (`statement.py`: одно на документ).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from src.contract.result import StatementLine, StatementValue
|
from src.contract.result import StatementLine, StatementValue
|
||||||
from src.parser.grid import Sheet
|
from src.parser.grid import Sheet
|
||||||
from src.parser.numbers import to_money
|
from src.parser.numbers import to_money
|
||||||
from src.parser.rules import Rules, normalize_text
|
from src.parser.rules import Rules
|
||||||
from src.parser.statements.columns import TableLayout, cell_label, line_code
|
from src.parser.statements.columns import TableLayout, cell_label, is_numbering_row, line_code
|
||||||
|
|
||||||
__all__ = ["LinesResult", "collect_lines"]
|
__all__ = ["LinesResult", "collect_lines"]
|
||||||
|
|
||||||
_FULL_CONFIDENCE = 100
|
_FULL_CONFIDENCE = 100
|
||||||
_THOUSANDS = "тыс"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -61,6 +61,27 @@ def _value(sheet: Sheet, row: int, col: int, column_index: int) -> tuple[Stateme
|
|||||||
return value, False
|
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
|
def collect_lines( # noqa: PLR0913
|
||||||
sheet: Sheet,
|
sheet: Sheet,
|
||||||
rules: Rules,
|
rules: Rules,
|
||||||
@ -77,7 +98,9 @@ def collect_lines( # noqa: PLR0913
|
|||||||
continue
|
continue
|
||||||
code = line_code(sheet.cell(row, layout.code_col), rules)
|
code = line_code(sheet.cell(row, layout.code_col), rules)
|
||||||
if code is None:
|
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
|
result.skipped_with_sums += 1
|
||||||
continue
|
continue
|
||||||
if code in seen:
|
if code in seen:
|
||||||
@ -91,25 +114,16 @@ def collect_lines( # noqa: PLR0913
|
|||||||
if value.amount is not None:
|
if value.amount is not None:
|
||||||
result.numeric_values += 1
|
result.numeric_values += 1
|
||||||
result.fractional_values += int(fractional)
|
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(
|
result.lines.append(
|
||||||
StatementLine(
|
StatementLine(
|
||||||
line_code=code,
|
line_code=code,
|
||||||
line_code_confidence=_FULL_CONFIDENCE,
|
line_code_confidence=_FULL_CONFIDENCE,
|
||||||
line_name=name,
|
line_name=name,
|
||||||
line_name_confidence=_FULL_CONFIDENCE,
|
line_name_confidence=_FULL_CONFIDENCE if name else None,
|
||||||
values=values,
|
values=values,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if result.skipped_with_sums:
|
if result.skipped_with_sums:
|
||||||
result.warnings.append(f"строк без кода с суммами пропущено: {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
|
return result
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from src.contract.result import StatementHeader, StatementLine
|
|||||||
from src.parser.rules import FormType
|
from src.parser.rules import FormType
|
||||||
from src.parser.statements.columns import PeriodColumn, TableLayout
|
from src.parser.statements.columns import PeriodColumn, TableLayout
|
||||||
|
|
||||||
__all__ = ["Draft", "can_join", "join"]
|
__all__ = ["Draft", "can_join", "join", "join_blocker"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -28,9 +28,22 @@ class Draft:
|
|||||||
return {line.line_code for line in self.lines}
|
return {line.line_code for line in self.lines}
|
||||||
|
|
||||||
|
|
||||||
def can_join(draft: Draft, layout: TableLayout) -> bool:
|
def join_blocker(draft: Draft, layout: TableLayout, kind: FormType | None = None) -> str | None:
|
||||||
"""Продолжение присоединяется, если число колонок-сумм совпало с открытым документом."""
|
"""Почему продолжение нельзя присоединить к открытому документу (`None` — можно): у документа нет колонки кодов,
|
||||||
return draft.has_code_column and len(layout.periods) == len(draft.periods)
|
тип формы в маркере «Форма 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:
|
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.grid import Sheet
|
||||||
from src.parser.rules import Rules
|
from src.parser.rules import Rules
|
||||||
from src.parser.statements.checks import run_statement_checks
|
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.detect import FormBlock, continuation_form, find_forms
|
||||||
from src.parser.statements.header import parse_header
|
from src.parser.statements.header import parse_header
|
||||||
from src.parser.statements.lines import collect_lines
|
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"]
|
__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:
|
def _continuation(self, sheet: Sheet, drafts: list[Draft], warnings: list[str]) -> bool:
|
||||||
"""`False` — лист потерян (нет колонки кодов): строки не попали ни в один документ, статус не `done`."""
|
"""`False` — лист потерян (нет колонки кодов): строки не попали ни в один документ, статус не `done`."""
|
||||||
|
weak = False
|
||||||
try:
|
try:
|
||||||
layout = find_layout(sheet, self._rules, 1, sheet.n_rows)
|
layout = find_layout(sheet, self._rules, 1, sheet.n_rows)
|
||||||
|
except LayoutNotFoundError:
|
||||||
|
try: # лист с 1–2 кодами (O-9): допустим только как присоединяемое продолжение
|
||||||
|
layout = find_layout(sheet, self._rules, 1, sheet.n_rows, relaxed=True)
|
||||||
|
weak = True
|
||||||
except LayoutNotFoundError:
|
except LayoutNotFoundError:
|
||||||
warnings.append(_line(sheet, NO_CODE_COLUMN))
|
warnings.append(_line(sheet, NO_CODE_COLUMN))
|
||||||
return False
|
return False
|
||||||
|
marker_kind, _ = continuation_form(sheet, self._rules)
|
||||||
open_draft = next((draft for draft in reversed(drafts) if draft.kind != "DISCLOSURE"), None)
|
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(
|
result = collect_lines(
|
||||||
sheet, self._rules, layout, layout.first_data_row, sheet.n_rows, units=open_draft.header.units
|
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
|
open_draft.numeric_values += result.numeric_values
|
||||||
warnings.extend(_line(sheet, text) for text in result.warnings)
|
warnings.extend(_line(sheet, text) for text in result.warnings)
|
||||||
return True
|
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)
|
header, _ = parse_header(sheet, self._rules, 1, (layout.header_row or layout.first_data_row) - 1, None)
|
||||||
draft = Draft(
|
draft = Draft(
|
||||||
kind=kind, header=header, periods=layout.periods, sheet_names=[sheet.name], lonely_continuation=True
|
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 [],
|
columns=columns if draft.has_code_column else [],
|
||||||
lines=[],
|
lines=[],
|
||||||
)
|
)
|
||||||
|
draft.warnings.extend(self._fractional_warning(draft))
|
||||||
checks, check_warnings = run_statement_checks(
|
checks, check_warnings = run_statement_checks(
|
||||||
draft.lines, len(columns), kind if kind != "OTHER" else "BALANCE", self._rules
|
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
|
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
|
@staticmethod
|
||||||
def _is_ok(draft: Draft, document: StatementDocument) -> bool:
|
def _is_ok(draft: Draft, document: StatementDocument) -> bool:
|
||||||
if document.document_type == "DISCLOSURE":
|
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): каждая запись распознавания срабатывает хотя бы раз на
|
"""Покрытие словарей `rules/*.yaml` (design D-10, ruling R-22): каждая запись распознавания срабатывает хотя бы раз на
|
||||||
реальных ОСВ и синтетике; мёртвый синоним — повод удалить его или добавить файл синтетики `s14_*`.
|
реальных ОСВ и синтетике; мёртвый синоним — повод удалить его или добавить файл синтетики `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` типов данных,
|
`total_row`, `header_markers`, `mapping`, правила периодов, `year`, `end`, `header`/`row` типов данных,
|
||||||
`currency_aliases`). Перечень кодов `currency_codes` — список значений, не синонимы: в покрытие не входит. Записи
|
`currency_aliases`). Перечень кодов `currency_codes` — список значений, не синонимы: в покрытие не входит. Записи
|
||||||
находятся обходом моделей `Rules`, поэтому новый паттерн в любом словаре попадает под проверку автоматически.
|
находятся обходом моделей `Rules`, поэтому новый паттерн в любом словаре попадает под проверку автоматически.
|
||||||
|
|||||||
@ -347,5 +347,11 @@ async def test_real_statements_through_worker_give_needs_check_with_two_document
|
|||||||
balance, income = stored["documents"]
|
balance, income = stored["documents"]
|
||||||
assert [balance["document_type"], income["document_type"]] == ["BALANCE", "INCOME"]
|
assert [balance["document_type"], income["document_type"]] == ["BALANCE", "INCOME"]
|
||||||
assert {item["relation"] for item in balance["checks"]["relations_failed"]} == {"1600 = 1700"}
|
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)
|
await core.assert_quiet(request)
|
||||||
|
|||||||
@ -43,7 +43,7 @@ files:
|
|||||||
inn_optional_for: [INCOME]
|
inn_optional_for: [INCOME]
|
||||||
documents:
|
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: 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":
|
"mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx":
|
||||||
organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"'
|
organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"'
|
||||||
inn: "7016006635"
|
inn: "7016006635"
|
||||||
|
|||||||
@ -557,7 +557,7 @@ def _s13() -> list[SheetSpec]:
|
|||||||
|
|
||||||
|
|
||||||
def _s14() -> list[SheetSpec]:
|
def _s14() -> list[SheetSpec]:
|
||||||
"""Три листа форм, опознаваемые каждый одним своим признаком (покрытие `statement_markers`), и лист ОСВ."""
|
"""Три листа форм, опознаваемые каждый одним своим признаком (заголовки и ОКУД словаря форм), и лист ОСВ."""
|
||||||
return [
|
return [
|
||||||
form_sheet("Форма 1", "Форма по ОКУД 0710001"),
|
form_sheet("Форма 1", "Форма по ОКУД 0710001"),
|
||||||
form_sheet("ОФР", "Отчет о финансовых результатах"),
|
form_sheet("ОФР", "Отчет о финансовых результатах"),
|
||||||
|
|||||||
@ -4,7 +4,7 @@ st01 — ББ и ОФР на одном листе; st02 — «csv-подобн
|
|||||||
строки, колонка «Изменение, %», отрицательные в скобках; st03 — форма без колонки кодов; st04 — книга с листом ОСВ
|
строки, колонка «Изменение, %», отрицательные в скобках; st03 — форма без колонки кодов; st04 — книга с листом ОСВ
|
||||||
и листом баланса; st05 — одинокая страница 2; st06 — варианты заголовков, шапки и подписей, которых нет в реальных
|
и листом баланса; st05 — одинокая страница 2; st06 — варианты заголовков, шапки и подписей, которых нет в реальных
|
||||||
файлах (покрытие словарей: старое название ОФР, расшифровки, ОДДС, ОИК, целевое использование, «Пояснения»).
|
файлах (покрытие словарей: старое название ОФР, расшифровки, ОДДС, ОИК, целевое использование, «Пояснения»).
|
||||||
Детерминирован (см. `generate.py`).
|
st07 — лист-продолжение ОФР с одной кодовой строкой (O-9). Детерминирован (см. `generate.py`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@ -176,6 +176,19 @@ def _st06() -> list[SheetSpec]:
|
|||||||
return [*sheets, okud_only]
|
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]]] = {
|
FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSpec]], bytes]]] = {
|
||||||
"st01_both_forms_one_sheet.xlsx": (_st01, to_xlsx),
|
"st01_both_forms_one_sheet.xlsx": (_st01, to_xlsx),
|
||||||
"st02_csv_like.xlsx": (_st02, 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),
|
"st04_mixed_book.xlsx": (_st04, to_xlsx),
|
||||||
"st05_continuation_only.xlsx": (_st05, to_xlsx),
|
"st05_continuation_only.xlsx": (_st05, to_xlsx),
|
||||||
"st06_header_variants.xlsx": (_st06, to_xlsx),
|
"st06_header_variants.xlsx": (_st06, to_xlsx),
|
||||||
|
"st07_single_code_continuation.xlsx": (_st07, to_xlsx),
|
||||||
}
|
}
|
||||||
FILE_NAMES = tuple(FILES)
|
FILE_NAMES = tuple(FILES)
|
||||||
|
|
||||||
@ -199,7 +213,7 @@ def generate_all(directory: Path) -> list[Path]:
|
|||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> int:
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
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(
|
parser.add_argument(
|
||||||
"directory", type=Path, help="каталог назначения, например ../../fixtures/statements-xlsx/synthetic"
|
"directory", type=Path, help="каталог назначения, например ../../fixtures/statements-xlsx/synthetic"
|
||||||
|
|||||||
@ -9,6 +9,7 @@ EXPECTED = (
|
|||||||
"st04_mixed_book.xlsx",
|
"st04_mixed_book.xlsx",
|
||||||
"st05_continuation_only.xlsx",
|
"st05_continuation_only.xlsx",
|
||||||
"st06_header_variants.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 == []
|
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
|
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:
|
def test_layout_continuation_without_header(rules: Rules) -> None:
|
||||||
sheet = sheet_from_rows(
|
sheet = sheet_from_rows(
|
||||||
[
|
[
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from src.parser.statements.lines import collect_lines
|
|||||||
_FULL_CONFIDENCE = 100
|
_FULL_CONFIDENCE = 100
|
||||||
_FRACTIONAL_VALUES_COUNT = 4
|
_FRACTIONAL_VALUES_COUNT = 4
|
||||||
_NUMERIC_VALUES_COUNT = 9
|
_NUMERIC_VALUES_COUNT = 9
|
||||||
_SKIPPED_WITH_SUMS_COUNT = 1
|
_SKIPPED_WITH_SUMS_COUNT = 0
|
||||||
_BOUNDARY_NUMERIC_VALUES = 20
|
_BOUNDARY_NUMERIC_VALUES = 20
|
||||||
_BOUNDARY_FRACTIONAL_VALUES = 2
|
_BOUNDARY_FRACTIONAL_VALUES = 2
|
||||||
_BELOW_THRESHOLD_NUMERIC_VALUES = 12
|
_BELOW_THRESHOLD_NUMERIC_VALUES = 12
|
||||||
@ -58,35 +58,13 @@ def test_lines_values_and_skips(rules: Rules) -> None:
|
|||||||
assert all(
|
assert all(
|
||||||
line.line_code_confidence == _FULL_CONFIDENCE and line.line_name_confidence == _FULL_CONFIDENCE
|
line.line_code_confidence == _FULL_CONFIDENCE and line.line_name_confidence == _FULL_CONFIDENCE
|
||||||
for line in result.lines
|
for line in result.lines
|
||||||
)
|
) # у всех строк название есть
|
||||||
assert [value.column_index for value in by_code["1110"].values] == [0, 1]
|
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.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
|
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:
|
def test_line_without_name_column(rules: Rules) -> None:
|
||||||
sheet = sheet_from_rows([["Код", "На 31.12.2024"], ["1150", 5], ["1210", 6], ["1600", 11]])
|
sheet = sheet_from_rows([["Код", "На 31.12.2024"], ["1150", 5], ["1210", 6], ["1600", 11]])
|
||||||
layout = find_layout(sheet, rules, 1, 4)
|
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%)
|
# Expect 20 numeric values and 2 fractional (10%)
|
||||||
assert result.numeric_values == _BOUNDARY_NUMERIC_VALUES and result.fractional_values == _BOUNDARY_FRACTIONAL_VALUES
|
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:
|
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
|
result.numeric_values == _BELOW_THRESHOLD_NUMERIC_VALUES
|
||||||
and result.fractional_values == _BELOW_THRESHOLD_FRACTIONAL_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:
|
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.numeric_values == 0
|
||||||
assert result.fractional_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())
|
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"
|
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),
|
("Январь - Февраль 2024 г.", datetime.date(2024, 1, 1), datetime.date(2024, 2, 29), True),
|
||||||
("Январь - Октябрь 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 10, 31), 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),
|
("Февраль 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(
|
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
|
_CODE_MIN_SHARE = 0.6
|
||||||
_MIN_CODES = 3
|
_MIN_CODES = 3
|
||||||
|
_FRACTIONAL_SHARE_PERCENT = 10
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@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")
|
(tmp_path / STATEMENT_CHECKS_FILE).write_text(yaml.safe_dump(broken, allow_unicode=True), encoding="utf-8")
|
||||||
with pytest.raises(RuntimeError, match="пятизначные коды допустимы только в сгенерированных подстроках"):
|
with pytest.raises(RuntimeError, match="пятизначные коды допустимы только в сгенерированных подстроках"):
|
||||||
load_rules(tmp_path)
|
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