From a5d10a6b0e98825d872984b76889f4932b7fd8ee Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:58:27 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BE=D0=B1=D1=89=D0=B0=D1=8F=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8F=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=D0=B0=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=20(detail=20=D0=BD=D0=B5=20=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=B8=D1=82)=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=D0=B0=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- arm_statements/status.py | 97 +++++++++++++++++++++++ arm_statements/tests/test_status.py | 117 ++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 arm_statements/status.py create mode 100644 arm_statements/tests/test_status.py diff --git a/arm_statements/status.py b/arm_statements/status.py new file mode 100644 index 0000000..02be8f8 --- /dev/null +++ b/arm_statements/status.py @@ -0,0 +1,97 @@ +"""Статус документа и конверта (спека «Шаг 8 — статус документа и конверта»). + +Одна функция на сервис и ядро: вход не зависит от источника строк. +""" + +from collections.abc import Sequence +from dataclasses import dataclass + +from arm_statements.model import ( + DocStatus, + DocumentType, + EnvelopeStatus, + LineStatus, + PeriodKind, + Severity, + StatementDocument, + StatementWarning, +) + +_ACCEPTABLE_LINES = frozenset({LineStatus.OK, LineStatus.DETAIL}) + + +@dataclass(frozen=True, slots=True) +class StatusLine: + line_status: LineStatus + needs_review: bool + + +@dataclass(frozen=True, slots=True) +class StatusValue: + line_status: LineStatus + needs_review: bool + + +@dataclass(frozen=True, slots=True) +class StatusInput: + document_type: DocumentType + layout_found: bool + has_organization_or_inn: bool + period_known: bool + needs_review_fields: tuple[str, ...] + lines: tuple[StatusLine, ...] + values: tuple[StatusValue, ...] + hard_failures: int + accepted: bool + warning_severities: tuple[Severity, ...] + + +def document_status(data: StatusInput) -> DocStatus: + """`DISCLOSURE` — всегда `done`; иначе `done` только при выполнении всех условий спеки.""" + if data.document_type is DocumentType.DISCLOSURE: + return DocStatus.DONE + done = ( + data.layout_found + and data.has_organization_or_inn + and data.period_known + and all(line.line_status in _ACCEPTABLE_LINES for line in data.lines) + and not any(line.needs_review for line in data.lines) + # значения строк detail в статус не входят (Р-23) + and not any(v.needs_review for v in data.values if v.line_status is not LineStatus.DETAIL) + and not data.needs_review_fields + and (data.hard_failures == 0 or data.accepted) + and Severity.ERROR not in data.warning_severities + ) + return DocStatus.DONE if done else DocStatus.NEEDS_CHECK + + +def envelope_status( + document_statuses: Sequence[DocStatus], file_warnings: Sequence[StatementWarning] +) -> EnvelopeStatus: + if not document_statuses: + return EnvelopeStatus.ERROR + if all(s is DocStatus.DONE for s in document_statuses) and all( + w.severity is not Severity.ERROR for w in file_warnings + ): + return EnvelopeStatus.DONE + return EnvelopeStatus.NEEDS_CHECK + + +def status_input(document: StatementDocument) -> StatusInput: + """Вход функции статуса из документа шагов 2–8.""" + header = document.header + period_known = header.report_date.value is not None or any( + c.period_kind is PeriodKind.ABSOLUTE and c.period_date is not None for c in document.columns + ) + return StatusInput( + document_type=document.document_type, + layout_found=document.lines != (), + has_organization_or_inn=header.organization_name.value is not None or header.inn.value is not None, + period_known=period_known, + needs_review_fields=header.needs_review_fields, + lines=tuple(StatusLine(line.line_status, line.needs_review) for line in document.lines), + values=tuple(StatusValue(line.line_status, v.needs_review) for line in document.lines for v in line.values), + hard_failures=0 if document.checks is None else len(document.checks.relations_failed), + accepted=False, + warning_severities=tuple(w.severity for w in document.warnings), + ) diff --git a/arm_statements/tests/test_status.py b/arm_statements/tests/test_status.py new file mode 100644 index 0000000..ec5c9b2 --- /dev/null +++ b/arm_statements/tests/test_status.py @@ -0,0 +1,117 @@ +from dataclasses import replace +from datetime import date +from decimal import Decimal as D + +from arm_statements.header import parse_header +from arm_statements.issues import make_warning +from arm_statements.loader import Rules +from arm_statements.model import ( + DocStatus, + DocumentType, + EnvelopeStatus, + FormVariant, + LineStatus, + PeriodKind, + Severity, + StatementChecks, + StatementColumn, + StatementDocument, + StatementLine, + StatementValue, +) +from arm_statements.status import ( + StatusInput, + StatusLine, + StatusValue, + document_status, + envelope_status, + status_input, +) +from arm_statements.tests.builders import hdr + +OK, DETAIL, UNREADABLE = LineStatus.OK, LineStatus.DETAIL, LineStatus.UNREADABLE +DONE, CHECK = DocStatus.DONE, DocStatus.NEEDS_CHECK +BASE = StatusInput( + document_type=DocumentType.BALANCE, + layout_found=True, + has_organization_or_inn=True, + period_known=True, + needs_review_fields=(), + lines=(StatusLine(OK, False),), + values=(StatusValue(OK, False),), + hard_failures=0, + accepted=False, + warning_severities=(Severity.INFO,), +) + + +def test_clean_form_done() -> None: + assert document_status(BASE) is DONE + + +def test_unreadable_line_blocks() -> None: + assert document_status(replace(BASE, lines=(StatusLine(OK, False), StatusLine(UNREADABLE, False)))) is CHECK + + +def test_detail_value_does_not_block() -> None: + lines = (StatusLine(OK, False), StatusLine(DETAIL, False)) + values = (StatusValue(OK, False), StatusValue(DETAIL, True)) + assert document_status(replace(BASE, lines=lines, values=values)) is DONE + + +def test_blocking_conditions() -> None: + hard = 2 + assert document_status(replace(BASE, values=(StatusValue(OK, True),))) is CHECK + assert document_status(replace(BASE, lines=(StatusLine(OK, True),))) is CHECK + assert document_status(replace(BASE, needs_review_fields=("report_date",))) is CHECK + assert document_status(replace(BASE, hard_failures=hard)) is CHECK + assert document_status(replace(BASE, hard_failures=hard, accepted=True)) is DONE + assert document_status(replace(BASE, warning_severities=(Severity.ERROR,))) is CHECK + assert document_status(replace(BASE, layout_found=False)) is CHECK + assert document_status(replace(BASE, has_organization_or_inn=False)) is CHECK + assert document_status(replace(BASE, period_known=False)) is CHECK + + +def test_disclosure_always_done() -> None: + disclosure = replace(BASE, document_type=DocumentType.DISCLOSURE, layout_found=False, lines=(), values=()) + assert document_status(disclosure) is DONE + + +def test_envelope(rules: Rules) -> None: + assert envelope_status([DONE, CHECK], []) is EnvelopeStatus.NEEDS_CHECK + assert envelope_status([DONE], []) is EnvelopeStatus.DONE + lost = make_warning("TABLE_LOST", "таблица без колонки кодов", rules, locator="file:a.json;table:1") + assert envelope_status([DONE], [lost]) is EnvelopeStatus.NEEDS_CHECK + assert envelope_status([], []) is EnvelopeStatus.ERROR + + +def test_status_input_from_document(rules: Rules) -> None: + head = parse_header(hdr(organization_name="ООО «Пример»", units="тыс. руб."), rules) + column = StatementColumn(2, "На 30.09.2025", 100.0, date(2025, 9, 30), PeriodKind.ABSOLUTE) + value = StatementValue(2, D("5"), None, "5", 60.0, True, needs_review=True) + detail = StatementLine(1, "row:1", DETAIL, None, None, None, "в т. ч.", 100.0, "row:0", False, (value,)) + line = StatementLine( + 0, "row:0", OK, "1210", "1210", 100.0, "Запасы", 100.0, None, False, (replace(value, needs_review=False),) + ) + doc = StatementDocument( + DocumentType.BALANCE, + FormVariant.FULL, + "file:t.json;table:0", + head, + (column,), + (line, detail), + StatementChecks((), ()), + DocStatus.DONE, + (), + ) + data = status_input(doc) + assert (data.layout_found, data.has_organization_or_inn, data.period_known, data.hard_failures) == ( + True, + True, + True, + 0, + ) + assert data.values == (StatusValue(OK, False), StatusValue(DETAIL, True)) + assert document_status(data) is DONE + empty = replace(doc, columns=(), lines=(), checks=None) + assert (status_input(empty).layout_found, status_input(empty).period_known) == (False, False)