feat(statements): адаптер arm_statements за протоколом StatementsEngine и вход пересчёта из БД

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 20:00:12 +04:00
parent 42f0dc5ca7
commit 6ce36b3e2d
6 changed files with 625 additions and 0 deletions

View File

@ -0,0 +1,126 @@
"""Вход пересчёта формы ББ/ОФР из строк БД: сборка `StoredForm` пакета `arm_statements` (часть Б, B1).
Единственное место ядра (вместе с `services/statements_engine.py`), где импортируется пакет. Здесь же все соглашения
границы: перечисления пакета — `StrEnum`, уверенности — `float | None`, `NULL` у `is_recognized` — `False`,
`period_kind = NULL` — `PeriodKind.NONE`, ключи строки и колонки — `str(uuid)`.
"""
from collections.abc import Sequence
from arm_statements import (
DocumentType,
LineStatus,
PeriodKind,
Severity,
StoredColumn,
StoredForm,
StoredLine,
StoredValue,
StoredWarning,
)
from src.contract import StatementDocumentV2
from src.models.enums import DocumentIssueKind
from src.models.files import Document
from src.models.statements import (
DocumentIssue,
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
__all__ = ["build_stored_form", "edition_codes"]
_SURE = 100 # «уверенный» код результата — уверенность ровно 100
def _confidence(value: object) -> float | None:
return None if value is None else float(value) # type: ignore[arg-type]
def _stored_column(column: FinancialStatementColumn) -> StoredColumn:
return StoredColumn(
key=str(column.id),
column_index=column.column_index,
period_date=column.period_date,
period_kind=PeriodKind(column.period_kind) if column.period_kind else PeriodKind.NONE,
)
def _stored_value(value: FinancialStatementValue) -> StoredValue:
return StoredValue(
column_key=str(value.column_id),
amount=value.amount,
text=value.source_text,
confidence=_confidence(value.confidence),
is_recognized=bool(value.is_recognized),
value_fixed=bool(value.is_modified) or bool(value.is_confirmed),
)
def _parent_keys(lines: Sequence[FinancialStatementLine]) -> dict[int, str]:
"""`row_index` → ключ строки; при повторе `row_index` предпочитается строка `ok`."""
result: dict[int, str] = {}
for line in lines:
if line.row_index is None:
continue
if line.row_index not in result or line.line_status.value == LineStatus.OK.value:
result[line.row_index] = str(line.id)
return result
def build_stored_form(
document: Document,
meta: FinancialStatementMeta,
columns: Sequence[FinancialStatementColumn],
lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]],
warnings: Sequence[DocumentIssue],
) -> StoredForm:
"""Собирает вход `recompute` из строк БД формы; `warnings` фильтруются до `kind = warning`.
Тип формы — `document.document_type` (`BALANCE` / `INCOME` / `DISCLOSURE`): `meta.statement_type` знает только
`balance` / `income`.
"""
row_keys = _parent_keys([line for line, _ in lines])
stored_lines: list[StoredLine] = []
for line, values in lines:
status = LineStatus(line.line_status.value)
parent_key = (
row_keys.get(line.parent_row_index)
if status is LineStatus.DETAIL and line.parent_row_index is not None
else None
)
stored_lines.append(
StoredLine(
key=str(line.id),
row_index=line.row_index,
line_status=status,
line_code=line.line_code,
line_code_confidence=_confidence(line.line_code_confidence),
line_name=line.line_name,
code_fixed=bool(line.line_code_modified) or bool(line.is_confirmed),
parent_key=parent_key,
values=tuple(_stored_value(v) for v in values),
)
)
return StoredForm(
document_type=DocumentType(document.document_type.value),
effective_date=document.reporting_date,
has_organization_or_inn=bool(meta.organization_name) or bool(meta.inn),
period_known=document.reporting_date is not None,
needs_review_fields=tuple(meta.needs_review_fields or ()),
accepted=meta.checks_accepted_at is not None,
warnings=tuple(
StoredWarning(code=w.code, severity=Severity(w.severity.value))
for w in warnings
if w.kind == DocumentIssueKind.warning
),
columns=tuple(_stored_column(c) for c in columns),
lines=tuple(stored_lines),
)
def edition_codes(doc: StatementDocumentV2) -> list[tuple[str, bool]]:
"""Пары `(line_code, уверенный)` строк `ok` результата по `row_index` — вход `engine.edition`."""
ok_lines = sorted((ln for ln in doc.lines if ln.line_status == "ok"), key=lambda ln: ln.row_index)
return [(str(ln.line_code), ln.line_code_confidence == _SURE) for ln in ok_lines]

View File

@ -0,0 +1,99 @@
"""Адаптер пакета `arm_statements` за протоколом `StatementsEngine` (часть Б, B1).
Единственная точка вызова функций пакета в ядре; остальной код работает с протоколом, модульные тесты подменяют
его `FakeEngine`. Правила пакета читаются один раз на процесс (`default_engine` кэширует адаптер).
"""
import datetime
import functools
from collections.abc import Mapping, Sequence
from decimal import Decimal
from typing import Any, Protocol
from arm_statements import (
DocStatus,
DocumentType,
Edition,
FormVariant,
PeriodKind,
RecomputeResult,
Rules,
StoredForm,
choose_edition,
default_rules,
document_status,
recompute,
resolve_relative,
statements_version,
status_input_from_contract,
)
__all__ = ["ArmStatementsEngine", "StatementsEngine", "default_engine"]
class StatementsEngine(Protocol):
@property
def version(self) -> str | None: ...
def recompute(self, form: StoredForm) -> RecomputeResult: ...
def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus: ...
def edition(
self,
document_type: DocumentType,
codes: Sequence[tuple[str, bool]],
effective_date: datetime.date | None,
form_variant: FormVariant | None,
) -> Edition | None: ...
def code_order(
self, edition: Edition, document_type: DocumentType, form_variant: FormVariant
) -> tuple[str, ...]: ...
def editions(self) -> tuple[Edition, ...]: ...
def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: ...
def tolerance(self) -> Decimal: ...
class ArmStatementsEngine:
"""Реализация поверх пакета: правила — `default_rules()` пакета."""
def __init__(self, rules: Rules) -> None:
self._rules = rules
@property
def version(self) -> str | None:
return statements_version()
def recompute(self, form: StoredForm) -> RecomputeResult:
return recompute(form, self._rules)
def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus:
return document_status(status_input_from_contract(document))
def edition(
self,
document_type: DocumentType,
codes: Sequence[tuple[str, bool]],
effective_date: datetime.date | None,
form_variant: FormVariant | None,
) -> Edition | None:
return choose_edition(document_type, codes, effective_date, self._rules, variant=form_variant)
def code_order(self, edition: Edition, document_type: DocumentType, form_variant: FormVariant) -> tuple[str, ...]:
if document_type is DocumentType.DISCLOSURE:
return ()
return self._rules.code_orders.sequence(edition, document_type, form_variant)
def editions(self) -> tuple[Edition, ...]:
return self._rules.code_orders.editions
def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None:
if kind in (PeriodKind.ABSOLUTE, PeriodKind.NONE):
return None
return resolve_relative(kind, effective_date)
def tolerance(self) -> Decimal:
return self._rules.checks.tolerance
@functools.cache
def default_engine() -> StatementsEngine:
"""Адаптер с правилами пакета; кэш на процесс."""
return ArmStatementsEngine(default_rules())

88
api/tests/fake_engine.py Normal file
View File

@ -0,0 +1,88 @@
"""`FakeEngine` для модульных тестов ядра: возвращает заданные ответы и запоминает вход (часть Б, B1)."""
import datetime
from collections.abc import Mapping, Sequence
from decimal import Decimal
from typing import Any
from arm_statements import (
DocStatus,
DocumentType,
Edition,
FormVariant,
PeriodKind,
RecomputeResult,
StatementChecks,
StoredForm,
)
def empty_result(status: DocStatus = DocStatus.DONE) -> RecomputeResult:
"""`RecomputeResult` без нарушений и признаков."""
return RecomputeResult(
form_variant=FormVariant.FULL,
edition=Edition.FSBU4,
checks=StatementChecks((), ()),
line_needs_review={},
value_needs_review={},
detail_parents={},
status=status,
)
class FakeEngine:
"""Подмена `StatementsEngine`: ответы задаются в конструкторе, вызовы запоминаются."""
def __init__( # noqa: PLR0913
self,
result: RecomputeResult | None = None,
*,
document_status: DocStatus = DocStatus.DONE,
edition: Edition | None = Edition.FSBU4,
version: str | None = "fake",
code_orders: Mapping[tuple[Edition, DocumentType, FormVariant], tuple[str, ...]] | None = None,
tolerance: Decimal = Decimal(1),
) -> None:
self.result = result if result is not None else empty_result()
self.document_status = document_status
self.edition_value = edition
self._version = version
self.orders = dict(code_orders or {})
self._tolerance = tolerance
self.forms: list[StoredForm] = []
self.edition_calls: list[
tuple[DocumentType, list[tuple[str, bool]], datetime.date | None, FormVariant | None]
] = []
@property
def version(self) -> str | None:
return self._version
def recompute(self, form: StoredForm) -> RecomputeResult:
self.forms.append(form)
return self.result
def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus:
return self.document_status
def edition(
self,
document_type: DocumentType,
codes: Sequence[tuple[str, bool]],
effective_date: datetime.date | None,
form_variant: FormVariant | None,
) -> Edition | None:
self.edition_calls.append((document_type, list(codes), effective_date, form_variant))
return self.edition_value
def code_order(self, edition: Edition, document_type: DocumentType, form_variant: FormVariant) -> tuple[str, ...]:
return self.orders.get((edition, document_type, form_variant), ())
def editions(self) -> tuple[Edition, ...]:
return (Edition.RAS_66N, Edition.FSBU4)
def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None:
return None
def tolerance(self) -> Decimal:
return self._tolerance

View File

@ -0,0 +1,185 @@
"""`build_stored_form` и `edition_codes` (часть Б, B1): отображение строк БД в вход пересчёта пакета."""
import datetime
import json
import uuid
from decimal import Decimal
from pathlib import Path
from arm_statements import DocumentType as PkgDocumentType
from arm_statements import LineStatus, PeriodKind, Severity
from src.contract import StatementDocumentV2
from src.domain.stored_form import build_stored_form, edition_codes
from src.models.enums import (
DocumentIssueKind,
DocumentStatus,
DocumentType,
IssueSeverity,
IssueSource,
StatementLineStatus,
StatementType,
)
from src.models.files import Document
from src.models.statements import (
DocumentIssue,
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
CONFIDENCE = 67.5
EXAMPLES = Path(__file__).resolve().parent.parent / "contracts" / "examples"
def make_document(reporting_date: datetime.date | None = datetime.date(2025, 9, 30)) -> Document:
return Document(
id=uuid.uuid4(),
document_type=DocumentType.BALANCE,
status=DocumentStatus.RECOGNIZED,
reporting_date=reporting_date,
)
def make_meta(**kwargs: object) -> FinancialStatementMeta:
return FinancialStatementMeta(id=uuid.uuid4(), statement_type=StatementType.balance, **kwargs)
def make_column(index: int = 1, period_kind: str | None = "relative:current") -> FinancialStatementColumn:
return FinancialStatementColumn(
id=uuid.uuid4(), column_index=index, period_kind=period_kind, period_date=datetime.date(2025, 9, 30)
)
def make_line( # noqa: PLR0913
code: str | None,
*,
row_index: int | None,
status: StatementLineStatus = StatementLineStatus.ok,
confidence: str | None = "100",
modified: bool | None = False,
confirmed: bool = False,
parent_row_index: int | None = None,
) -> FinancialStatementLine:
return FinancialStatementLine(
id=uuid.uuid4(),
line_code=code,
line_code_confidence=None if confidence is None else Decimal(confidence),
line_code_modified=modified,
line_status=status,
row_index=row_index,
parent_row_index=parent_row_index,
is_confirmed=confirmed,
)
def make_value( # noqa: PLR0913
line: FinancialStatementLine,
column: FinancialStatementColumn,
amount: str | None,
*,
confidence: str | None = "100",
recognized: bool | None = True,
modified: bool = False,
confirmed: bool = False,
text: str | None = None,
) -> FinancialStatementValue:
return FinancialStatementValue(
id=uuid.uuid4(),
line_id=line.id,
column_id=column.id,
amount=None if amount is None else Decimal(amount),
confidence=None if confidence is None else Decimal(confidence),
is_recognized=recognized,
is_modified=modified,
is_confirmed=confirmed,
source_text=text,
)
def make_issue(kind: DocumentIssueKind, code: str, severity: IssueSeverity = IssueSeverity.info) -> DocumentIssue:
return DocumentIssue(
id=uuid.uuid4(), kind=kind, code=code, severity=severity, message="m", source=IssueSource.service
)
def test_build_stored_form_maps_flags() -> None:
column = make_column()
template = make_line("1260", row_index=None, confidence=None, modified=None)
edited = make_line("1230", row_index=0)
confirmed = make_line("1210", row_index=1)
assigned = make_line("1250", row_index=2, confidence="25", modified=True)
parent = make_line("1200", row_index=3)
detail = make_line(None, row_index=4, status=StatementLineStatus.detail, parent_row_index=3)
pairs = [
(template, [make_value(template, column, "10", modified=True)]),
(edited, [make_value(edited, column, "5", modified=True, text="5 0")]),
(confirmed, [make_value(confirmed, column, "7", confirmed=True)]),
(assigned, [make_value(assigned, column, "1")]),
(parent, [make_value(parent, column, "1")]),
(detail, [make_value(detail, column, "2")]),
]
warnings = [
make_issue(DocumentIssueKind.warning, "MAPPING_WARNING", IssueSeverity.error),
make_issue(DocumentIssueKind.relation, "1200 = ..."),
make_issue(DocumentIssueKind.soft, "x"),
]
meta = make_meta(organization_name="ООО", needs_review_fields=["inn"])
form = build_stored_form(make_document(), meta, [column], pairs, warnings)
by_key = {line.key: line for line in form.lines}
assert by_key[str(template.id)].row_index is None
assert by_key[str(template.id)].values[0].value_fixed
assert by_key[str(edited.id)].values[0].value_fixed
assert by_key[str(edited.id)].values[0].text == "5 0"
assert by_key[str(confirmed.id)].values[0].value_fixed
assert not by_key[str(confirmed.id)].code_fixed
assert by_key[str(assigned.id)].code_fixed
assert by_key[str(detail.id)].parent_key == str(parent.id)
assert by_key[str(detail.id)].line_status is LineStatus.DETAIL
assert by_key[str(parent.id)].parent_key is None
assert [(w.code, w.severity) for w in form.warnings] == [("MAPPING_WARNING", Severity.ERROR)]
assert form.effective_date == datetime.date(2025, 9, 30)
assert form.period_known
assert form.has_organization_or_inn
assert form.needs_review_fields == ("inn",)
assert not form.accepted
assert form.document_type is PkgDocumentType.BALANCE
def test_confirmed_line_is_code_fixed_and_accepted_flag() -> None:
column = make_column()
line = make_line("1210", row_index=0, confirmed=True)
meta = make_meta(checks_accepted_at=datetime.datetime(2026, 10, 18, tzinfo=datetime.UTC))
form = build_stored_form(make_document(None), meta, [column], [(line, [])], [])
assert form.lines[0].code_fixed
assert form.accepted
assert not form.period_known
assert form.effective_date is None
assert not form.has_organization_or_inn
def test_stored_form_types() -> None:
column = make_column(period_kind=None)
line = make_line("1210", row_index=0, confidence="67.50")
value = make_value(line, column, "1", confidence="67.50", recognized=None)
form = build_stored_form(make_document(), make_meta(), [column], [(line, [value])], [])
stored = form.lines[0]
assert stored.line_code_confidence == CONFIDENCE
assert type(stored.line_code_confidence) is float
assert type(stored.values[0].confidence) is float
assert stored.values[0].is_recognized is False
assert form.columns[0].period_kind is PeriodKind.NONE
assert form.columns[0].key == str(column.id)
assert stored.line_status is LineStatus.OK
assert isinstance(stored.line_status, LineStatus)
def test_edition_codes_from_result_by_row_index() -> None:
raw = json.loads((EXAMPLES / "result-statement-minimal.json").read_text(encoding="utf-8"))
doc = StatementDocumentV2.model_validate(raw["documents"][0])
pairs = edition_codes(doc)
# unreadable (row 2) не входит; порядок — по row_index
assert pairs == [(c, True) for c in ("1210", "1230", "1200", "1600", "1370", "1300", "1700")]
doc.lines.reverse()
assert edition_codes(doc) == pairs

View File

@ -0,0 +1,124 @@
"""Адаптер `StatementsEngine` поверх реального пакета `arm_statements` (часть Б, B1)."""
import datetime
import json
from decimal import Decimal
from pathlib import Path
import pytest
from arm_statements import DocStatus, DocumentType, Edition, FormVariant, PeriodKind, default_rules, statements_version
from src.contract import StatementDocumentV2
from src.domain.stored_form import build_stored_form, edition_codes
from src.models.enums import StatementLineStatus
from src.services.statements_engine import ArmStatementsEngine, default_engine
from tests.fake_engine import FakeEngine, empty_result
from tests.test_domain_stored_form import make_column, make_document, make_line, make_meta, make_value
EXAMPLES = Path(__file__).resolve().parent.parent / "contracts" / "examples"
ENGINE = default_engine()
def _documents(name: str) -> list[dict[str, object]]:
raw = json.loads((EXAMPLES / name).read_text(encoding="utf-8"))
return [d for d in raw["documents"] if d["document_type"] in ("BALANCE", "INCOME", "DISCLOSURE")]
@pytest.mark.parametrize("name", ["result-statement-minimal.json", "result-statement-excel.json"])
def test_contract_document_status_matches_examples(name: str) -> None:
documents = _documents(name)
assert documents
for doc in documents:
assert ENGINE.contract_document_status(doc) == DocStatus(doc["status"])
if name == "result-statement-minimal.json":
assert ENGINE.contract_document_status(documents[0]) is DocStatus.NEEDS_CHECK # строка unreadable
def test_edition_codes_and_engine_edition() -> None:
doc = StatementDocumentV2.model_validate(_documents("result-statement-minimal.json")[0])
pairs = edition_codes(doc)
effective = datetime.date(2025, 9, 30)
edition = ENGINE.edition(DocumentType.BALANCE, pairs, effective, FormVariant.FULL)
assert isinstance(edition, Edition)
# та же форма, собранная из этого результата в строки БД: редакция пересчёта совпадает с редакцией приёма
column = make_column()
lines = []
for ln in doc.lines:
line = make_line(
ln.line_code,
row_index=ln.row_index,
status=StatementLineStatus(ln.line_status),
confidence=None if ln.line_code_confidence is None else str(ln.line_code_confidence),
)
lines.append((line, [make_value(line, column, v.amount, confidence=str(v.confidence)) for v in ln.values]))
form = build_stored_form(make_document(effective), make_meta(organization_name="ООО"), [column], lines, [])
assert ENGINE.recompute(form).edition == edition
def test_engine_scenario_recompute_after_edit() -> None:
column = make_column()
effective = datetime.date(2025, 9, 30)
l1210 = make_line("1210", row_index=0)
l1230 = make_line("1230", row_index=1)
l1250 = make_line("1250", row_index=2, confidence="25", modified=True) # бывшая unreadable с назначенным кодом
l1260 = make_line("1260", row_index=None, confidence=None, modified=None) # шаблонная строка
l1200 = make_line("1200", row_index=3)
detail = make_line(None, row_index=4, status=StatementLineStatus.detail, parent_row_index=3)
pairs = [
(l1210, [make_value(l1210, column, "100")]),
(l1230, [make_value(l1230, column, "428311", confidence="60", modified=True)]),
(l1250, [make_value(l1250, column, "50", confidence="67", modified=True)]),
(l1260, [make_value(l1260, column, "10", confidence=None, recognized=None, modified=True)]),
(l1200, [make_value(l1200, column, "428471")]),
(detail, [make_value(detail, column, "5", confidence="60")]),
]
form = build_stored_form(make_document(effective), make_meta(organization_name="ООО"), [column], pairs, [])
result = ENGINE.recompute(form)
assert result.edition is Edition.FSBU4
assert result.form_variant is FormVariant.FULL
key = str(column.id)
assert result.value_needs_review[(str(l1230.id), key)] is False
assert result.value_needs_review[(str(detail.id), key)] is False
assert result.line_needs_review[str(l1250.id)] is False
assert result.detail_parents[str(detail.id)] == str(l1200.id) # ближайшая выше строка ok
# 1200 = … + 1250 + 1260 сошлось только с учётом строки 1250 и шаблонной 1260
assert [f for f in result.checks.relations_failed if f.relation.startswith("1200")] == []
# без шаблонной строки 1260 сумма не сходится: соотношение нарушено с ключом колонки БД
without_template = build_stored_form(
make_document(effective),
make_meta(organization_name="ООО"),
[column],
[p for p in pairs if p[0] is not l1260],
[],
)
failed = [f for f in ENGINE.recompute(without_template).checks.relations_failed if f.relation.startswith("1200")]
assert [(f.column_key, f.expected, f.actual) for f in failed] == [(key, Decimal(428471), Decimal(428461))]
def test_engine_version_reads_file(tmp_path: Path) -> None:
assert ENGINE.version == statements_version()
assert statements_version(tmp_path / "VERSION") is None
def test_engine_rules_accessors() -> None:
engine = ArmStatementsEngine(default_rules())
assert engine.tolerance() == Decimal(1)
assert engine.editions() == (Edition.RAS_66N, Edition.FSBU4)
assert engine.code_order(Edition.FSBU4, DocumentType.DISCLOSURE, FormVariant.FULL) == ()
assert "1200" in engine.code_order(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL)
assert engine.resolve_relative(PeriodKind.ABSOLUTE, datetime.date(2025, 9, 30)) is None
assert engine.resolve_relative(PeriodKind.NONE, datetime.date(2025, 9, 30)) is None
assert engine.resolve_relative(PeriodKind.PREV_YEAR_END, datetime.date(2025, 9, 30)) == datetime.date(2024, 12, 31)
def test_fake_engine_records_form() -> None:
fake = FakeEngine(empty_result(DocStatus.NEEDS_CHECK), document_status=DocStatus.NEEDS_CHECK, edition=None)
form = build_stored_form(make_document(), make_meta(), [], [], [])
assert fake.recompute(form).status is DocStatus.NEEDS_CHECK
assert fake.forms == [form]
assert fake.edition(DocumentType.BALANCE, [("1200", True)], None, None) is None
assert fake.edition_calls[0][1] == [("1200", True)]
assert fake.contract_document_status({}) is DocStatus.NEEDS_CHECK

View File

@ -33,3 +33,6 @@ Goals и Non-goals — см. `proposal.md`.
| 2026-10-18 | **R-1 (Р-40).** `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` пишутся только для кодов из ровно четырёх цифр; пятизначные подстроки Excel не проверяются. **P3-7 плана в этой части заменён** (запись на код документа сохраняется, пятизначные коды — нет) | решение владельца 18.10.2026 (Р-40) перед исполнением Б; закрывает O-3 (поток `info` у пятизначных) | пропущенный `info` у нестандартного пятизначного кода; восстановление — снять условие длины кода в маппере 2.0 и пересчёте |
| 2026-10-18 | **R-2 (Р-34).** Подтверждение кода строки — флаг `is_confirmed`; `line_code_confidence` и `line_code_modified` не меняются (P3-16 части А) | решение владельца 18.10.2026; в пересчёте Б правило `is_confirmed ⇒ needs_review строки = false` берётся из P3-16 | нет — поведение уже реализовано в части А |
| 2026-10-18 | **R-3.** Удаление копии словаря §7 в дельте оформлено как MODIFIED «Соотношения §7 по колонкам», а не REMOVED (см. D-4); «Ответ `GET …/statement`» и порядок 409 оформлены новыми ADDED-требованиями, так как в `openspec/specs/` и дельтах части А одноимённых нет | `openspec archive` требует, чтобы имя REMOVED/MODIFIED существовало; иначе архив упадёт | при архивации — правка имён заголовков дельт |
| 2026-10-18 | **P3-25 (B1).** Тип формы во входе пересчёта — `document.document_type` (`BALANCE` / `INCOME` / `DISCLOSURE`), а не `meta.statement_type` (только `balance` / `income`); план писал «тип формы» без источника | `DISCLOSURE` не представим в `statement_type`; тип документа — единственный источник для всех трёх типов | форма `DISCLOSURE` собралась бы с ошибкой `KeyError` / потерей типа; правка — одна строка в `build_stored_form` |
| 2026-10-18 | **P3-26 (B1, O-3 часть А).** Копия `statement-code-orders.yaml` в `api/contracts/` не заводится: перечень кодов читает пакет из своей байтовой копии `api/arm_statements/rules/statement_code_orders.yaml` (`loader._ORDERS`), ядро файла не открывает; идентичность копии пакета с `docs/contracts/statement-code-orders.yaml` проверяет `sync-statements --check`; тест идентичности контракта сверяет только `*.json`. Сейчас копия пакета побайтно равна файлу docs | второй экземпляр файла в ядре никто бы не читал и он мог бы разойтись с пакетом | нет; если sync-statements когда-либо перестанет сверять YAML — добавить сверку в `contract_identity.py` |
| 2026-10-18 | **P3-27 (B1).** `edition_codes` берёт строки `ok` результата `2.0`, сортирует по `row_index` и помечает код уверенным при `line_code_confidence == 100` (ровно), `None` — неуверенный | как в разделе «Что ядро вызывает» и в `recompute` пакета (код правленый/подтверждённый там — `code_fixed`) | редакция приёма разойдётся с редакцией пересчёта; сверку делает `test_edition_codes_and_engine_edition` |