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:
Andrey Epifancev 2026-10-05 22:20:22 +04:00
parent 3b4d3a45fe
commit f9306b8270
3 changed files with 536 additions and 3 deletions

View 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)

View 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

View File

@ -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)`.
---