Merge pull request 'AURORA-1734, 1735, 1864: pylib_ai_arm_statements 0.1.5' (#88) from feat/87-statements-015 into main

This commit is contained in:
aep 2026-11-12 13:31:46 +03:00
commit bd71650ecc
5 changed files with 60 additions and 24 deletions

View File

@ -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 со

View File

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

View File

@ -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,14 +38,15 @@ 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):
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
# Документа нет: предупреждения адаптера (формат 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))

View File

@ -362,10 +362,10 @@ async def test_real_statements_through_worker_give_done_with_two_documents(
"1500 = 1510 + 1520 + 1530 + 1540 + 1550",
"1410 = 14101 + 14102 + 14103 + 14104 + 14105",
}
assert {item["relation"] for item in income["checks"]["soft_relations_failed"]} >= {
"2400 = 2300 - 2410 + 2420",
"2410 = 2411 + 2412",
}
income_soft = {item["relation"] for item in income["checks"]["soft_relations_failed"]}
assert "2410 = 2411 + 2412" in income_soft
# 2400 — группа альтернатив (0.1.4, AURORA-1864): одна из них в НПЗ сходится, ложного расхождения нет
assert "2400 = 2300 - 2410 + 2420" not in income_soft
# лист с единственной строкой 2400 присоединён (O-9)
assert "2400" in {line["line_code"] for line in income["lines"]}
await core.assert_quiet(request)

View File

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