feat(domain): маппинг osvDocument в черновики документа и строк, нормализация кода счёта, проверки (§1.3, §9.3)
domain/osv_mapping.py — чистые функции без БД и S3 (design D-6…D-9): normalize_account_code / account_root (ASSUMPTION(OQ-103)), build_entries (без TOTAL, дерево по parent_row_number, сироты, account_id по плану счетов, EntryDraft.counterparty_id = None по R-3), document_fields (DocumentDraft), counterparty_check, document_status (OQ-05), opening_matches (±0.01, OQ-02). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
3b4d3a45fe
commit
f9306b8270
228
api/src/domain/osv_mapping.py
Normal file
228
api/src/domain/osv_mapping.py
Normal file
@ -0,0 +1,228 @@
|
||||
"""Маппинг `osvDocument` результата распознавания в черновики `document` и `osv_entry` (§1.3, §1.5, §9.3).
|
||||
|
||||
Чистые функции без БД, S3 и сессии (spec osv-result-mapping; design D-6…D-9): нормализация кода счёта, строки ОСВ
|
||||
с деревом по `parent_row_number`, поля документа из шапки, проверка контрагента шапки, статус документа и сверка
|
||||
сальдо на начало с Q1 для признака нарастающей. Резолв контрагента строки и запись в БД — в цикле маппинга
|
||||
(`services/result_mapping.py`), он проставляет `EntryDraft.counterparty_id` через `dataclasses.replace`.
|
||||
Суммы — `Decimal` из контракта без изменений, `float` не используется.
|
||||
"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
from src.contract.result import OsvChecks, OsvDocument, OsvHeader, OsvRow
|
||||
from src.domain.aliases import normalize_alias
|
||||
from src.models.enums import DocumentCheckResult, DocumentStatus, OsvDataType, OsvGroupType, PeriodQuarter
|
||||
|
||||
__all__ = [
|
||||
"ACCOUNT_SEGMENT_DIGITS",
|
||||
"OPENING_TOLERANCE",
|
||||
"DocumentDraft",
|
||||
"EntryDraft",
|
||||
"MappedSheet",
|
||||
"account_root",
|
||||
"build_entries",
|
||||
"counterparty_check",
|
||||
"document_fields",
|
||||
"document_status",
|
||||
"normalize_account_code",
|
||||
"opening_matches",
|
||||
]
|
||||
|
||||
ACCOUNT_SEGMENT_DIGITS = 2 # субсчёт — две цифры: «60.1» → «60.01»
|
||||
SUBACCOUNT_SEGMENT_INDEX = 1 # дополняется нулём только второй сегмент; первый и третий — как есть (§1.3)
|
||||
ROOT_LEVEL = 0
|
||||
TOTAL_GROUP_TYPE = "TOTAL" # «Итого» не хранится как osv_entry (ASSUMPTION(OQ-01))
|
||||
OPENING_TOLERANCE = Decimal("0.01") # D-9: сальдо на начало совпадает с Q1 с точностью ±0.01
|
||||
|
||||
_NUMERIC_SEGMENT_RE = re.compile(r"[0-9]{1,3}")
|
||||
_ROOT_SEGMENT_RE = re.compile(r"[0-9]+")
|
||||
_NON_DIGITS_RE = re.compile(r"[^0-9]")
|
||||
_SEGMENT_SEPARATOR = "."
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntryDraft:
|
||||
"""Черновик `osv_entry`: id выдаётся заранее (`uuid4`), чтобы связать дерево до вставки."""
|
||||
|
||||
id: uuid.UUID
|
||||
row_number: int
|
||||
level: int
|
||||
parent_id: uuid.UUID | None
|
||||
row_value: str
|
||||
group_type: OsvGroupType
|
||||
group_name: str | None
|
||||
data_type: OsvDataType | None
|
||||
account_code: str | None
|
||||
account_root: str | None
|
||||
account_id: uuid.UUID | None
|
||||
counterparty_name: str | None
|
||||
counterparty_inn: str | None
|
||||
# opening_debit, opening_credit, turnover_debit, turnover_credit, closing_debit, closing_credit
|
||||
sums: tuple[Decimal | None, ...]
|
||||
counterparty_id: uuid.UUID | None = None # проставляет цикл маппинга (резолв §3 п. 4), не build_entries
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MappedSheet:
|
||||
"""Строки одного листа ОСВ: без `TOTAL`; `root` — корневая строка (level 0 без родителя) для сверки D-9."""
|
||||
|
||||
entries: list[EntryDraft]
|
||||
root: EntryDraft | None
|
||||
total_present: bool
|
||||
orphans: tuple[int, ...] # row_number строк, чей parent_row_number не найден: вызывающий пишет предупреждение
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocumentDraft:
|
||||
"""Поля `document` (DBML v5.15), которые задаёт шапка листа ОСВ; остальное (ЮЛ, файл, статус) — вызывающий."""
|
||||
|
||||
osv_account_code: str
|
||||
period_year: int | None
|
||||
period_quarter: PeriodQuarter | None
|
||||
is_cumulative: bool | None # по тексту периода; ядро может переопределить по данным (D-9, ASSUMPTION(OQ-02))
|
||||
locator: str
|
||||
|
||||
|
||||
def normalize_account_code(raw: str) -> str:
|
||||
"""§1.3: «60.1» → «60.01», «76.7.1» → «76.07.1», «60.01.1», «76.АВ», «МЦ.04», «001» — без изменений.
|
||||
|
||||
Сегменты по «.»; числовой (1–3 цифры) второй сегмент дополняется нулём слева до двух цифр, первый и третий
|
||||
сегменты и буквенные сегменты остаются как в файле.
|
||||
"""
|
||||
segments = raw.strip().split(_SEGMENT_SEPARATOR)
|
||||
if len(segments) > SUBACCOUNT_SEGMENT_INDEX:
|
||||
sub = segments[SUBACCOUNT_SEGMENT_INDEX]
|
||||
if _NUMERIC_SEGMENT_RE.fullmatch(sub):
|
||||
segments[SUBACCOUNT_SEGMENT_INDEX] = sub.zfill(ACCOUNT_SEGMENT_DIGITS)
|
||||
return _SEGMENT_SEPARATOR.join(segments)
|
||||
|
||||
|
||||
def account_root(code: str) -> str | None:
|
||||
"""Первый сегмент кода («66», «001» — забалансовый), если он из цифр; иначе `None`."""
|
||||
# ASSUMPTION(OQ-103): счета с буквой в первом сегменте («МЦ.04», «ГТД») — без account_root
|
||||
first = code.strip().split(_SEGMENT_SEPARATOR, 1)[0]
|
||||
return first if _ROOT_SEGMENT_RE.fullmatch(first) else None
|
||||
|
||||
|
||||
def _sums(row: OsvRow) -> tuple[Decimal | None, ...]:
|
||||
return (
|
||||
row.opening_debit,
|
||||
row.opening_credit,
|
||||
row.turnover_debit,
|
||||
row.turnover_credit,
|
||||
row.closing_debit,
|
||||
row.closing_credit,
|
||||
)
|
||||
|
||||
|
||||
def build_entries(doc: OsvDocument, chart: Mapping[str, uuid.UUID]) -> MappedSheet:
|
||||
"""D-6: все строки листа, кроме `TOTAL`, в черновики `osv_entry`.
|
||||
|
||||
`parent_id` — по `parent_row_number` через карту `row_number → id`; родителя нет в карте → `parent_id = None` и
|
||||
`row_number` в `orphans`. `account_code` — `account_code_raw` строки как пришёл (у не-счётов парсер его наследует
|
||||
от ветки); `account_id` — точное совпадение нормализованного кода с `chart` (`chart_of_account.code → id`).
|
||||
"""
|
||||
rows = [row for row in doc.rows if row.group_type != TOTAL_GROUP_TYPE]
|
||||
ids = {row.row_number: uuid.uuid4() for row in rows}
|
||||
entries: list[EntryDraft] = []
|
||||
orphans: list[int] = []
|
||||
root: EntryDraft | None = None
|
||||
for row in rows:
|
||||
parent_id = ids.get(row.parent_row_number) if row.parent_row_number is not None else None
|
||||
if row.parent_row_number is not None and parent_id is None:
|
||||
orphans.append(row.row_number)
|
||||
code = row.account_code_raw
|
||||
entry = EntryDraft(
|
||||
id=ids[row.row_number],
|
||||
row_number=row.row_number,
|
||||
level=row.level,
|
||||
parent_id=parent_id,
|
||||
row_value=row.row_value,
|
||||
group_type=OsvGroupType(row.group_type),
|
||||
group_name=row.group_name,
|
||||
data_type=OsvDataType(row.data_type) if row.data_type else None,
|
||||
account_code=code,
|
||||
account_root=account_root(code) if code else None,
|
||||
account_id=chart.get(normalize_account_code(code)) if code else None,
|
||||
counterparty_name=row.counterparty_name,
|
||||
counterparty_inn=row.counterparty_inn,
|
||||
sums=_sums(row),
|
||||
)
|
||||
if root is None and row.parent_row_number is None and row.level == ROOT_LEVEL:
|
||||
root = entry
|
||||
entries.append(entry)
|
||||
return MappedSheet(
|
||||
entries=entries,
|
||||
root=root,
|
||||
total_present=len(rows) != len(doc.rows),
|
||||
orphans=tuple(orphans),
|
||||
)
|
||||
|
||||
|
||||
def document_fields(doc: OsvDocument) -> DocumentDraft:
|
||||
"""D-7, §1.5: счёт (нормализованный), период и признак нарастающей — по шапке листа, `locator` — как пришёл."""
|
||||
header = doc.header
|
||||
return DocumentDraft(
|
||||
osv_account_code=normalize_account_code(header.account_code_raw),
|
||||
period_year=header.period_year,
|
||||
period_quarter=PeriodQuarter(header.period_quarter) if header.period_quarter else None,
|
||||
is_cumulative=header.is_cumulative,
|
||||
locator=doc.locator,
|
||||
)
|
||||
|
||||
|
||||
def counterparty_check(
|
||||
header: OsvHeader, owner_inn: str, owner_names_normalized: frozenset[str]
|
||||
) -> DocumentCheckResult:
|
||||
"""D-8: организация в шапке ОСВ — владелец файла?
|
||||
|
||||
ИНН шапки есть → `MATCH`, если его цифры равны ИНН владельца, иначе `MISMATCH`. ИНН нет →
|
||||
`normalize_alias(organization_name)` среди нормализованных имени и алиасов владельца → `MATCH`, иначе `MISMATCH`.
|
||||
Нет ни ИНН, ни имени (в том числе имя только из ОПФ и пунктуации) → `UNKNOWN`.
|
||||
"""
|
||||
header_inn = _NON_DIGITS_RE.sub("", header.organization_inn or "")
|
||||
if header_inn:
|
||||
return (
|
||||
DocumentCheckResult.MATCH
|
||||
if header_inn == _NON_DIGITS_RE.sub("", owner_inn)
|
||||
else DocumentCheckResult.MISMATCH
|
||||
)
|
||||
name = normalize_alias(header.organization_name)
|
||||
if not name:
|
||||
return DocumentCheckResult.UNKNOWN
|
||||
return DocumentCheckResult.MATCH if name in owner_names_normalized else DocumentCheckResult.MISMATCH
|
||||
|
||||
|
||||
def _checks_clean(checks: OsvChecks | None) -> bool:
|
||||
if checks is None:
|
||||
return True
|
||||
return (
|
||||
checks.total_row_matches_root is not False
|
||||
and not checks.balance_identity_violations
|
||||
and not checks.parent_sum_violations
|
||||
)
|
||||
|
||||
|
||||
def document_status(
|
||||
*,
|
||||
job_done: bool,
|
||||
checks: OsvChecks | None,
|
||||
counterparty: DocumentCheckResult,
|
||||
warnings: Sequence[str],
|
||||
) -> DocumentStatus:
|
||||
"""§9.3, D-7 (OQ-05): `RECOGNIZED` только при задании `done`, `checks` без нарушений, контрагенте шапки не
|
||||
`MISMATCH` и без предупреждений маппинга; иначе `NEEDS_CHECK`."""
|
||||
recognized = job_done and _checks_clean(checks) and counterparty != DocumentCheckResult.MISMATCH and not warnings
|
||||
return DocumentStatus.RECOGNIZED if recognized else DocumentStatus.NEEDS_CHECK
|
||||
|
||||
|
||||
def opening_matches(a: EntryDraft, b_opening: tuple[Decimal | None, Decimal | None]) -> bool:
|
||||
"""D-9: сальдо на начало (дебет, кредит) строки `a` равно `b_opening` с точностью ±0.01; `None` = 0."""
|
||||
# ASSUMPTION(OQ-02): нарастающая определяется совпадением сальдо на начало корня с документом Q1
|
||||
zero = Decimal(0)
|
||||
pairs = zip(a.sums[:2], b_opening, strict=True)
|
||||
return all(abs((left or zero) - (right or zero)) <= OPENING_TOLERANCE for left, right in pairs)
|
||||
305
api/tests/test_domain_osv_mapping.py
Normal file
305
api/tests/test_domain_osv_mapping.py
Normal file
@ -0,0 +1,305 @@
|
||||
"""Чистый домен маппинга `osvDocument` (spec osv-result-mapping; design D-6…D-9; §1.3, §1.5, §9.3).
|
||||
|
||||
Без БД и S3: строки → черновики `osv_entry`, шапка → черновик `document`, проверка контрагента шапки, статус документа,
|
||||
сверка сальдо на начало для нарастающей (D-9).
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.seed_lib.loaders import load_chart_of_accounts
|
||||
from src.contract.result import OsvChecks, OsvDocument, OsvHeader
|
||||
from src.domain.osv_mapping import (
|
||||
DocumentDraft,
|
||||
EntryDraft,
|
||||
account_root,
|
||||
build_entries,
|
||||
counterparty_check,
|
||||
document_fields,
|
||||
document_status,
|
||||
normalize_account_code,
|
||||
opening_matches,
|
||||
)
|
||||
from src.models.enums import DocumentCheckResult, DocumentStatus, OsvDataType, OsvGroupType, PeriodQuarter
|
||||
|
||||
API_DIR = Path(__file__).resolve().parent.parent
|
||||
SEED_DIR = API_DIR / "seed"
|
||||
MINIMAL_EXAMPLE = API_DIR / "contracts" / "examples" / "result-osv-minimal.json"
|
||||
GOLDEN_OSV_66 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОСВ_66 за 2024.json"
|
||||
|
||||
OWNER_INN = "3528052718"
|
||||
OWNER_NAMES = frozenset({"русский бисквит", "рб"})
|
||||
SUM_COUNT = 6
|
||||
|
||||
|
||||
def _load_doc(path: Path) -> OsvDocument:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return OsvDocument.model_validate(data["documents"][0])
|
||||
|
||||
|
||||
def _seed_chart() -> dict[str, uuid.UUID]:
|
||||
"""`code → id` из seed-плана счетов (как `chart_of_account` после `make seed`)."""
|
||||
codes = load_chart_of_accounts(SEED_DIR / "core", SEED_DIR)
|
||||
return {code: uuid.uuid5(uuid.NAMESPACE_URL, f"chart:{code}") for code in codes}
|
||||
|
||||
|
||||
def _header(**fields: Any) -> OsvHeader:
|
||||
base: dict[str, Any] = {"organization_name": "", "account_code_raw": "60", "period_text": "2024 г."}
|
||||
return OsvHeader.model_validate(base | fields)
|
||||
|
||||
|
||||
def _entry(opening_debit: Decimal | None, opening_credit: Decimal | None) -> EntryDraft:
|
||||
return EntryDraft(
|
||||
id=uuid.uuid4(),
|
||||
row_number=1,
|
||||
level=0,
|
||||
parent_id=None,
|
||||
row_value="60",
|
||||
group_type=OsvGroupType.ACCOUNT,
|
||||
group_name=None,
|
||||
data_type=OsvDataType.BU,
|
||||
account_code="60",
|
||||
account_root="60",
|
||||
account_id=None,
|
||||
counterparty_name=None,
|
||||
counterparty_inn=None,
|
||||
sums=(opening_debit, opening_credit, None, None, None, None),
|
||||
)
|
||||
|
||||
|
||||
# --- нормализация кода счёта (§1.3) ---------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("60.1", "60.01"),
|
||||
("76.7.1", "76.07.1"),
|
||||
("60.01.1", "60.01.1"),
|
||||
("58.03", "58.03"),
|
||||
("76.АВ", "76.АВ"),
|
||||
("МЦ.04", "МЦ.04"),
|
||||
("001", "001"),
|
||||
("60", "60"),
|
||||
],
|
||||
)
|
||||
def test_normalize_account_code(raw: str, expected: str) -> None:
|
||||
assert normalize_account_code(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("code", "expected"), [("60", "60"), ("60.01", "60"), ("001", "001"), ("МЦ.04", None)])
|
||||
def test_account_root_is_numeric_first_segment(code: str, expected: str | None) -> None:
|
||||
"""ASSUMPTION(OQ-103): нечисловой первый сегмент (забалансовые «МЦ.04») → `None`."""
|
||||
assert account_root(code) == expected
|
||||
|
||||
|
||||
# --- строки ОСВ (D-6) -----------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_entries_on_minimal_example() -> None:
|
||||
doc = _load_doc(MINIMAL_EXAMPLE)
|
||||
chart = {"60": uuid.uuid4()}
|
||||
|
||||
sheet = build_entries(doc, chart)
|
||||
|
||||
assert [e.row_number for e in sheet.entries] == [1, 2]
|
||||
root, child = sheet.entries
|
||||
assert sheet.root == root
|
||||
assert sheet.total_present is False
|
||||
assert sheet.orphans == ()
|
||||
assert root.parent_id is None
|
||||
assert child.parent_id == root.id
|
||||
assert root.id != child.id
|
||||
assert root.group_type is OsvGroupType.ACCOUNT
|
||||
assert child.group_type is OsvGroupType.COUNTERPARTY
|
||||
assert child.group_name == "Контрагенты"
|
||||
assert child.data_type is OsvDataType.BU
|
||||
assert child.account_code == "60"
|
||||
assert child.account_root == "60"
|
||||
assert child.account_id == chart["60"]
|
||||
assert child.counterparty_name == "ООО Лютик"
|
||||
assert child.counterparty_inn == "7711111111"
|
||||
assert child.counterparty_id is None # R-3: резолв — в цикле маппинга, не здесь
|
||||
assert child.sums == (
|
||||
Decimal("0.00"),
|
||||
Decimal("1000.00"),
|
||||
Decimal("500.00"),
|
||||
Decimal("200.50"),
|
||||
Decimal("0.00"),
|
||||
Decimal("700.50"),
|
||||
)
|
||||
assert all(isinstance(value, Decimal) for value in child.sums)
|
||||
|
||||
|
||||
def test_build_entries_skips_total_and_reports_orphans() -> None:
|
||||
data = json.loads(MINIMAL_EXAMPLE.read_text(encoding="utf-8"))["documents"][0]
|
||||
rows = data["rows"]
|
||||
total = rows[0] | {"row_number": 3, "row_value": "Итого", "group_type": "TOTAL", "account_code_raw": None}
|
||||
orphan = rows[1] | {"row_number": 4, "parent_row_number": 99, "data_type": "NU", "account_code_raw": "МЦ.04"}
|
||||
doc = OsvDocument.model_validate(data | {"rows": [*rows, total, orphan]})
|
||||
|
||||
sheet = build_entries(doc, {})
|
||||
|
||||
assert [e.row_number for e in sheet.entries] == [1, 2, 4]
|
||||
assert sheet.total_present is True
|
||||
assert sheet.orphans == (4,)
|
||||
last = sheet.entries[-1]
|
||||
assert last.parent_id is None
|
||||
assert last.data_type is OsvDataType.NU
|
||||
assert last.account_code == "МЦ.04"
|
||||
assert last.account_root is None
|
||||
assert last.account_id is None
|
||||
|
||||
|
||||
def test_build_entries_keeps_absent_account_and_data_type() -> None:
|
||||
data = json.loads(MINIMAL_EXAMPLE.read_text(encoding="utf-8"))["documents"][0]
|
||||
row = data["rows"][1] | {"account_code_raw": None, "data_type": None, "opening_debit": None}
|
||||
doc = OsvDocument.model_validate(data | {"rows": [data["rows"][0], row]})
|
||||
|
||||
entry = build_entries(doc, {"60": uuid.uuid4()}).entries[1]
|
||||
|
||||
assert entry.account_code is None
|
||||
assert entry.account_root is None
|
||||
assert entry.account_id is None
|
||||
assert entry.data_type is None
|
||||
assert entry.sums[0] is None
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_build_entries_on_golden_osv_66(fixtures_dir: Path) -> None:
|
||||
doc = _load_doc(fixtures_dir / GOLDEN_OSV_66)
|
||||
chart = _seed_chart()
|
||||
|
||||
sheet = build_entries(doc, chart)
|
||||
|
||||
assert len(sheet.entries) == len(doc.rows) - 1 # всё, кроме TOTAL (OQ-01)
|
||||
assert sheet.total_present is True
|
||||
assert sheet.orphans == ()
|
||||
assert sheet.root is not None
|
||||
assert sheet.root.row_value == "66"
|
||||
by_number = {e.row_number: e for e in sheet.entries}
|
||||
by_id = {e.id: e for e in sheet.entries}
|
||||
bakery = [e for e in sheet.entries if e.counterparty_name == "Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ"]
|
||||
assert [e.account_code for e in bakery] == ["66.03", "66.04"]
|
||||
first = bakery[0]
|
||||
assert first.parent_id is not None
|
||||
assert by_id[first.parent_id].row_value == "66.03"
|
||||
assert first.account_root == "66"
|
||||
assert first.account_id == chart["66.03"]
|
||||
assert by_number[13].account_id is None # 66.01 нет в seed-плане счетов
|
||||
for entry in sheet.entries:
|
||||
assert (entry.level == 0) == (entry.parent_id is None)
|
||||
assert len(entry.sums) == SUM_COUNT
|
||||
assert all(value is None or isinstance(value, Decimal) for value in entry.sums)
|
||||
|
||||
|
||||
# --- документ (D-7) -------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_document_fields_on_minimal_example() -> None:
|
||||
draft = document_fields(_load_doc(MINIMAL_EXAMPLE))
|
||||
|
||||
assert draft == DocumentDraft(
|
||||
osv_account_code="60",
|
||||
period_year=2025,
|
||||
period_quarter=PeriodQuarter.Q1,
|
||||
is_cumulative=False,
|
||||
locator="sheet:Лист_1",
|
||||
)
|
||||
|
||||
|
||||
def test_document_fields_normalize_account_and_keep_absent_period() -> None:
|
||||
data = json.loads(MINIMAL_EXAMPLE.read_text(encoding="utf-8"))["documents"][0]
|
||||
header = data["header"] | {"account_code_raw": "60.1", "period_year": None, "period_quarter": None}
|
||||
header.pop("is_cumulative")
|
||||
draft = document_fields(OsvDocument.model_validate(data | {"header": header}))
|
||||
|
||||
assert draft.osv_account_code == "60.01"
|
||||
assert draft.period_year is None
|
||||
assert draft.period_quarter is None
|
||||
assert draft.is_cumulative is None
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_document_fields_on_golden_osv_66(fixtures_dir: Path) -> None:
|
||||
draft = document_fields(_load_doc(fixtures_dir / GOLDEN_OSV_66))
|
||||
|
||||
assert draft == DocumentDraft(
|
||||
osv_account_code="66",
|
||||
period_year=2024,
|
||||
period_quarter=PeriodQuarter.Q4,
|
||||
is_cumulative=True,
|
||||
locator="sheet:Лист_1",
|
||||
)
|
||||
|
||||
|
||||
# --- проверка контрагента шапки (D-8) -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("header", "expected"),
|
||||
[
|
||||
({"organization_name": "АО «Русский бисквит»", "organization_inn": OWNER_INN}, DocumentCheckResult.MATCH),
|
||||
({"organization_name": "", "organization_inn": " 3528 052718 "}, DocumentCheckResult.MATCH),
|
||||
({"organization_name": "АО «Русский бисквит»", "organization_inn": "3528000000"}, DocumentCheckResult.MISMATCH),
|
||||
({"organization_name": 'АО "Русский бисквит"'}, DocumentCheckResult.MATCH), # по нормализованному имени
|
||||
({"organization_name": "ООО РБ"}, DocumentCheckResult.MATCH), # по алиасу
|
||||
({"organization_name": "Пекарня Счастливочная"}, DocumentCheckResult.MISMATCH),
|
||||
({"organization_name": "", "organization_inn": None}, DocumentCheckResult.UNKNOWN), # пустая шапка
|
||||
({"organization_name": " ", "organization_inn": ""}, DocumentCheckResult.UNKNOWN),
|
||||
],
|
||||
)
|
||||
def test_counterparty_check(header: dict[str, Any], expected: DocumentCheckResult) -> None:
|
||||
assert counterparty_check(_header(**header), OWNER_INN, OWNER_NAMES) is expected
|
||||
|
||||
|
||||
# --- статус документа (§9.3, OQ-05) ---------------------------------------------------------------------------------
|
||||
|
||||
CLEAN = OsvChecks(total_row_matches_root=True, balance_identity_violations=[], parent_sum_violations=[])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("job_done", "checks", "counterparty", "warnings", "expected"),
|
||||
[
|
||||
(True, CLEAN, DocumentCheckResult.MATCH, [], DocumentStatus.RECOGNIZED),
|
||||
(True, None, DocumentCheckResult.UNKNOWN, [], DocumentStatus.RECOGNIZED),
|
||||
(False, CLEAN, DocumentCheckResult.MATCH, [], DocumentStatus.NEEDS_CHECK),
|
||||
(True, OsvChecks(total_row_matches_root=False), DocumentCheckResult.MATCH, [], DocumentStatus.NEEDS_CHECK),
|
||||
(True, OsvChecks(balance_identity_violations=[5]), DocumentCheckResult.MATCH, [], DocumentStatus.NEEDS_CHECK),
|
||||
(True, OsvChecks(parent_sum_violations=[7]), DocumentCheckResult.MATCH, [], DocumentStatus.NEEDS_CHECK),
|
||||
(True, CLEAN, DocumentCheckResult.MISMATCH, [], DocumentStatus.NEEDS_CHECK),
|
||||
(True, CLEAN, DocumentCheckResult.MATCH, ["sheet=Лист_2: счёт 60 уже есть"], DocumentStatus.NEEDS_CHECK),
|
||||
],
|
||||
)
|
||||
def test_document_status(
|
||||
job_done: bool,
|
||||
checks: OsvChecks | None,
|
||||
counterparty: DocumentCheckResult,
|
||||
warnings: list[str],
|
||||
expected: DocumentStatus,
|
||||
) -> None:
|
||||
status = document_status(job_done=job_done, checks=checks, counterparty=counterparty, warnings=warnings)
|
||||
assert status is expected
|
||||
|
||||
|
||||
# --- сверка сальдо на начало с Q1 (D-9) -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entry", "q1_opening", "expected"),
|
||||
[
|
||||
(_entry(Decimal("100.00"), None), (Decimal("100.00"), None), True),
|
||||
(_entry(Decimal("100.00"), None), (Decimal("100.01"), None), True), # граница ±0.01 включительно
|
||||
(_entry(Decimal("100.00"), None), (Decimal("99.99"), Decimal("0.00")), True), # None = 0
|
||||
(_entry(Decimal("100.00"), None), (Decimal("100.02"), None), False),
|
||||
(_entry(None, Decimal("5.00")), (None, Decimal("4.98")), False),
|
||||
(_entry(None, None), (Decimal("0.01"), Decimal("-0.01")), True),
|
||||
],
|
||||
)
|
||||
def test_opening_matches(entry: EntryDraft, q1_opening: tuple[Decimal | None, Decimal | None], expected: bool) -> None:
|
||||
assert opening_matches(entry, q1_opening) is expected
|
||||
@ -168,9 +168,9 @@ def document_status(*, job_done: bool, checks: OsvChecks | None, counterparty: D
|
||||
def opening_matches(a: EntryDraft, b_opening: tuple[Decimal | None, Decimal | None]) -> bool # ±0.01, None = 0 — для D-9
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Тесты** (unit): нормализация (таблица из 8 случаев), `account_root` (`60`, `001`, `МЦ.04` → None); `build_entries` на `api/contracts/examples/result-osv-minimal.json` (TOTAL отсутствует, parent выставлен, суммы `Decimal`) и на golden `../fixtures/golden/expected_osv_parse/rb/2024-FY/ОСВ_66 за 2024.json` (маркер `fixtures`; `chart` — словарь из seed CSV): 20 строк, у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `account_code = "66.03"`, `account_id` задан; `counterparty_check` — ИНН совпал/не совпал, по имени, по алиасу, пустая шапка; `document_status` — 5 комбинаций; `opening_matches` на границе 0.01.
|
||||
- [ ] **Step 2–4:** падает → реализация → `pytest tests/test_domain_osv_mapping.py -q`, `mypy src` strict для `src.domain.*`.
|
||||
- [ ] **Step 5: Commit** `feat(domain): маппинг osvDocument в черновики документа и строк, нормализация кода счёта, проверки (§1.3, §9.3)`.
|
||||
- [x] **Step 1: Тесты** (unit): нормализация (таблица из 8 случаев), `account_root` (`60`, `001`, `МЦ.04` → None); `build_entries` на `api/contracts/examples/result-osv-minimal.json` (TOTAL отсутствует, parent выставлен, суммы `Decimal`) и на golden `../fixtures/golden/expected_osv_parse/rb/2024-FY/ОСВ_66 за 2024.json` (маркер `fixtures`; `chart` — словарь из seed CSV): 20 строк, у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `account_code = "66.03"`, `account_id` задан; `counterparty_check` — ИНН совпал/не совпал, по имени, по алиасу, пустая шапка; `document_status` — 5 комбинаций; `opening_matches` на границе 0.01.
|
||||
- [x] **Step 2–4:** падает → реализация → `pytest tests/test_domain_osv_mapping.py -q`, `mypy src` strict для `src.domain.*`.
|
||||
- [x] **Step 5: Commit** `feat(domain): маппинг osvDocument в черновики документа и строк, нормализация кода счёта, проверки (§1.3, §9.3)`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user