Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
156 lines
6.5 KiB
Python
156 lines
6.5 KiB
Python
"""Чистые функции маппинга формы (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"))]) # правленое аналитиком значение не тянет статус вниз
|