feat(statements): чистые функции маппинга формы — период, черновик, статус; counterparty_check общий (D-2, D-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
4126a44e0e
commit
3a72702c3d
@ -14,7 +14,7 @@ from collections.abc import Mapping, Sequence
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from src.contract.result import OsvChecks, OsvDocument, OsvHeader, OsvRow
|
from src.contract.result import OsvChecks, OsvDocument, OsvRow
|
||||||
from src.domain.aliases import normalize_alias
|
from src.domain.aliases import normalize_alias
|
||||||
from src.models.enums import (
|
from src.models.enums import (
|
||||||
CounterpartyMatchKind,
|
CounterpartyMatchKind,
|
||||||
@ -206,25 +206,25 @@ def document_fields(doc: OsvDocument) -> DocumentDraft:
|
|||||||
|
|
||||||
|
|
||||||
def counterparty_check(
|
def counterparty_check(
|
||||||
header: OsvHeader, owner_inn: str, owner_names_normalized: frozenset[str]
|
inn: str | None, name: str | None, owner_inn: str, owner_names_normalized: frozenset[str]
|
||||||
) -> DocumentCheckResult:
|
) -> DocumentCheckResult:
|
||||||
"""D-8: организация в шапке ОСВ — владелец файла?
|
"""D-8, Срез 2c/3b: организация в шапке — владелец файла?
|
||||||
|
|
||||||
ИНН шапки есть → `MATCH`, если его цифры равны ИНН владельца, иначе `MISMATCH`. ИНН нет →
|
ИНН шапки есть → `MATCH`, если его цифры равны ИНН владельца, иначе `MISMATCH`. ИНН нет →
|
||||||
`normalize_alias(organization_name)` среди нормализованных имени и алиасов владельца → `MATCH`, иначе `MISMATCH`.
|
`normalize_alias(name)` среди нормализованных имени и алиасов владельца → `MATCH`, иначе `MISMATCH`.
|
||||||
Нет ни ИНН, ни имени (в том числе имя только из ОПФ и пунктуации) → `UNKNOWN`.
|
Нет ни ИНН, ни имени (в том числе имя только из ОПФ и пунктуации) → `UNKNOWN`.
|
||||||
"""
|
"""
|
||||||
header_inn = _NON_DIGITS_RE.sub("", header.organization_inn or "")
|
header_inn = _NON_DIGITS_RE.sub("", inn or "")
|
||||||
if header_inn:
|
if header_inn:
|
||||||
return (
|
return (
|
||||||
DocumentCheckResult.MATCH
|
DocumentCheckResult.MATCH
|
||||||
if header_inn == _NON_DIGITS_RE.sub("", owner_inn)
|
if header_inn == _NON_DIGITS_RE.sub("", owner_inn)
|
||||||
else DocumentCheckResult.MISMATCH
|
else DocumentCheckResult.MISMATCH
|
||||||
)
|
)
|
||||||
name = normalize_alias(header.organization_name)
|
normalized = normalize_alias(name or "")
|
||||||
if not name:
|
if not normalized:
|
||||||
return DocumentCheckResult.UNKNOWN
|
return DocumentCheckResult.UNKNOWN
|
||||||
return DocumentCheckResult.MATCH if name in owner_names_normalized else DocumentCheckResult.MISMATCH
|
return DocumentCheckResult.MATCH if normalized in owner_names_normalized else DocumentCheckResult.MISMATCH
|
||||||
|
|
||||||
|
|
||||||
def _checks_clean(checks: OsvChecks | None) -> bool:
|
def _checks_clean(checks: OsvChecks | None) -> bool:
|
||||||
|
|||||||
225
api/src/domain/statement_mapping.py
Normal file
225
api/src/domain/statement_mapping.py
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
"""Чистые функции маппинга формы ББ/ОФР (`statementDocument` v1.2) в черновики `financial_statement_*`
|
||||||
|
(spec osv-result-mapping «Документ формы ББ/ОФР», «Статус документа формы»; design D-2, D-3, D-6, D-14)."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.contract.result import StatementDocument, StatementValue
|
||||||
|
from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementType
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LineDraft",
|
||||||
|
"StatementDraft",
|
||||||
|
"ValueDraft",
|
||||||
|
"build_statement",
|
||||||
|
"edition_for",
|
||||||
|
"quarter_of",
|
||||||
|
"statement_period",
|
||||||
|
"statement_status",
|
||||||
|
"values_ok",
|
||||||
|
]
|
||||||
|
|
||||||
|
FULL_CONFIDENCE = Decimal("100")
|
||||||
|
EDITION_BOUNDARY = datetime.date(2024, 12, 31)
|
||||||
|
_QUARTER_ENDS = {
|
||||||
|
(3, 31): PeriodQuarter.Q1,
|
||||||
|
(6, 30): PeriodQuarter.Q2,
|
||||||
|
(9, 30): PeriodQuarter.Q3,
|
||||||
|
(12, 31): PeriodQuarter.Q4,
|
||||||
|
}
|
||||||
|
_STATEMENT_TYPES = {DocumentType.BALANCE: StatementType.balance, DocumentType.INCOME: StatementType.income}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValueDraft:
|
||||||
|
line_code: str
|
||||||
|
column_index: int
|
||||||
|
amount: Decimal | None
|
||||||
|
string_value: str | None
|
||||||
|
confidence: Decimal | None
|
||||||
|
is_recognized: bool | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LineDraft:
|
||||||
|
line_code: str
|
||||||
|
line_code_confidence: Decimal | None
|
||||||
|
line_name: str
|
||||||
|
line_name_confidence: Decimal | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StatementDraft:
|
||||||
|
document_type: DocumentType
|
||||||
|
statement_type: StatementType
|
||||||
|
locator: str
|
||||||
|
period_date: datetime.date | None
|
||||||
|
period_year: int | None
|
||||||
|
period_quarter: PeriodQuarter | None
|
||||||
|
header: dict[str, Any]
|
||||||
|
columns: list[tuple[int, str, datetime.date | None, bool | None]]
|
||||||
|
lines: list[LineDraft]
|
||||||
|
values: list[ValueDraft]
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def quarter_of(day: datetime.date) -> PeriodQuarter | None:
|
||||||
|
return _QUARTER_ENDS.get((day.month, day.day))
|
||||||
|
|
||||||
|
|
||||||
|
def statement_period(
|
||||||
|
report_date: datetime.date | None, column_dates: Sequence[datetime.date | None]
|
||||||
|
) -> datetime.date | None:
|
||||||
|
"""D-2: дата отчёта, иначе самая поздняя дата колонок."""
|
||||||
|
if report_date is not None:
|
||||||
|
return report_date
|
||||||
|
dates = [day for day in column_dates if day is not None]
|
||||||
|
return max(dates) if dates else None
|
||||||
|
|
||||||
|
|
||||||
|
def edition_for(period_date: datetime.date | None) -> str | None:
|
||||||
|
"""D-14: редакция формы для шаблона фронта."""
|
||||||
|
if period_date is None:
|
||||||
|
return None
|
||||||
|
return "66n" if period_date <= EDITION_BOUNDARY else "fsbu4"
|
||||||
|
|
||||||
|
|
||||||
|
def _decimal(value: float | None) -> Decimal | None:
|
||||||
|
return Decimal(str(value)) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_statement(doc: StatementDocument) -> StatementDraft:
|
||||||
|
"""Черновик формы `BALANCE`/`INCOME` (D-2): шапка, колонки, строки (повтор кода — первая), значение
|
||||||
|
на каждую ячейку."""
|
||||||
|
document_type = DocumentType(doc.document_type)
|
||||||
|
header = doc.header
|
||||||
|
warnings: list[str] = []
|
||||||
|
column_dates = [column.period_date for column in doc.columns]
|
||||||
|
period_date = statement_period(header.report_date, column_dates)
|
||||||
|
period_year = period_date.year if period_date is not None else None
|
||||||
|
period_quarter = quarter_of(period_date) if period_date is not None else None
|
||||||
|
if period_date is None:
|
||||||
|
warnings.append(f"{doc.locator}: период формы не определён (нет даты отчёта и дат колонок)")
|
||||||
|
elif period_quarter is None:
|
||||||
|
warnings.append(f"{doc.locator}: дата {period_date.isoformat()} не конец квартала — квартал не определён")
|
||||||
|
latest = max((day for day in column_dates if day is not None), default=None)
|
||||||
|
columns = [
|
||||||
|
(
|
||||||
|
column.column_index,
|
||||||
|
column.label,
|
||||||
|
column.period_date,
|
||||||
|
None
|
||||||
|
if latest is None
|
||||||
|
else column.period_date == latest
|
||||||
|
and column.column_index == min(c.column_index for c in doc.columns if c.period_date == latest),
|
||||||
|
)
|
||||||
|
for column in doc.columns
|
||||||
|
]
|
||||||
|
known_columns = {column.column_index for column in doc.columns}
|
||||||
|
lines: list[LineDraft] = []
|
||||||
|
values: list[ValueDraft] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for line in doc.lines:
|
||||||
|
if line.line_code in seen:
|
||||||
|
warnings.append(f"{doc.locator}: код {line.line_code} повторяется — оставлена первая строка")
|
||||||
|
continue
|
||||||
|
seen.add(line.line_code)
|
||||||
|
lines.append(
|
||||||
|
LineDraft(
|
||||||
|
line.line_code,
|
||||||
|
_decimal(line.line_code_confidence),
|
||||||
|
line.line_name,
|
||||||
|
_decimal(line.line_name_confidence),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
by_column: dict[int, StatementValue] = {}
|
||||||
|
for cell_value in line.values:
|
||||||
|
if cell_value.column_index not in known_columns:
|
||||||
|
warnings.append(
|
||||||
|
f"{doc.locator}: строка {line.line_code}: значение колонки {cell_value.column_index}"
|
||||||
|
" вне колонок формы пропущено"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
by_column.setdefault(cell_value.column_index, cell_value)
|
||||||
|
for column in doc.columns:
|
||||||
|
value: StatementValue | None = by_column.get(column.column_index)
|
||||||
|
values.append(
|
||||||
|
ValueDraft(
|
||||||
|
line.line_code,
|
||||||
|
column.column_index,
|
||||||
|
value.amount if value is not None else None,
|
||||||
|
value.string_value if value is not None else None,
|
||||||
|
_decimal(value.confidence) if value is not None else None,
|
||||||
|
value.is_recognized if value is not None else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inn_confidence = _decimal(header.inn_confidence)
|
||||||
|
meta = {
|
||||||
|
"inn": header.inn,
|
||||||
|
"inn_original_value": header.inn,
|
||||||
|
"inn_confidence": inn_confidence,
|
||||||
|
"inn_verified": inn_confidence == FULL_CONFIDENCE if header.inn is not None else None,
|
||||||
|
"inn_modified": False,
|
||||||
|
"organization_name": header.organization_name,
|
||||||
|
"report_date": header.report_date,
|
||||||
|
"report_date_original_value": header.report_date_text,
|
||||||
|
"report_date_verified": header.report_date is not None,
|
||||||
|
"report_date_modified": False,
|
||||||
|
"units": header.units,
|
||||||
|
"units_original_value": header.units,
|
||||||
|
"units_verified": header.units is not None,
|
||||||
|
"units_modified": False,
|
||||||
|
"okud": header.okud,
|
||||||
|
"okpo": header.okpo,
|
||||||
|
"okopf": header.okopf,
|
||||||
|
"okfs": header.okfs,
|
||||||
|
"address": header.address,
|
||||||
|
}
|
||||||
|
return StatementDraft(
|
||||||
|
document_type=document_type,
|
||||||
|
statement_type=_STATEMENT_TYPES[document_type],
|
||||||
|
locator=doc.locator,
|
||||||
|
period_date=period_date,
|
||||||
|
period_year=period_year,
|
||||||
|
period_quarter=period_quarter,
|
||||||
|
header=meta,
|
||||||
|
columns=columns,
|
||||||
|
lines=lines,
|
||||||
|
values=values,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def values_ok(values: Iterable[tuple[bool, bool | None, Decimal | None]]) -> bool:
|
||||||
|
"""D-6: каждое значение правлено аналитиком или распознано без сомнений (`is_recognized` не false,
|
||||||
|
уверенность 100/NULL)."""
|
||||||
|
return all(
|
||||||
|
modified or (recognized is not False and (confidence is None or confidence == FULL_CONFIDENCE))
|
||||||
|
for modified, recognized, confidence in values
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def statement_status( # noqa: PLR0913 - условия D-6 перечислены явно
|
||||||
|
*,
|
||||||
|
job_done: bool,
|
||||||
|
checks_passed: bool,
|
||||||
|
counterparty: DocumentCheckResult,
|
||||||
|
period_known: bool,
|
||||||
|
values_ok: bool,
|
||||||
|
has_lines: bool,
|
||||||
|
warnings: Sequence[str],
|
||||||
|
) -> DocumentStatus:
|
||||||
|
"""D-6: `RECOGNIZED` только при всех условиях; иначе `NEEDS_CHECK`."""
|
||||||
|
recognized = (
|
||||||
|
job_done
|
||||||
|
and checks_passed
|
||||||
|
and counterparty != DocumentCheckResult.MISMATCH
|
||||||
|
and period_known
|
||||||
|
and values_ok
|
||||||
|
and has_lines
|
||||||
|
and not warnings
|
||||||
|
)
|
||||||
|
return DocumentStatus.RECOGNIZED if recognized else DocumentStatus.NEEDS_CHECK
|
||||||
@ -386,7 +386,9 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай
|
|||||||
sheet_warnings.append(
|
sheet_warnings.append(
|
||||||
f"sheet={sheet.locator}: период {sheet.header.period_text}: нарастающая по данным Q1"
|
f"sheet={sheet.locator}: период {sheet.header.period_text}: нарастающая по данным Q1"
|
||||||
)
|
)
|
||||||
check = counterparty_check(sheet.header, owner.inn, owner_names)
|
check = counterparty_check(
|
||||||
|
sheet.header.organization_inn, sheet.header.organization_name, owner.inn, owner_names
|
||||||
|
)
|
||||||
document = Document(
|
document = Document(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
stored_file_id=file.id,
|
stored_file_id=file.id,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import pytest
|
|||||||
|
|
||||||
from scripts.seed_lib.loaders import load_chart_of_accounts
|
from scripts.seed_lib.loaders import load_chart_of_accounts
|
||||||
from src.contract.result import OsvChecks, OsvDocument, OsvHeader
|
from src.contract.result import OsvChecks, OsvDocument, OsvHeader
|
||||||
|
from src.domain.aliases import normalize_alias
|
||||||
from src.domain.osv_mapping import (
|
from src.domain.osv_mapping import (
|
||||||
DocumentDraft,
|
DocumentDraft,
|
||||||
EntryDraft,
|
EntryDraft,
|
||||||
@ -244,21 +245,21 @@ def test_document_fields_on_golden_osv_66(fixtures_dir: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("header", "expected"),
|
("inn", "name", "expected"),
|
||||||
[
|
[
|
||||||
({"organization_name": "АО «Русский бисквит»", "organization_inn": OWNER_INN}, DocumentCheckResult.MATCH),
|
(OWNER_INN, "АО «Русский бисквит»", DocumentCheckResult.MATCH),
|
||||||
({"organization_name": "", "organization_inn": " 3528 052718 "}, DocumentCheckResult.MATCH),
|
(" 3528 052718 ", "", DocumentCheckResult.MATCH),
|
||||||
({"organization_name": "АО «Русский бисквит»", "organization_inn": "3528000000"}, DocumentCheckResult.MISMATCH),
|
("3528000000", "АО «Русский бисквит»", DocumentCheckResult.MISMATCH),
|
||||||
({"organization_name": 'АО "Русский бисквит"'}, DocumentCheckResult.MATCH), # по нормализованному имени
|
(None, 'АО "Русский бисквит"', DocumentCheckResult.MATCH), # по нормализованному имени
|
||||||
({"organization_name": "ООО РБ"}, DocumentCheckResult.MATCH), # по алиасу
|
(None, "ООО РБ", DocumentCheckResult.MATCH), # по алиасу
|
||||||
({"organization_name": "Пекарня Счастливочная"}, DocumentCheckResult.MISMATCH),
|
(None, "Пекарня Счастливочная", DocumentCheckResult.MISMATCH),
|
||||||
({"organization_name": "", "organization_inn": None}, DocumentCheckResult.UNKNOWN), # пустая шапка
|
(None, "", DocumentCheckResult.UNKNOWN), # пустая шапка
|
||||||
({"organization_name": " ", "organization_inn": ""}, DocumentCheckResult.UNKNOWN),
|
("", " ", DocumentCheckResult.UNKNOWN),
|
||||||
({"organization_name": "ООО"}, DocumentCheckResult.UNKNOWN), # только ОПФ: нормализуется в пустое имя
|
(None, "ООО", DocumentCheckResult.UNKNOWN), # только ОПФ: нормализуется в пустое имя
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_counterparty_check(header: dict[str, Any], expected: DocumentCheckResult) -> None:
|
def test_counterparty_check(inn: str | None, name: str | None, expected: DocumentCheckResult) -> None:
|
||||||
assert counterparty_check(_header(**header), OWNER_INN, OWNER_NAMES) is expected
|
assert counterparty_check(inn, name, OWNER_INN, OWNER_NAMES) is expected
|
||||||
|
|
||||||
|
|
||||||
# --- статус документа (§9.3, OQ-05) ---------------------------------------------------------------------------------
|
# --- статус документа (§9.3, OQ-05) ---------------------------------------------------------------------------------
|
||||||
@ -319,3 +320,11 @@ def test_account_sort_key_survives_non_ascii_digits(code: str) -> None:
|
|||||||
key = account_sort_key(code)
|
key = account_sort_key(code)
|
||||||
assert key == account_sort_key(code) # детерминирован
|
assert key == account_sort_key(code) # детерминирован
|
||||||
assert sorted(["A", code, "60"], key=account_sort_key)[0] == "60" # ASCII-числа — раньше нечисловых сегментов
|
assert sorted(["A", code, "60"], key=account_sort_key)[0] == "60" # ASCII-числа — раньше нечисловых сегментов
|
||||||
|
|
||||||
|
|
||||||
|
def test_counterparty_check_plain_values() -> None:
|
||||||
|
names = frozenset({normalize_alias("ООО Ромашка")})
|
||||||
|
assert counterparty_check("7700000000", None, "7700000000", names) is DocumentCheckResult.MATCH
|
||||||
|
assert counterparty_check(None, "ООО «Ромашка»", "7700000001", names) is DocumentCheckResult.MATCH
|
||||||
|
assert counterparty_check("3121200351", "мусор", "7700000000", names) is DocumentCheckResult.MISMATCH
|
||||||
|
assert counterparty_check(None, None, "7700000000", names) is DocumentCheckResult.UNKNOWN
|
||||||
|
|||||||
155
api/tests/test_domain_statement_mapping.py
Normal file
155
api/tests/test_domain_statement_mapping.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
"""Чистые функции маппинга формы (spec osv-result-mapping «Документ формы ББ/ОФР»; design D-2, D-3, D-6, D-14)."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.contract.result import StatementDocument
|
||||||
|
from src.domain.statement_mapping import (
|
||||||
|
build_statement,
|
||||||
|
edition_for,
|
||||||
|
quarter_of,
|
||||||
|
statement_period,
|
||||||
|
statement_status,
|
||||||
|
values_ok,
|
||||||
|
)
|
||||||
|
from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementType
|
||||||
|
|
||||||
|
d = datetime.date
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("day", "quarter"),
|
||||||
|
[
|
||||||
|
(d(2025, 3, 31), PeriodQuarter.Q1),
|
||||||
|
(d(2025, 6, 30), PeriodQuarter.Q2),
|
||||||
|
(d(2025, 9, 30), PeriodQuarter.Q3),
|
||||||
|
(d(2024, 12, 31), PeriodQuarter.Q4),
|
||||||
|
(d(2025, 10, 31), None),
|
||||||
|
(d(2025, 6, 29), None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_quarter_of(day: datetime.date, quarter: PeriodQuarter | None) -> None:
|
||||||
|
assert quarter_of(day) is quarter
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_and_edition() -> None:
|
||||||
|
assert statement_period(d(2025, 9, 30), [d(2024, 12, 31)]) == d(2025, 9, 30)
|
||||||
|
assert statement_period(None, [d(2024, 12, 31), d(2025, 6, 30), None]) == d(2025, 6, 30)
|
||||||
|
assert statement_period(None, [None]) is None
|
||||||
|
assert edition_for(d(2024, 12, 31)) == "66n"
|
||||||
|
assert edition_for(d(2025, 3, 31)) == "fsbu4"
|
||||||
|
assert edition_for(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _doc(**overrides: object) -> StatementDocument:
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"document_type": "BALANCE",
|
||||||
|
"locator": "sheet:ББ",
|
||||||
|
"header": {
|
||||||
|
"form_name": "Бухгалтерский баланс",
|
||||||
|
"inn": "7700000000",
|
||||||
|
"inn_confidence": 100,
|
||||||
|
"report_date": "2025-09-30",
|
||||||
|
"report_date_text": "на 30 сентября 2025 г.",
|
||||||
|
"units": "тыс. руб.",
|
||||||
|
"organization_name": "ООО Пример",
|
||||||
|
"okud": "0710001",
|
||||||
|
},
|
||||||
|
"columns": [
|
||||||
|
{"column_index": 0, "label": "На 30.09.2025", "period_date": "2025-09-30"},
|
||||||
|
{"column_index": 1, "label": "На 31.12.2024", "period_date": "2024-12-31"},
|
||||||
|
],
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"line_code": "1150",
|
||||||
|
"line_code_confidence": 100,
|
||||||
|
"line_name": "ОС",
|
||||||
|
"line_name_confidence": 100,
|
||||||
|
"values": [{"column_index": 0, "amount": "10", "confidence": 100, "is_recognized": True}],
|
||||||
|
},
|
||||||
|
{"line_code": "1150", "line_name": "дубль", "values": []},
|
||||||
|
{
|
||||||
|
"line_code": "1600",
|
||||||
|
"line_name": "БАЛАНС",
|
||||||
|
"values": [{"column_index": 5, "amount": "1", "confidence": 100, "is_recognized": True}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return StatementDocument.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_statement_cells_and_header() -> None:
|
||||||
|
draft = build_statement(_doc())
|
||||||
|
expected_cells = 4 # 2 строки × 2 колонки
|
||||||
|
assert draft.document_type is DocumentType.BALANCE and draft.statement_type is StatementType.balance
|
||||||
|
assert (draft.period_year, draft.period_quarter, draft.period_date) == (2025, PeriodQuarter.Q3, d(2025, 9, 30))
|
||||||
|
assert draft.header["inn"] == "7700000000" and draft.header["inn_original_value"] == "7700000000"
|
||||||
|
assert draft.header["inn_verified"] is True
|
||||||
|
assert draft.header["report_date_original_value"] == "на 30 сентября 2025 г."
|
||||||
|
assert draft.header["units"] == draft.header["units_original_value"] == "тыс. руб."
|
||||||
|
assert draft.columns == [(0, "На 30.09.2025", d(2025, 9, 30), True), (1, "На 31.12.2024", d(2024, 12, 31), False)]
|
||||||
|
assert [line.line_code for line in draft.lines] == ["1150", "1600"] # дубль 1150 отброшен
|
||||||
|
cells = {(v.line_code, v.column_index): v for v in draft.values}
|
||||||
|
assert len(cells) == expected_cells
|
||||||
|
assert cells[("1150", 0)].amount == Decimal("10") and cells[("1150", 1)].amount is None
|
||||||
|
assert cells[("1150", 1)].is_recognized is None and cells[("1600", 0)].confidence is None
|
||||||
|
assert any("1150" in w and "повтор" in w for w in draft.warnings)
|
||||||
|
assert any("колонк" in w and "5" in w for w in draft.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_outside_columns_skipped() -> None:
|
||||||
|
"""Review Focus 3: значение с column_index вне колонок — пропущено с предупреждением, остальное смаплено."""
|
||||||
|
draft = build_statement(_doc())
|
||||||
|
assert all(v.column_index in (0, 1) for v in draft.values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_from_columns_and_non_quarter() -> None:
|
||||||
|
no_date = _doc(header={"form_name": "ББ", "report_date": None})
|
||||||
|
draft = build_statement(no_date)
|
||||||
|
assert (draft.period_year, draft.period_quarter) == (2025, PeriodQuarter.Q3)
|
||||||
|
assert draft.header["report_date"] is None
|
||||||
|
october = _doc(header={"form_name": "ББ", "report_date": "2025-10-31"})
|
||||||
|
draft = build_statement(october)
|
||||||
|
assert (draft.period_year, draft.period_quarter) == (2025, None)
|
||||||
|
assert any("конец квартала" in w for w in draft.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_dates_at_all() -> None:
|
||||||
|
draft = build_statement(_doc(header={"form_name": "ББ"}, columns=[{"column_index": 0, "label": ""}]))
|
||||||
|
assert (draft.period_year, draft.period_quarter, draft.period_date) == (None, None, None)
|
||||||
|
assert draft.columns == [(0, "", None, None)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_rules() -> None:
|
||||||
|
ok = dict(
|
||||||
|
job_done=True,
|
||||||
|
checks_passed=True,
|
||||||
|
counterparty=DocumentCheckResult.MATCH,
|
||||||
|
period_known=True,
|
||||||
|
values_ok=True,
|
||||||
|
has_lines=True,
|
||||||
|
warnings=[],
|
||||||
|
)
|
||||||
|
assert statement_status(**ok) is DocumentStatus.RECOGNIZED # type: ignore[arg-type]
|
||||||
|
for key, bad in [
|
||||||
|
("job_done", False),
|
||||||
|
("checks_passed", False),
|
||||||
|
("counterparty", DocumentCheckResult.MISMATCH),
|
||||||
|
("period_known", False),
|
||||||
|
("values_ok", False),
|
||||||
|
("has_lines", False),
|
||||||
|
("warnings", ["x"]),
|
||||||
|
]:
|
||||||
|
assert statement_status(**{**ok, key: bad}) is DocumentStatus.NEEDS_CHECK # type: ignore[arg-type]
|
||||||
|
assert statement_status(**{**ok, "counterparty": DocumentCheckResult.UNKNOWN}) is DocumentStatus.RECOGNIZED # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_values_ok() -> None:
|
||||||
|
hundred = Decimal("100")
|
||||||
|
assert values_ok([(False, True, hundred), (False, None, None)])
|
||||||
|
assert not values_ok([(False, False, hundred)])
|
||||||
|
assert not values_ok([(False, True, Decimal("99"))])
|
||||||
|
assert values_ok([(True, False, Decimal("10"))]) # правленое аналитиком значение не тянет статус вниз
|
||||||
Loading…
x
Reference in New Issue
Block a user