diff --git a/api/src/domain/stored_form.py b/api/src/domain/stored_form.py new file mode 100644 index 0000000..b6548b9 --- /dev/null +++ b/api/src/domain/stored_form.py @@ -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] diff --git a/api/src/services/statements_engine.py b/api/src/services/statements_engine.py new file mode 100644 index 0000000..74b8365 --- /dev/null +++ b/api/src/services/statements_engine.py @@ -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()) diff --git a/api/tests/fake_engine.py b/api/tests/fake_engine.py new file mode 100644 index 0000000..84fa0fd --- /dev/null +++ b/api/tests/fake_engine.py @@ -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 diff --git a/api/tests/test_domain_stored_form.py b/api/tests/test_domain_stored_form.py new file mode 100644 index 0000000..258eed0 --- /dev/null +++ b/api/tests/test_domain_stored_form.py @@ -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 diff --git a/api/tests/test_statements_engine.py b/api/tests/test_statements_engine.py new file mode 100644 index 0000000..a5a3723 --- /dev/null +++ b/api/tests/test_statements_engine.py @@ -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 diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index 270ad02..01ff4b4 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -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` |