From f9306b8270af2ce0e4b855856d1cc24790711aa5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 22:20:22 +0400 Subject: [PATCH] =?UTF-8?q?feat(domain):=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20osvDocument=20=D0=B2=20=D1=87=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D0=BA=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B8=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA,=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=B4=D0=B0=20=D1=81?= =?UTF-8?q?=D1=87=D1=91=D1=82=D0=B0,=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B8=20(=C2=A71.3,=20=C2=A79.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/domain/osv_mapping.py | 228 +++++++++++++ api/tests/test_domain_osv_mapping.py | 305 ++++++++++++++++++ .../plans/2026-10-05-slice-2c-osv-ingest.md | 6 +- 3 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 api/src/domain/osv_mapping.py create mode 100644 api/tests/test_domain_osv_mapping.py diff --git a/api/src/domain/osv_mapping.py b/api/src/domain/osv_mapping.py new file mode 100644 index 0000000..743b0b1 --- /dev/null +++ b/api/src/domain/osv_mapping.py @@ -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) diff --git a/api/tests/test_domain_osv_mapping.py b/api/tests/test_domain_osv_mapping.py new file mode 100644 index 0000000..48d79c4 --- /dev/null +++ b/api/tests/test_domain_osv_mapping.py @@ -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 diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index 25d3639..632cb99 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -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)`. ---