Merge pull request 'Сводная ОСВ: несопоставленные — только без ИНН; ИНН сопоставленного ЮЛ в строке (#74)' (#75) from feat/74-osv-unmapped-no-inn into main

Reviewed-on: #75
This commit is contained in:
aep 2026-10-08 22:02:18 +03:00
commit 29f24b318d
10 changed files with 87 additions and 26 deletions

View File

@ -736,7 +736,10 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
(`gaps[]` по паре, строк нет). Q1-эквивалентность ОСВ — см. «Привязка документов». (`gaps[]` по паре, строк нет). Q1-эквивалентность ОСВ — см. «Привязка документов».
- Контрагент строки: хранимый `osv_entry.counterparty_id` либо резолв на лету (ИНН → точное имя → алиас → нормализованное имя; алиас приоритетнее ЮЛ-тёзок, OQ-128; в БД не пишется, - Контрагент строки: хранимый `osv_entry.counterparty_id` либо резолв на лету (ИНН → точное имя → алиас → нормализованное имя; алиас приоритетнее ЮЛ-тёзок, OQ-128; в БД не пишется,
`match_stored = false`); `match_kind` — `INN`/`NAME`/`ALIAS` (шаг резолва), `MANUAL`, `PROPAGATED`. `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` - Фильтры: `legal_entity_id`, `account_root`, `counterparty_type`, `unmapped`, `match_kind` (повторяемый), `q`
(имя — подстрока без регистра, ИНН — префикс). Порядок: имя ЮЛ, счёт (натуральный), номер строки. (имя — подстрока без регистра, ИНН — префикс). Порядок: имя ЮЛ, счёт (натуральный), номер строки.
- `warnings` строк — диагностика (расхождение `counterparty_id` между кварталами), не контракт. - `warnings` строк — диагностика (расхождение `counterparty_id` между кварталами), не контракт.

View File

@ -121,6 +121,7 @@ class ConsolidatedRow:
counterparty_inn: str | None counterparty_inn: str | None
counterparty_id: uuid.UUID | None counterparty_id: uuid.UUID | None
counterparty_entity_name: str | None counterparty_entity_name: str | None
counterparty_entity_inn: str | None # ИНН сопоставленного ЮЛ справочника (не из файла)
counterparty_type: CounterpartyType counterparty_type: CounterpartyType
match_kind: CounterpartyMatchKind | None match_kind: CounterpartyMatchKind | None
match_stored: bool # `False` — найдено дорезолвом на чтении (или не найдено), в `osv_entry` не записано match_stored: bool # `False` — найдено дорезолвом на чтении (или не найдено), в `osv_entry` не записано
@ -161,7 +162,7 @@ class Resolver(Protocol):
@dataclass(frozen=True) @dataclass(frozen=True)
class OsvFilter: class OsvFilter:
"""Фильтры свода (`GET …/osv`, экспорт): пустые поля не фильтруют; `q` — подстрока имени без регистра или префикс """Фильтры свода (`GET …/osv`, экспорт): пустые поля не фильтруют; `q` — подстрока имени без регистра или префикс
ИНН контрагента; `unmapped` — `counterparty_id` пуст после дорезолва (`False` — только сопоставленные).""" ИНН контрагента; `unmapped` — строка требует сопоставления (`needs_mapping`; `False` — все остальные)."""
legal_entity_id: uuid.UUID | None = None legal_entity_id: uuid.UUID | None = None
account_root: str | None = None account_root: str | None = None
@ -348,6 +349,7 @@ def _compose( # noqa: PLR0913 - сборка строки свода из ча
counterparty_inn=representative.counterparty_inn, counterparty_inn=representative.counterparty_inn,
counterparty_id=counterparty_id, counterparty_id=counterparty_id,
counterparty_entity_name=counterparty.name if counterparty is not None else None, 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_type=classify(
counterparty_id, members=context.members, entities=context.entities, group_id=context.group_id 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: def _matches_query(row: ConsolidatedRow, q: str | None) -> bool:
text = (q or "").strip() text = (q or "").strip()
if not text: if not text:
@ -485,7 +493,7 @@ def _matches(row: ConsolidatedRow, f: OsvFilter) -> bool: # noqa: PLR0911 - п
return False return False
if f.counterparty_type is not None and row.counterparty_type != f.counterparty_type: if f.counterparty_type is not None and row.counterparty_type != f.counterparty_type:
return False 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 return False
if f.match_kinds and row.match_kind not in f.match_kinds: if f.match_kinds and row.match_kind not in f.match_kinds:
return False return False
@ -498,8 +506,8 @@ def apply_filter(rows: Iterable[ConsolidatedRow], f: OsvFilter) -> list[Consolid
def summary(c: Consolidated) -> tuple[int, int, int]: 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`).""" (`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) auto_matched = sum(1 for row in c.rows if row.match_kind in AUTO_MATCH_KINDS)
return len(c.rows), unmapped, auto_matched return len(c.rows), unmapped, auto_matched

View File

@ -203,6 +203,7 @@ class ConsolidatedRowOut(BaseModel):
counterparty_inn: str | None counterparty_inn: str | None
counterparty_id: uuid.UUID | None counterparty_id: uuid.UUID | None
counterparty_entity_name: str | None counterparty_entity_name: str | None
counterparty_entity_inn: str | None # ИНН сопоставленного ЮЛ справочника; `counterparty_inn` — ИНН из файла
counterparty_type: CounterpartyType counterparty_type: CounterpartyType
match_kind: CounterpartyMatchKind | None match_kind: CounterpartyMatchKind | None
match_stored: bool # `False` — сопоставление найдено дорезолвом на чтении и в `osv_entry` не записано match_stored: bool # `False` — сопоставление найдено дорезолвом на чтении и в `osv_entry` не записано

View File

@ -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["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["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_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) assert (row["counterparty_type"], row["match_kind"], row["match_stored"]) == ("GSK", "INN", True)
cumulative = _row(body, "ООО А", "62", "ООО Б") cumulative = _row(body, "ООО А", "62", "ООО Б")
assert cumulative["is_cumulative"] is True and cumulative["closing_credit"] == "42.50" 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", "Неизвестный контрагент") unknown = _row(body, "ООО А", "60", "Неизвестный контрагент")
assert (unknown["counterparty_id"], unknown["counterparty_type"], unknown["match_kind"]) == (None, "EXTERNAL", None) 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["match_stored"] is False and unknown["counterparty_entity_name"] is None
assert unknown["counterparty_entity_inn"] is None
by_name = _row(body, "ООО А", "60", "ООО Внешний") by_name = _row(body, "ООО А", "60", "ООО Внешний")
assert (by_name["counterparty_id"], by_name["match_kind"], by_name["match_stored"]) == ( assert (by_name["counterparty_id"], by_name["match_kind"], by_name["match_stored"]) == (
str(world.external.id), "NAME", False) # fmt: skip 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"]) == ( assert (by_alias["counterparty_id"], by_alias["match_kind"], by_alias["match_stored"]) == (
str(world.aliased.id), "ALIAS", False) # fmt: skip str(world.aliased.id), "ALIAS", False) # fmt: skip
assert by_alias["counterparty_entity_name"] == "Фирма Эф" 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" 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") unmapped = await _page(client, world, "unmapped=true")
assert unmapped["total"] == summary["unmapped_total"] assert unmapped["total"] == summary["unmapped_total"]
assert len(unmapped["items"]) == 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") mapped = await _page(client, world, "unmapped=false")
assert mapped["total"] == ROWS_TOTAL - UNMAPPED_TOTAL assert mapped["total"] == ROWS_TOTAL - UNMAPPED_TOTAL

View File

@ -387,9 +387,10 @@ async def test_mapping_from_slice_plan_propagates_and_creates_alias(
alias = (await seeded.execute(select(LegalEntityAlias))).scalar_one() 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 assert alias.created_by == (await _user(seeded, KA_RF)).id and alias.created_at is not None
after_summary = await _summary(client, world) after_summary = await _summary(client, world)
# строка 76 с чужим ИНН: хранимого сопоставления нет (проверено выше), но ИНН нет в справочнике и не блокирует # строка 76 с чужим ИНН в «Осталось N» не входит и до сопоставления: ИНН из файла есть (отмена П-1, arm-core #74);
# поиск по имени, а алиас приоритетнее ЮЛ-тёзки (OQ-128) — на чтении она резолвится по алиасу # на чтении она резолвится по алиасу (ИНН нет в справочнике и не блокирует поиск по имени, алиас приоритетнее
assert after_summary["unmapped_total"] == before_summary["unmapped_total"] - (1 + body["propagated"]) - 1 # ЮЛ-тёзки, OQ-128)
assert after_summary["unmapped_total"] == before_summary["unmapped_total"] - (1 + body["propagated"])
assert after_summary["unmapped_total"] == 0 assert after_summary["unmapped_total"] == 0
page = (await client.get(f"/consolidations/versions/{world.version.id}/osv?limit=100")).json() 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) alien = next(r for r in page["items"] if r["account_code"] == "76" and r["counterparty_name"] == NAME)

View File

@ -48,9 +48,9 @@ SCHASTL_FILE_NAME = "ПЕКАРНЯ СЧАСТЛИВОЧНАЯ" # в файла
RB_NAME_IN_SCHASTL = "Русский бисквит" RB_NAME_IN_SCHASTL = "Русский бисквит"
TIME_BUDGET_SECONDS = 60.0 TIME_BUDGET_SECONDS = 60.0
Q1, Q2, Q3 = "Q1", "Q2", "Q3" Q1, Q2, Q3 = "Q1", "Q2", "Q3"
# свод РБ 2024 Q3; 1379 = 1381 − 2: две строки находят контрагента по ИНН, извлечённому парсером из названия # свод РБ 2024 Q3; несопоставленные — строки без ЮЛ и без ИНН из файла (отмена П-1, #74): из 1379 строк без ЮЛ
# (tabular-parser #15, golden fixtures #8; issue #33) # (1381 − 2 по ИНН, извлечённому парсером из названия: tabular-parser #15, golden fixtures #8, issue #33) у 119 ИНН есть
ROWS_TOTAL, UNMAPPED_TOTAL = 1385, 1379 ROWS_TOTAL, UNMAPPED_TOTAL = 1385, 1260
CHANGED_ROWS, PROPAGATED = 4, 17 # строк свода, получивших контрагента; записей osv_entry, кроме выбранной (18 всего) CHANGED_ROWS, PROPAGATED = 4, 17 # строк свода, получивших контрагента; записей osv_entry, кроме выбранной (18 всего)
SUMS = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit") SUMS = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit")

View File

@ -20,6 +20,7 @@ from src.domain.consolidated_osv import (
apply_filter, apply_filter,
build, build,
classify, classify,
needs_mapping,
summary, summary,
) )
from src.domain.osv_mapping import Resolution 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] 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_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_type, row.counterparty_entity_name) == (GSK, SCH.name)
assert row.counterparty_entity_inn == SCH.inn
assert len(row.warnings) == 1 assert len(row.warnings) == 1
assert str(early.id) in row.warnings[0] assert str(early.id) in row.warnings[0]
assert str(CHKF.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_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].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].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_type == GK
assert by_entry[to_chkf.id].counterparty_entity_name == CHKF.name 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: 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] 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_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_type, row.counterparty_entity_name) == (GSK, SCH.name)
assert row.counterparty_entity_inn == SCH.inn
assert resolver.calls == [(" ", "Пекарня Счастливочная ООО")] assert resolver.calls == [(" ", "Пекарня Счастливочная ООО")]
assert source.counterparty_id is None assert source.counterparty_id is None
assert source.match_kind 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, ...]: def _filter_fixture() -> tuple[ConsolidatedRow, ...]:
rb, sch = _doc(RB, "60", cumulative=True), _doc(SCH, "62", cumulative=True) rb, sch = _doc(RB, "60", cumulative=True), _doc(SCH, "62", cumulative=True)
rows = [ 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=2), # ALIAS дорезолвом
_row(rb, "ЧКФ", row_number=3, account_code="60.01", counterparty_id=CHKF.id, match_kind=NAME), # GK, NAME _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, "Русский бисквит", 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, "Внешний", 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)}) resolver = _Resolver({(None, "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)})
return _build([rb, sch], rows, resolver=resolver).rows return _build([rb, sch], rows, resolver=resolver).rows
@ -484,21 +489,28 @@ def test_empty_filter_returns_all_rows() -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("osv_filter", "expected"), ("osv_filter", "expected"),
[ [
(OsvFilter(legal_entity_id=SCH.id), ["Русский бисквит", "Внешний"]), (OsvFilter(legal_entity_id=SCH.id), ["Русский бисквит", "Внешний", "Ведомость"]),
(OsvFilter(account_root="62"), ["Русский бисквит", "Внешний"]), (OsvFilter(account_root="62"), ["Русский бисквит", "Внешний", "Ведомость"]),
(OsvFilter(account_root="6"), []), (OsvFilter(account_root="6"), []),
(OsvFilter(counterparty_type=GSK), ["Пекарня Счастливочная ООО", "Русский бисквит"]), (OsvFilter(counterparty_type=GSK), ["Пекарня Счастливочная ООО", "Русский бисквит"]),
(OsvFilter(counterparty_type=GK), ["ЧКФ"]), (OsvFilter(counterparty_type=GK), ["ЧКФ"]),
(OsvFilter(counterparty_type=EXTERNAL), ["ООО Ромашка", "Внешний"]), (OsvFilter(counterparty_type=EXTERNAL), ["ООО Ромашка", "Внешний", "Ведомость"]),
(OsvFilter(unmapped=True), ["ООО Ромашка"]), # отмена П-1 (08.10.2026): строка с ИНН из файла не требует сопоставления, даже если ИНН нет в справочнике
(OsvFilter(unmapped=False), ["Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"]), (OsvFilter(unmapped=True), ["Ведомость"]),
(
OsvFilter(unmapped=False),
["ООО Ромашка", "Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"],
),
(OsvFilter(match_kinds=frozenset({NAME, ALIAS})), ["Пекарня Счастливочная ООО", "ЧКФ"]), (OsvFilter(match_kinds=frozenset({NAME, ALIAS})), ["Пекарня Счастливочная ООО", "ЧКФ"]),
(OsvFilter(match_kinds=frozenset({MANUAL})), ["Внешний"]), (OsvFilter(match_kinds=frozenset({MANUAL})), ["Внешний"]),
(OsvFilter(q="рОмАш"), ["ООО Ромашка"]), (OsvFilter(q="рОмАш"), ["ООО Ромашка"]),
(OsvFilter(q=" бисквит "), ["Русский бисквит"]), (OsvFilter(q=" бисквит "), ["Русский бисквит"]),
(OsvFilter(q="7701"), ["ООО Ромашка"]), (OsvFilter(q="7701"), ["ООО Ромашка"]),
(OsvFilter(q="1000001"), []), # ИНН — только префикс (OsvFilter(q="1000001"), []), # ИНН — только префикс
(OsvFilter(q=" "), ["ООО Ромашка", "Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"]), (
OsvFilter(q=" "),
["ООО Ромашка", "Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний", "Ведомость"],
),
(OsvFilter(legal_entity_id=RB.id, counterparty_type=EXTERNAL), ["ООО Ромашка"]), (OsvFilter(legal_entity_id=RB.id, counterparty_type=EXTERNAL), ["ООО Ромашка"]),
(OsvFilter(legal_entity_id=RB.id, match_kinds=frozenset({ALIAS}), q="пекарня"), ["Пекарня Счастливочная ООО"]), (OsvFilter(legal_entity_id=RB.id, match_kinds=frozenset({ALIAS}), q="пекарня"), ["Пекарня Счастливочная ООО"]),
(OsvFilter(account_root="60", unmapped=True, q="ЧКФ"), []), (OsvFilter(account_root="60", unmapped=True, q="ЧКФ"), []),
@ -513,10 +525,29 @@ def test_summary_counts_and_matches_filters() -> None:
rows = _filter_fixture() rows = _filter_fixture()
result = Consolidated(rows=rows, gaps=(), documents_total=2) result = Consolidated(rows=rows, gaps=(), documents_total=2)
rows_total, unmapped_total, auto_matched_total = summary(result) 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(unmapped=True))) == unmapped_total
assert len(apply_filter(rows, OsvFilter(match_kinds=frozenset({NAME, ALIAS})))) == auto_matched_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: def test_counterparty_type_is_str_enum() -> None:

View File

@ -35,6 +35,7 @@ def _row(name: str = "ООО Б", **overrides: object) -> ConsolidatedRow:
"counterparty_inn": "0987654321", "counterparty_inn": "0987654321",
"counterparty_id": None, "counterparty_id": None,
"counterparty_entity_name": None, "counterparty_entity_name": None,
"counterparty_entity_inn": None,
"counterparty_type": CounterpartyType.EXTERNAL, "counterparty_type": CounterpartyType.EXTERNAL,
"match_kind": None, "match_kind": None,
"match_stored": False, "match_stored": False,

View File

@ -87,6 +87,7 @@ def _row(**amounts: Decimal | None) -> ConsolidatedRowOut:
"counterparty_inn": None, "counterparty_inn": None,
"counterparty_id": None, "counterparty_id": None,
"counterparty_entity_name": None, "counterparty_entity_name": None,
"counterparty_entity_inn": None,
"counterparty_type": "EXTERNAL", "counterparty_type": "EXTERNAL",
"match_kind": None, "match_kind": None,
"match_stored": False, "match_stored": False,

View File

@ -43,7 +43,8 @@ SHALL склеиваться по ключу (`account_code` строки, `coun
### Requirement: Строки свода, признак и дорезолв ### Requirement: Строки свода, признак и дорезолв
Строка свода SHALL содержать: `entry_ids`, ЮЛ (`id`, `name`, `inn`), `account_code`, `account_root`, `period` Строка свода SHALL содержать: `entry_ids`, ЮЛ (`id`, `name`, `inn`), `account_code`, `account_root`, `period`
(`"<год> Q<n>"`), `is_cumulative`, `row_number`, `counterparty_name` и `counterparty_inn` как в файле, (`"<год> Q<n>"`), `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 — строки), {`INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`, null}, `match_stored`, шесть сумм (`Decimal`, в JSON — строки),
`warnings[]`. В свод SHALL входить только строки `group_type = COUNTERPARTY` с `data_type IN (BU, SUM)`; строки `warnings[]`. В свод SHALL входить только строки `group_type = COUNTERPARTY` с `data_type IN (BU, SUM)`; строки
MUST NOT агрегироваться между контрагентами. `counterparty_id` SHALL браться из исходных строк (непустое значение MUST NOT агрегироваться между контрагентами. `counterparty_id` SHALL браться из исходных строк (непустое значение
@ -73,15 +74,25 @@ MUST NOT агрегироваться между контрагентами. `co
### Requirement: Страница свода и сводка ### Requirement: Страница свода и сводка
`GET …/osv` (`CONSOLIDATION_VIEW`, policy) SHALL отдавать `Page` строк свода в порядке: имя ЮЛ, `account_code`, `GET …/osv` (`CONSOLIDATION_VIEW`, policy) SHALL отдавать `Page` строк свода в порядке: имя ЮЛ, `account_code`,
`row_number`, первый `entry_id`; `limit` ≤ 2000 (по умолчанию 500). Фильтры: `legal_entity_id`, `account_root`, `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 отдавать (подстрока без регистра в `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`, `match_stored`), `documents_total` (привязанные ОСВ-документы) и `gaps[]` (`legal_entity_id`, `legal_entity_name`,
`account_code`, `missing_quarters`). Версия в любом статусе, включая `CANCELLED`, SHALL читаться. `account_code`, `missing_quarters`). Версия в любом статусе, включая `CANCELLED`, SHALL читаться.
#### Scenario: Только несопоставленные #### Scenario: Только несопоставленные
- **WHEN** запрошено `GET …/osv?unmapped=true` - **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: Фильтр по способу #### Scenario: Фильтр по способу
- **WHEN** запрошено `GET …/osv?match_kind=NAME&match_kind=ALIAS` - **WHEN** запрошено `GET …/osv?match_kind=NAME&match_kind=ALIAS`