diff --git a/CLAUDE.md b/CLAUDE.md index dfba77e..c6ecccb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам ( новый формат = новая фикстура + синоним, не новый `if`. ## Правила репозитория -- `arm_statements` — библиотека `pylib_ai_arm_statements==0.1.1`; установка и обновление — `make install` +- `arm_statements` — библиотека `pylib_ai_arm_statements` (версия — пин в `api/requirements.txt`); установка и обновление — `make install` (`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`); код возврата 20 при расхождении. Словари форм — в пакете; `api/rules/statement_*.yaml` удалены планом 4 (#54). @@ -117,7 +117,7 @@ property, покрытие словарей); без каталога `../fixtur **Модули адаптера** (`api/src/parser/statements/`): `forms.py` (блоки форм и вид листа по словарям пакета), `cells.py` (ячейка Excel -> `arm_statements.Cell`), `header_cells.py` (шапка по меткам словаря), `adapter.py` (`plan_sheets`, `build_candidates`: листы -> кандидаты для пакета, продолжение присоединяется), `pipeline.py` (`StatementPipeline`: -кандидаты -> `normalize_candidate` -> `to_contract`), `number_format.py` (числовой формат ячейки, #42). +кандидаты -> `split_by_form` (разрез по смене формы, AURORA-1734) -> `normalize_candidate` -> `to_contract`), `number_format.py` (числовой формат ячейки, #42). `book.py` (`BookParser`) склеивает ОСВ и формы; `get_parser(settings)` создаёт его с `default_rules()` пакета. **Контракт 2.0:** `CONTRACT_VERSION = "2.0"`; у документов `status` и `warnings`; `checks` обязателен у BALANCE/INCOME со diff --git a/api/requirements.txt b/api/requirements.txt index 3674953..4ba7ab2 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -43,7 +43,7 @@ Pygments==2.20.0 PyNaCl==1.5.0 python-dotenv==1.2.2 python-multipart==0.0.32 -pylib_ai_arm_statements==0.1.3 +pylib_ai_arm_statements==0.1.5 pyxlsb==1.0.10 PyYAML==6.0.3 rich==15.0.0 diff --git a/api/src/parser/statements/pipeline.py b/api/src/parser/statements/pipeline.py index aa6ac48..0583888 100644 --- a/api/src/parser/statements/pipeline.py +++ b/api/src/parser/statements/pipeline.py @@ -1,15 +1,16 @@ """Конвейер форм книги: кандидаты адаптера → шаги 2–8 пакета `arm_statements` → документы контракта 2.0. -Своего перевода в контракт здесь нет: документ формы — `to_contract` пакета, предупреждение файла — -`warning_to_contract(..., file_level=True)`. Изоляцию кандидатов друг от друга не делаем: исключение пакета — ошибка -книги (`RECOGNITION_FAILED` в `BookParser`). +Перед шагами 2–8 кандидат режется по смене формы (`split_by_form` пакета, AURORA-1734): ББ и ОФР одним листом без +своего заголовка — два документа. Своего перевода в контракт здесь нет: документ формы — `to_contract` пакета, +предупреждение файла — `warning_to_contract(..., file_level=True)`. Изоляцию кандидатов друг от друга не делаем: +исключение пакета — ошибка книги (`RECOGNITION_FAILED` в `BookParser`). """ from collections.abc import Sequence from dataclasses import dataclass from arm_statements import Rules as StatementRules -from arm_statements import StatementWarning, normalize_candidate, to_contract, warning_to_contract +from arm_statements import StatementWarning, normalize_candidate, split_by_form, to_contract, warning_to_contract from src.contract import FileWarning, StatementDocument from src.parser.deadline import Deadline @@ -37,16 +38,17 @@ class StatementPipeline: def parse(self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline, *, xlsb: bool) -> StatementsOutcome: documents: list[StatementDocument] = [] file_warnings: list[FileWarning] = [] - for candidate in build_candidates(sheets, self._rules, deadline, xlsb=xlsb): - deadline.check(f"форма «{candidate.locator}»") - result = normalize_candidate(candidate, self._rules) - if result.document is not None: - documents.append(StatementDocument.model_validate(to_contract(result.document))) - else: - # Документа нет: предупреждения адаптера (формат xlsb, числовой формат, склейка) пакет не переносит — - # они остаются предупреждениями файла со своим locator - file_warnings.extend(_file_warning(w) for w in candidate.warnings) - file_warnings.extend(_file_warning(w) for w in result.file_warnings) + for source in build_candidates(sheets, self._rules, deadline, xlsb=xlsb): + for candidate in split_by_form(source, self._rules): + deadline.check(f"форма «{candidate.locator}»") + result = normalize_candidate(candidate, self._rules) + if result.document is not None: + documents.append(StatementDocument.model_validate(to_contract(result.document))) + else: + # Документа нет: предупреждения адаптера (формат xlsb, числовой формат, склейка) пакет не + # переносит — они остаются предупреждениями файла со своим locator + file_warnings.extend(_file_warning(w) for w in candidate.warnings) + file_warnings.extend(_file_warning(w) for w in result.file_warnings) return StatementsOutcome(tuple(documents), tuple(file_warnings)) diff --git a/api/tests/unit/parser/statements/test_pipeline.py b/api/tests/unit/parser/statements/test_pipeline.py index f5c257a..8fc953f 100644 --- a/api/tests/unit/parser/statements/test_pipeline.py +++ b/api/tests/unit/parser/statements/test_pipeline.py @@ -31,9 +31,9 @@ def statement_rules() -> StatementRules: return default_rules() -def _balance(name: str = "Баланс") -> Sheet: +def _balance_rows() -> list[list[object]]: """Шапка из восьми строк, подпись столбцов в 9-й, первая строка с кодом — 10-я (код в столбце C).""" - rows = [ + return [ at({1: "Бухгалтерский баланс"}), at({1: "на 31 марта 2025 г."}), at({1: "Организация", 2: "ООО Ромашка"}), @@ -45,7 +45,10 @@ def _balance(name: str = "Баланс") -> Sheet: at({1: "Наименование показателя", 3: "Код", 4: "На 31 марта 2025 г."}), *[at({1: title, 3: code, 4: amount}) for title, code, amount in BALANCE_LINES], ] - return sheet_from_rows(rows, name=name) + + +def _balance(name: str = "Баланс") -> Sheet: + return sheet_from_rows(_balance_rows(), name=name) def test_document_converts_to_contract(statement_rules: StatementRules) -> None: @@ -83,3 +86,34 @@ def test_xlsb_flag_adds_document_warning(statement_rules: StatementRules) -> Non outcome = StatementPipeline(statement_rules).parse([(_balance(), SheetKind.STATEMENT)], NO_DEADLINE, xlsb=True) assert "XLSB_NO_FORMATS" in {w.code for w in outcome.documents[0].warnings} + + +INCOME_LINES = ( + ("Выручка", 2110, 300), + ("Себестоимость продаж", 2120, -200), + ("Валовая прибыль (убыток)", 2100, 100), + ("Прибыль (убыток) от продаж", 2200, 100), + ("Чистая прибыль (убыток)", 2400, 100), +) + + +def test_balance_and_income_on_one_sheet_split_into_two_documents(statement_rules: StatementRules) -> None: + """ББ и ОФР одним листом без заголовка ОФР: шапка таблицы ОФР режет кандидата (`split_by_form`, AURORA-1734).""" + rows = [ + *_balance_rows(), + at({1: "Наименование показателя", 3: "Код", 4: "За январь - март 2025 г."}), + *[at({1: title, 3: code, 4: amount}) for title, code, amount in INCOME_LINES], + ] + + outcome = StatementPipeline(statement_rules).parse( + [(sheet_from_rows(rows, name="Баланс"), SheetKind.STATEMENT)], NO_DEADLINE, xlsb=False + ) + + assert [(d.document_type, d.locator) for d in outcome.documents] == [ + ("BALANCE", "sheet:Баланс"), + ("INCOME", "sheet:Баланс;form:1"), + ] + balance, income = outcome.documents + assert {line.line_code for line in balance.lines} == {str(code) for _, code, _ in BALANCE_LINES} + assert {line.line_code for line in income.lines} == {str(code) for _, code, _ in INCOME_LINES} + assert [str(c.period_date) for c in income.columns] == ["2025-03-31"]