From 1b4a0859e47d7bde8e2e05199d1ec74562ccb1ac Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 19:37:18 +0400 Subject: [PATCH] =?UTF-8?q?feat(osv):=20=D0=BD=D0=B5=D1=81=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=98=D0=9D=D0=9D;=20=D0=98=D0=9D=D0=9D=20=D1=81=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=AE=D0=9B=20=D0=B2=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B5=20=D1=81=D0=B2=D0=BE=D0=B4=D0=B0=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решения владельца 08.10.2026: - отмена П-1: «Осталось N» (`unmapped_total`) и `?unmapped=true` — строки без ЮЛ после резолва и без ИНН из файла; строка с ИНН, которого нет в справочнике, сопоставления не требует (`domain.needs_mapping`); - `ConsolidatedRowOut.counterparty_entity_inn` — ИНН сопоставленного ЮЛ справочника (П-8): фронту — «таблетка» с ИНН и пересопоставление у строк, сопоставленных без ИНН в файле. Фикстуры РБ 2024 Q3: unmapped_total 1379 → 1260. Спека consolidated-osv и README обновлены. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++- api/src/domain/consolidated_osv.py | 16 +++++-- api/src/schemas/consolidations.py | 1 + api/tests/test_api_consolidated_osv.py | 6 ++- api/tests/test_api_counterparty_mapping.py | 7 +-- api/tests/test_consolidation_fixtures.py | 6 +-- api/tests/test_domain_consolidated_osv.py | 51 +++++++++++++++++----- api/tests/test_osv_export_workbook.py | 1 + api/tests/test_schemas_consolidations.py | 1 + openspec/specs/consolidated-osv/spec.md | 19 ++++++-- 10 files changed, 87 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 84119e8..979aa57 100644 --- a/README.md +++ b/README.md @@ -736,7 +736,10 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` (`gaps[]` по паре, строк нет). Q1-эквивалентность ОСВ — см. «Привязка документов». - Контрагент строки: хранимый `osv_entry.counterparty_id` либо резолв на лету (ИНН → точное имя → алиас → нормализованное имя; алиас приоритетнее ЮЛ-тёзок, OQ-128; в БД не пишется, `match_stored = false`); `match_kind` — `INN`/`NAME`/`ALIAS` (шаг резолва), `MANUAL`, `PROPAGATED`. - Тип контрагента: `GSK` (член периметра), `GK` (та же ГК), иначе `EXTERNAL`. + Тип контрагента: `GSK` (член периметра), `GK` (та же ГК), иначе `EXTERNAL`. Имя и ИНН сопоставленного ЮЛ справочника — + `counterparty_entity_name` / `counterparty_entity_inn` (`counterparty_name` / `counterparty_inn` — как в файле). +- **Несопоставленная строка** (`unmapped=true`, `unmapped_total`, «Осталось N») — нет ЮЛ после резолва **и** нет ИНН из + файла; строка с ИНН, которого нет в справочнике, сопоставления не требует (решение владельца 08.10.2026, отмена П-1, #74). - Фильтры: `legal_entity_id`, `account_root`, `counterparty_type`, `unmapped`, `match_kind` (повторяемый), `q` (имя — подстрока без регистра, ИНН — префикс). Порядок: имя ЮЛ, счёт (натуральный), номер строки. - `warnings` строк — диагностика (расхождение `counterparty_id` между кварталами), не контракт. diff --git a/api/src/domain/consolidated_osv.py b/api/src/domain/consolidated_osv.py index def4de7..8e6eb62 100644 --- a/api/src/domain/consolidated_osv.py +++ b/api/src/domain/consolidated_osv.py @@ -121,6 +121,7 @@ class ConsolidatedRow: counterparty_inn: str | None counterparty_id: uuid.UUID | None counterparty_entity_name: str | None + counterparty_entity_inn: str | None # ИНН сопоставленного ЮЛ справочника (не из файла) counterparty_type: CounterpartyType match_kind: CounterpartyMatchKind | None match_stored: bool # `False` — найдено дорезолвом на чтении (или не найдено), в `osv_entry` не записано @@ -161,7 +162,7 @@ class Resolver(Protocol): @dataclass(frozen=True) class OsvFilter: """Фильтры свода (`GET …/osv`, экспорт): пустые поля не фильтруют; `q` — подстрока имени без регистра или префикс - ИНН контрагента; `unmapped` — `counterparty_id` пуст после дорезолва (`False` — только сопоставленные).""" + ИНН контрагента; `unmapped` — строка требует сопоставления (`needs_mapping`; `False` — все остальные).""" legal_entity_id: uuid.UUID | None = None account_root: str | None = None @@ -348,6 +349,7 @@ def _compose( # noqa: PLR0913 - сборка строки свода из ча counterparty_inn=representative.counterparty_inn, counterparty_id=counterparty_id, counterparty_entity_name=counterparty.name if counterparty is not None else None, + counterparty_entity_inn=counterparty.inn if counterparty is not None else None, counterparty_type=classify( counterparty_id, members=context.members, entities=context.entities, group_id=context.group_id ), @@ -469,6 +471,12 @@ def build( # noqa: PLR0913 - сигнатура зафиксирована пл # --------------------------------------------------------------------------- фильтры и сводка +def needs_mapping(row: ConsolidatedRow) -> bool: + """Строка требует ручного сопоставления: нет ЮЛ после дорезолва и нет ИНН контрагента в файле. Строка с ИНН, + которого нет в справочнике, определена ИНН и сопоставления не требует (решение владельца 08.10.2026, отмена П-1).""" + return row.counterparty_id is None and not normalize_inn(row.counterparty_inn or "") + + def _matches_query(row: ConsolidatedRow, q: str | None) -> bool: text = (q or "").strip() if not text: @@ -485,7 +493,7 @@ def _matches(row: ConsolidatedRow, f: OsvFilter) -> bool: # noqa: PLR0911 - п return False if f.counterparty_type is not None and row.counterparty_type != f.counterparty_type: return False - if f.unmapped is not None and (row.counterparty_id is None) != f.unmapped: + if f.unmapped is not None and needs_mapping(row) != f.unmapped: return False if f.match_kinds and row.match_kind not in f.match_kinds: return False @@ -498,8 +506,8 @@ def apply_filter(rows: Iterable[ConsolidatedRow], f: OsvFilter) -> list[Consolid def summary(c: Consolidated) -> tuple[int, int, int]: - """Сводка свода: `rows_total`, `unmapped_total` (`counterparty_id` пуст после дорезолва), `auto_matched_total` + """Сводка свода: `rows_total`, `unmapped_total` (`needs_mapping`: нет ЮЛ и нет ИНН из файла), `auto_matched_total` (`match_kind` ∈ {`NAME`, `ALIAS`} независимо от `match_stored`).""" - unmapped = sum(1 for row in c.rows if row.counterparty_id is None) + unmapped = sum(1 for row in c.rows if needs_mapping(row)) auto_matched = sum(1 for row in c.rows if row.match_kind in AUTO_MATCH_KINDS) return len(c.rows), unmapped, auto_matched diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 35d37eb..7c7eddf 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -203,6 +203,7 @@ class ConsolidatedRowOut(BaseModel): counterparty_inn: str | None counterparty_id: uuid.UUID | None counterparty_entity_name: str | None + counterparty_entity_inn: str | None # ИНН сопоставленного ЮЛ справочника; `counterparty_inn` — ИНН из файла counterparty_type: CounterpartyType match_kind: CounterpartyMatchKind | None match_stored: bool # `False` — сопоставление найдено дорезолвом на чтении и в `osv_entry` не записано diff --git a/api/tests/test_api_consolidated_osv.py b/api/tests/test_api_consolidated_osv.py index bf97a41..5300f21 100644 --- a/api/tests/test_api_consolidated_osv.py +++ b/api/tests/test_api_consolidated_osv.py @@ -196,6 +196,7 @@ async def test_page_rows_order_total_and_money_strings(auth_client: AuthClientFa assert row["legal_entity_id"] == str(world.a.id) and row["legal_entity_inn"] == world.a.inn assert row["account_root"] == "60" and row["counterparty_inn"] == world.b.inn assert (row["counterparty_id"], row["counterparty_entity_name"]) == (str(world.b.id), "ООО Б") + assert row["counterparty_entity_inn"] == world.b.inn assert (row["counterparty_type"], row["match_kind"], row["match_stored"]) == ("GSK", "INN", True) cumulative = _row(body, "ООО А", "62", "ООО Б") assert cumulative["is_cumulative"] is True and cumulative["closing_credit"] == "42.50" @@ -216,6 +217,7 @@ async def test_counterparty_types_and_read_time_resolution( unknown = _row(body, "ООО А", "60", "Неизвестный контрагент") assert (unknown["counterparty_id"], unknown["counterparty_type"], unknown["match_kind"]) == (None, "EXTERNAL", None) assert unknown["match_stored"] is False and unknown["counterparty_entity_name"] is None + assert unknown["counterparty_entity_inn"] is None by_name = _row(body, "ООО А", "60", "ООО Внешний") assert (by_name["counterparty_id"], by_name["match_kind"], by_name["match_stored"]) == ( str(world.external.id), "NAME", False) # fmt: skip @@ -224,6 +226,8 @@ async def test_counterparty_types_and_read_time_resolution( assert (by_alias["counterparty_id"], by_alias["match_kind"], by_alias["match_stored"]) == ( str(world.aliased.id), "ALIAS", False) # fmt: skip assert by_alias["counterparty_entity_name"] == "Фирма Эф" + # ИНН в файле нет — ИНН сопоставленного ЮЛ справочника отдаётся отдельным полем (arm-core #74) + assert (by_alias["counterparty_inn"], by_alias["counterparty_entity_inn"]) == (None, world.aliased.inn) assert _row(body, "ООО Б", "60", "ООО А")["counterparty_type"] == "GSK" @@ -278,7 +282,7 @@ async def test_summary_counts_and_page_total_agree(auth_client: AuthClientFactor unmapped = await _page(client, world, "unmapped=true") assert unmapped["total"] == summary["unmapped_total"] assert len(unmapped["items"]) == UNMAPPED_TOTAL - assert all(row["counterparty_id"] is None for row in unmapped["items"]) + assert all(row["counterparty_id"] is None and row["counterparty_inn"] is None for row in unmapped["items"]) mapped = await _page(client, world, "unmapped=false") assert mapped["total"] == ROWS_TOTAL - UNMAPPED_TOTAL diff --git a/api/tests/test_api_counterparty_mapping.py b/api/tests/test_api_counterparty_mapping.py index 2502f29..31ad5f9 100644 --- a/api/tests/test_api_counterparty_mapping.py +++ b/api/tests/test_api_counterparty_mapping.py @@ -387,9 +387,10 @@ async def test_mapping_from_slice_plan_propagates_and_creates_alias( alias = (await seeded.execute(select(LegalEntityAlias))).scalar_one() assert alias.created_by == (await _user(seeded, KA_RF)).id and alias.created_at is not None after_summary = await _summary(client, world) - # строка 76 с чужим ИНН: хранимого сопоставления нет (проверено выше), но ИНН нет в справочнике и не блокирует - # поиск по имени, а алиас приоритетнее ЮЛ-тёзки (OQ-128) — на чтении она резолвится по алиасу - assert after_summary["unmapped_total"] == before_summary["unmapped_total"] - (1 + body["propagated"]) - 1 + # строка 76 с чужим ИНН в «Осталось N» не входит и до сопоставления: ИНН из файла есть (отмена П-1, arm-core #74); + # на чтении она резолвится по алиасу (ИНН нет в справочнике и не блокирует поиск по имени, алиас приоритетнее + # ЮЛ-тёзки, OQ-128) + assert after_summary["unmapped_total"] == before_summary["unmapped_total"] - (1 + body["propagated"]) assert after_summary["unmapped_total"] == 0 page = (await client.get(f"/consolidations/versions/{world.version.id}/osv?limit=100")).json() alien = next(r for r in page["items"] if r["account_code"] == "76" and r["counterparty_name"] == NAME) diff --git a/api/tests/test_consolidation_fixtures.py b/api/tests/test_consolidation_fixtures.py index fd51cfc..4132741 100644 --- a/api/tests/test_consolidation_fixtures.py +++ b/api/tests/test_consolidation_fixtures.py @@ -48,9 +48,9 @@ SCHASTL_FILE_NAME = "ПЕКАРНЯ СЧАСТЛИВОЧНАЯ" # в файла RB_NAME_IN_SCHASTL = "Русский бисквит" TIME_BUDGET_SECONDS = 60.0 Q1, Q2, Q3 = "Q1", "Q2", "Q3" -# свод РБ 2024 Q3; 1379 = 1381 − 2: две строки находят контрагента по ИНН, извлечённому парсером из названия -# (tabular-parser #15, golden fixtures #8; issue #33) -ROWS_TOTAL, UNMAPPED_TOTAL = 1385, 1379 +# свод РБ 2024 Q3; несопоставленные — строки без ЮЛ и без ИНН из файла (отмена П-1, #74): из 1379 строк без ЮЛ +# (1381 − 2 по ИНН, извлечённому парсером из названия: tabular-parser #15, golden fixtures #8, issue #33) у 119 ИНН есть +ROWS_TOTAL, UNMAPPED_TOTAL = 1385, 1260 CHANGED_ROWS, PROPAGATED = 4, 17 # строк свода, получивших контрагента; записей osv_entry, кроме выбранной (18 всего) SUMS = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit") diff --git a/api/tests/test_domain_consolidated_osv.py b/api/tests/test_domain_consolidated_osv.py index 8e34e25..a66adf1 100644 --- a/api/tests/test_domain_consolidated_osv.py +++ b/api/tests/test_domain_consolidated_osv.py @@ -20,6 +20,7 @@ from src.domain.consolidated_osv import ( apply_filter, build, classify, + needs_mapping, summary, ) from src.domain.osv_mapping import Resolution @@ -331,6 +332,7 @@ def test_counterparty_id_from_last_quarter_with_warning_on_divergence() -> None: row = _build([q1, q2, q3], [early, unmapped, late], resolver=resolver).rows[0] assert (row.counterparty_id, row.match_kind, row.match_stored) == (SCH.id, MANUAL, True) assert (row.counterparty_type, row.counterparty_entity_name) == (GSK, SCH.name) + assert row.counterparty_entity_inn == SCH.inn assert len(row.warnings) == 1 assert str(early.id) in row.warnings[0] assert str(CHKF.id) in row.warnings[0] @@ -366,9 +368,10 @@ def test_counterparty_types_on_rb_fixture_shape() -> None: assert by_entry[to_sch.id].counterparty_type == EXTERNAL assert (by_entry[to_sch.id].counterparty_id, by_entry[to_sch.id].match_kind) == (None, None) assert by_entry[to_sch.id].match_stored is False - assert by_entry[to_sch.id].counterparty_entity_name is None + assert (by_entry[to_sch.id].counterparty_entity_name, by_entry[to_sch.id].counterparty_entity_inn) == (None, None) assert by_entry[to_chkf.id].counterparty_type == GK assert by_entry[to_chkf.id].counterparty_entity_name == CHKF.name + assert by_entry[to_chkf.id].counterparty_entity_inn == CHKF.inn # ИНН ЮЛ справочника, в файле ИНН нет def test_classify_counterparty_type() -> None: @@ -388,6 +391,7 @@ def test_resolve_on_read_is_not_stored() -> None: row = _build([cumulative], [source], resolver=resolver).rows[0] assert (row.counterparty_id, row.match_kind, row.match_stored) == (SCH.id, ALIAS, False) assert (row.counterparty_type, row.counterparty_entity_name) == (GSK, SCH.name) + assert row.counterparty_entity_inn == SCH.inn assert resolver.calls == [(" ", "Пекарня Счастливочная ООО")] assert source.counterparty_id is None assert source.match_kind is None @@ -462,11 +466,12 @@ def test_gaps_are_ordered_by_entity_name_and_account() -> None: def _filter_fixture() -> tuple[ConsolidatedRow, ...]: rb, sch = _doc(RB, "60", cumulative=True), _doc(SCH, "62", cumulative=True) rows = [ - _row(rb, "ООО Ромашка", inn="7701000001", row_number=1), # EXTERNAL, не сопоставлена + _row(rb, "ООО Ромашка", inn="7701000001", row_number=1), # EXTERNAL, не сопоставлена, но ИНН из файла есть _row(rb, "Пекарня Счастливочная ООО", row_number=2), # ALIAS дорезолвом _row(rb, "ЧКФ", row_number=3, account_code="60.01", counterparty_id=CHKF.id, match_kind=NAME), # GK, NAME _row(sch, "Русский бисквит", inn="3528052718", counterparty_id=RB.id, match_kind=INN), # GSK, INN _row(sch, "Внешний", row_number=2, counterparty_id=OUTSIDER.id, match_kind=MANUAL), # EXTERNAL + _row(sch, "Ведомость", inn=" ", row_number=3), # EXTERNAL, не сопоставлена и без ИНН — требует сопоставления ] resolver = _Resolver({(None, "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)}) return _build([rb, sch], rows, resolver=resolver).rows @@ -484,21 +489,28 @@ def test_empty_filter_returns_all_rows() -> None: @pytest.mark.parametrize( ("osv_filter", "expected"), [ - (OsvFilter(legal_entity_id=SCH.id), ["Русский бисквит", "Внешний"]), - (OsvFilter(account_root="62"), ["Русский бисквит", "Внешний"]), + (OsvFilter(legal_entity_id=SCH.id), ["Русский бисквит", "Внешний", "Ведомость"]), + (OsvFilter(account_root="62"), ["Русский бисквит", "Внешний", "Ведомость"]), (OsvFilter(account_root="6"), []), (OsvFilter(counterparty_type=GSK), ["Пекарня Счастливочная ООО", "Русский бисквит"]), (OsvFilter(counterparty_type=GK), ["ЧКФ"]), - (OsvFilter(counterparty_type=EXTERNAL), ["ООО Ромашка", "Внешний"]), - (OsvFilter(unmapped=True), ["ООО Ромашка"]), - (OsvFilter(unmapped=False), ["Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"]), + (OsvFilter(counterparty_type=EXTERNAL), ["ООО Ромашка", "Внешний", "Ведомость"]), + # отмена П-1 (08.10.2026): строка с ИНН из файла не требует сопоставления, даже если ИНН нет в справочнике + (OsvFilter(unmapped=True), ["Ведомость"]), + ( + OsvFilter(unmapped=False), + ["ООО Ромашка", "Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"], + ), (OsvFilter(match_kinds=frozenset({NAME, ALIAS})), ["Пекарня Счастливочная ООО", "ЧКФ"]), (OsvFilter(match_kinds=frozenset({MANUAL})), ["Внешний"]), (OsvFilter(q="рОмАш"), ["ООО Ромашка"]), (OsvFilter(q=" бисквит "), ["Русский бисквит"]), (OsvFilter(q="7701"), ["ООО Ромашка"]), (OsvFilter(q="1000001"), []), # ИНН — только префикс - (OsvFilter(q=" "), ["ООО Ромашка", "Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"]), + ( + OsvFilter(q=" "), + ["ООО Ромашка", "Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний", "Ведомость"], + ), (OsvFilter(legal_entity_id=RB.id, counterparty_type=EXTERNAL), ["ООО Ромашка"]), (OsvFilter(legal_entity_id=RB.id, match_kinds=frozenset({ALIAS}), q="пекарня"), ["Пекарня Счастливочная ООО"]), (OsvFilter(account_root="60", unmapped=True, q="ЧКФ"), []), @@ -513,10 +525,29 @@ def test_summary_counts_and_matches_filters() -> None: rows = _filter_fixture() result = Consolidated(rows=rows, gaps=(), documents_total=2) rows_total, unmapped_total, auto_matched_total = summary(result) - assert (rows_total, unmapped_total, auto_matched_total) == (5, 1, 2) + assert (rows_total, unmapped_total, auto_matched_total) == (6, 1, 2) assert len(apply_filter(rows, OsvFilter(unmapped=True))) == unmapped_total assert len(apply_filter(rows, OsvFilter(match_kinds=frozenset({NAME, ALIAS})))) == auto_matched_total - assert all(r.counterparty_id is None for r in apply_filter(rows, OsvFilter(unmapped=True))) + assert all(needs_mapping(r) for r in apply_filter(rows, OsvFilter(unmapped=True))) + + +@pytest.mark.parametrize( + ("inn", "counterparty_id", "expected"), + [ + (None, None, True), + ("", None, True), + (" ", None, True), + ("7701000001", None, False), # ИНН из файла, в справочнике нет — отмена П-1 + (None, uuid.uuid4(), False), + ("7701000001", uuid.uuid4(), False), + ], +) +def test_needs_mapping_is_no_counterparty_and_no_inn( + inn: str | None, counterparty_id: uuid.UUID | None, *, expected: bool +) -> None: + """Строка требует сопоставления («Указать», `unmapped`), только если нет ни ЮЛ, ни ИНН из файла.""" + row = dataclasses.replace(_filter_fixture()[0], counterparty_inn=inn, counterparty_id=counterparty_id) + assert needs_mapping(row) is expected def test_counterparty_type_is_str_enum() -> None: diff --git a/api/tests/test_osv_export_workbook.py b/api/tests/test_osv_export_workbook.py index 51f4c81..dbcf9de 100644 --- a/api/tests/test_osv_export_workbook.py +++ b/api/tests/test_osv_export_workbook.py @@ -35,6 +35,7 @@ def _row(name: str = "ООО Б", **overrides: object) -> ConsolidatedRow: "counterparty_inn": "0987654321", "counterparty_id": None, "counterparty_entity_name": None, + "counterparty_entity_inn": None, "counterparty_type": CounterpartyType.EXTERNAL, "match_kind": None, "match_stored": False, diff --git a/api/tests/test_schemas_consolidations.py b/api/tests/test_schemas_consolidations.py index a5416cc..e6249bf 100644 --- a/api/tests/test_schemas_consolidations.py +++ b/api/tests/test_schemas_consolidations.py @@ -87,6 +87,7 @@ def _row(**amounts: Decimal | None) -> ConsolidatedRowOut: "counterparty_inn": None, "counterparty_id": None, "counterparty_entity_name": None, + "counterparty_entity_inn": None, "counterparty_type": "EXTERNAL", "match_kind": None, "match_stored": False, diff --git a/openspec/specs/consolidated-osv/spec.md b/openspec/specs/consolidated-osv/spec.md index 6b611d1..2dbd2bf 100644 --- a/openspec/specs/consolidated-osv/spec.md +++ b/openspec/specs/consolidated-osv/spec.md @@ -43,7 +43,8 @@ SHALL склеиваться по ключу (`account_code` строки, `coun ### Requirement: Строки свода, признак и дорезолв Строка свода SHALL содержать: `entry_ids`, ЮЛ (`id`, `name`, `inn`), `account_code`, `account_root`, `period` (`"<год> Q"`), `is_cumulative`, `row_number`, `counterparty_name` и `counterparty_inn` как в файле, -`counterparty_id`, `counterparty_entity_name`, `counterparty_type` ∈ {`GSK`, `GK`, `EXTERNAL`}, `match_kind` ∈ +`counterparty_id`, `counterparty_entity_name` и `counterparty_entity_inn` (имя и ИНН сопоставленного ЮЛ справочника; +`null` без сопоставления), `counterparty_type` ∈ {`GSK`, `GK`, `EXTERNAL`}, `match_kind` ∈ {`INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`, null}, `match_stored`, шесть сумм (`Decimal`, в JSON — строки), `warnings[]`. В свод SHALL входить только строки `group_type = COUNTERPARTY` с `data_type IN (BU, SUM)`; строки MUST NOT агрегироваться между контрагентами. `counterparty_id` SHALL браться из исходных строк (непустое значение @@ -73,15 +74,25 @@ MUST NOT агрегироваться между контрагентами. `co ### Requirement: Страница свода и сводка `GET …/osv` (`CONSOLIDATION_VIEW`, policy) SHALL отдавать `Page` строк свода в порядке: имя ЮЛ, `account_code`, `row_number`, первый `entry_id`; `limit` ≤ 2000 (по умолчанию 500). Фильтры: `legal_entity_id`, `account_root`, -`counterparty_type`, `unmapped=true` (`counterparty_id` пуст после дорезолва), `match_kind` (повторяемый), `q` +`counterparty_type`, `unmapped=true` (строка требует сопоставления: `counterparty_id` пуст после дорезолва **и** +`counterparty_inn` пуст или из пробелов; строка с ИНН из файла, которого нет в справочнике, сопоставления не требует — +решение владельца 08.10.2026, отмена П-1; `unmapped=false` — все остальные строки), `match_kind` (повторяемый), `q` (подстрока без регистра в `counterparty_name` или префикс `counterparty_inn`). `GET …/osv/summary` SHALL отдавать -`rows_total`, `unmapped_total`, `auto_matched_total` (строки с `match_kind` ∈ {`NAME`, `ALIAS`} независимо от +`rows_total`, `unmapped_total` (строки `unmapped=true`), `auto_matched_total` (строки с `match_kind` ∈ {`NAME`, `ALIAS`} независимо от `match_stored`), `documents_total` (привязанные ОСВ-документы) и `gaps[]` (`legal_entity_id`, `legal_entity_name`, `account_code`, `missing_quarters`). Версия в любом статусе, включая `CANCELLED`, SHALL читаться. #### Scenario: Только несопоставленные - **WHEN** запрошено `GET …/osv?unmapped=true` -- **THEN** все строки ответа с `counterparty_id = null`, `total` = `summary.unmapped_total` +- **THEN** все строки ответа с `counterparty_id = null` и без `counterparty_inn`, `total` = `summary.unmapped_total` + +#### Scenario: Строка с ИНН вне справочника не требует сопоставления +- **WHEN** у строки `counterparty_inn = "7701000001"`, такого ИНН и имени в справочнике нет (`counterparty_id = null`) +- **THEN** строки нет в `GET …/osv?unmapped=true`, она не входит в `summary.unmapped_total` + +#### Scenario: ИНН сопоставленного ЮЛ +- **WHEN** строка без ИНН в файле сопоставлена с ЮЛ по алиасу (на лету или хранимо) +- **THEN** `counterparty_inn = null`, `counterparty_entity_inn` — ИНН этого ЮЛ из справочника #### Scenario: Фильтр по способу - **WHEN** запрошено `GET …/osv?match_kind=NAME&match_kind=ALIAS`