Merge pull request 'Сводная ОСВ: пары участников без встречных записей вместо «сопоставьте все» (#195, AURORA-1746)' (#196) from feat/195-counter-pairs into main

This commit is contained in:
aep 2026-10-27 22:09:33 +03:00
commit 3ed70c2996
34 changed files with 1756 additions and 108 deletions

View File

@ -24,7 +24,7 @@
`directory_import.py` — чтение xlsx/csv и план импорта §13, `aliases.py` — нормализация алиаса §3, `inn.py` — формат
ИНН, `file_types.py` — тип файла по расширению и сигнатуре, `osv_mapping.py` — результат парсера → документ/строки
ОСВ (§1.3, §1.5, §9.3); Срез 4 — `consolidation_periods.py` (выбор источников и приведение периодов, §2, Q1-эквивалентность ОСВ,
R-14), `consolidated_osv.py` (чистая сборка свода: склейка кварталов, пробелы, дорезолв, фильтры, §2–§3), `checklist.py`
R-14), `consolidated_osv.py` (чистая сборка свода: склейка кварталов, пробелы, дорезолв, фильтры, §2–§3; `counter_pairs` — пары участников без встречных записей, §3 п. 9, AURORA-1746), `checklist.py`
(чек-лист комплекта, ячейки `CellStatus`); Срез 3b — `statement_mapping.py` (формы ББ/ОФР: период, черновик, статус D-6),
`statement_checks.py` (остатки §7 в ядре: межформенная 1370↔2400 и `subline_parents`; соотношения §7 считает `arm_statements`), `checklist_csv.py` (CSV чек-листа);
`policy.py` дополнен `IN_WORK_STATUSES`/`ensure_version_editable`, §10; для шахматки

View File

@ -786,8 +786,8 @@ advisory-блокировкой.
| `POST /consolidations/versions/{id}/status` | EDIT | ручной переход: `CANCELLED` (с комментарием) или возврат в `INITIATED` |
| `PUT /consolidations/versions/{id}/members` | EDIT | заменить периметр (ЮЛ должны принадлежать ГК, ≥ 2) |
| `GET /consolidations/versions/{id}/checklist` | VIEW | комплект документов: ЮЛ × правила, статус ячейки; `?format=csv` — тот же чек-лист файлом CSV |
| `GET /consolidations/versions/{id}/osv` | VIEW | страница сводной ОСВ и фильтры (`limit` ≤ 2000, по умолчанию 500) |
| `GET /consolidations/versions/{id}/osv/summary` | VIEW | `rows_total`, `unmapped_total`, `auto_matched_total`, `documents_total`, `gaps[]` |
| `GET /consolidations/versions/{id}/osv` | VIEW | страница сводной ОСВ и фильтры, в т. ч. `counterparty_id` — строки с этим контрагентом (`limit` ≤ 2000, по умолчанию 500) |
| `GET /consolidations/versions/{id}/osv/summary` | VIEW | `rows_total`, `unmapped_total`, `auto_matched_total`, `documents_total`, `gaps[]`, `counter_pairs[]` (пары «участник не видит встречных записей») |
| `GET /consolidations/versions/{id}/osv/export` | VIEW | xlsx: лист «Сводная ОСВ» и лист «Пробелы» |
| `GET /consolidations/versions/{id}/counterparty-candidates` | VIEW | кандидаты для сопоставления: `scope=group\|all`, `q`, `entry_id` |
| `POST /consolidations/versions/{id}/osv/entries/{entry_id}/counterparty` | EDIT | ручное сопоставление контрагента строки свода |

View File

@ -33,7 +33,8 @@ async def list_osv(
query: Annotated[OsvQuery, Query()],
) -> Page[ConsolidatedRowOut]:
"""Строки свода в порядке: имя ЮЛ, счёт, номер строки, первый `entry_id`. Фильтры `legal_entity_id`,
`account_root`, `counterparty_type`, `unmapped`, `match_kind` (повторяемый), `q`; `limit` ≤ 2000 (по умолчанию 500).
`account_root`, `counterparty_type`, `unmapped`, `counterparty_id`, `match_kind` (повторяемый), `q`;
`limit` ≤ 2000 (по умолчанию 500).
Суммы — строки с двумя знаками."""
return await service.page(version_id, query, query)

View File

@ -34,6 +34,7 @@ __all__ = [
"AUTO_MATCH_KINDS",
"Consolidated",
"ConsolidatedRow",
"CounterPair",
"CounterpartyType",
"Entity",
"GapItem",
@ -44,6 +45,7 @@ __all__ = [
"apply_filter",
"build",
"classify",
"counter_pairs",
"glue_key",
"summary",
]
@ -170,6 +172,7 @@ class OsvFilter:
account_root: str | None = None
counterparty_type: CounterpartyType | None = None
unmapped: bool | None = None
counterparty_id: uuid.UUID | None = None
match_kinds: frozenset[CounterpartyMatchKind] = frozenset()
q: str | None = None
@ -498,6 +501,8 @@ def _matches(row: ConsolidatedRow, f: OsvFilter) -> bool: # noqa: PLR0911 - п
return False
if f.unmapped is not None and needs_mapping(row) != f.unmapped:
return False
if f.counterparty_id is not None and row.counterparty_id != f.counterparty_id:
return False
if f.match_kinds and row.match_kind not in f.match_kinds:
return False
return _matches_query(row, f.q)
@ -508,6 +513,50 @@ def apply_filter(rows: Iterable[ConsolidatedRow], f: OsvFilter) -> list[Consolid
return [row for row in rows if _matches(row, f)]
@dataclass(frozen=True)
class CounterPair:
"""Пара «держатель видит, встречной записи нет» (§3 п. 9 05-domain-rules, AURORA-1746): у `holder` есть строки с
контрагентом `missing`, а у `missing` нет ни одной строки с контрагентом `holder`."""
holder_id: uuid.UUID
holder_name: str
missing_id: uuid.UUID
missing_name: str
holder_rows: int # строки держателя с контрагентом `missing`
missing_unmapped_rows: int # строки `missing`, требующие сопоставления (`needs_mapping`)
missing_rows_total: int # все строки `missing` в своде
def counter_pairs(rows: Sequence[ConsolidatedRow], members: Mapping[uuid.UUID, Entity]) -> list[CounterPair]:
"""Пары членов периметра без встречных записей (§3 п. 9 05-domain-rules, AURORA-1746). Видимая пара —
`(legal_entity_id, counterparty_id)` по строкам свода (с дорезолвом на чтении), оба в `members` и различны.
Порядок: `missing_name`, затем `holder_name` (затем id для устойчивости)."""
seen: dict[tuple[uuid.UUID, uuid.UUID], int] = defaultdict(int)
totals: dict[uuid.UUID, int] = defaultdict(int)
unmapped: dict[uuid.UUID, int] = defaultdict(int)
for row in rows:
totals[row.legal_entity_id] += 1
if needs_mapping(row):
unmapped[row.legal_entity_id] += 1
other = row.counterparty_id
if other is not None and other != row.legal_entity_id and other in members and row.legal_entity_id in members:
seen[(row.legal_entity_id, other)] += 1
pairs = [
CounterPair(
holder_id=holder,
holder_name=members[holder].name,
missing_id=missing,
missing_name=members[missing].name,
holder_rows=count,
missing_unmapped_rows=unmapped[missing],
missing_rows_total=totals[missing],
)
for (holder, missing), count in seen.items()
if (missing, holder) not in seen
]
return sorted(pairs, key=lambda p: (p.missing_name, p.holder_name, str(p.missing_id), str(p.holder_id)))
def summary(c: Consolidated) -> tuple[int, int, int]:
"""Сводка свода: `rows_total`, `unmapped_total` (`needs_mapping`: нет ЮЛ и нет ИНН из файла), `auto_matched_total`
(`match_kind` ∈ {`NAME`, `ALIAS`} независимо от `match_stored`)."""

View File

@ -241,6 +241,18 @@ class GapOut(BaseModel):
missing_quarters: list[PeriodQuarter]
class CounterPairOut(BaseModel):
"""Пара «держатель видит, встречной записи нет» (§3 п. 9, AURORA-1746) в сводке свода."""
holder_id: uuid.UUID
holder_name: str
missing_id: uuid.UUID
missing_name: str
holder_rows: int
missing_unmapped_rows: int
missing_rows_total: int
class OsvSummaryOut(BaseModel):
"""Сводка по своду версии (`GET …/osv/summary`)."""
@ -249,6 +261,7 @@ class OsvSummaryOut(BaseModel):
auto_matched_total: int
documents_total: int
gaps: list[GapOut]
counter_pairs: list[CounterPairOut]
class OsvFilters(BaseModel):
@ -258,6 +271,7 @@ class OsvFilters(BaseModel):
account_root: str | None = None
counterparty_type: CounterpartyType | None = None
unmapped: bool | None = None
counterparty_id: uuid.UUID | None = None
match_kind: list[CounterpartyMatchKind] | None = None
q: str | None = Field(default=None, max_length=MAX_OSV_QUERY)

View File

@ -36,6 +36,7 @@ from src.schemas.common import Page
from src.schemas.consolidations import (
CandidateOut,
ConsolidatedRowOut,
CounterPairOut,
GapOut,
OsvFilters,
OsvPageParams,
@ -65,6 +66,7 @@ def domain_filter(f: OsvFilters) -> domain.OsvFilter:
account_root=f.account_root,
counterparty_type=f.counterparty_type,
unmapped=f.unmapped,
counterparty_id=f.counterparty_id,
match_kinds=frozenset(f.match_kind or ()),
q=f.q,
)
@ -232,6 +234,10 @@ class ConsolidatedOsvService:
auto_matched_total=auto_matched_total,
documents_total=result.consolidated.documents_total,
gaps=[GapOut.model_validate(gap, from_attributes=True) for gap in result.consolidated.gaps],
counter_pairs=[
CounterPairOut.model_validate(pair, from_attributes=True)
for pair in domain.counter_pairs(result.consolidated.rows, result.members)
],
)
async def row_for_entry(self, version_id: uuid.UUID, entry_id: uuid.UUID) -> ConsolidatedRowOut | None:

View File

@ -277,7 +277,7 @@ async def test_summary_counts_and_page_total_agree(auth_client: AuthClientFactor
assert summary == {
"rows_total": ROWS_TOTAL, "unmapped_total": UNMAPPED_TOTAL, "auto_matched_total": AUTO_MATCHED_TOTAL,
"documents_total": DOCUMENTS_TOTAL, "gaps": [],
"documents_total": DOCUMENTS_TOTAL, "gaps": [], "counter_pairs": [],
} # fmt: skip
unmapped = await _page(client, world, "unmapped=true")
assert unmapped["total"] == summary["unmapped_total"]
@ -287,6 +287,22 @@ async def test_summary_counts_and_page_total_agree(auth_client: AuthClientFactor
assert mapped["total"] == ROWS_TOTAL - UNMAPPED_TOTAL
@pytest.mark.integration
async def test_filter_by_counterparty_id(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)
client = await auth_client(KA_RF)
body = await _page(client, world, f"legal_entity_id={world.a.id}&counterparty_id={world.b.id}")
# у «ООО А» контрагент Б — в ОСВ 60 (склейка кварталов) и в ОСВ 62
assert body["total"] == 2 # noqa: PLR2004
assert {(r["legal_entity_name"], r["account_code"], r["counterparty_name"]) for r in body["items"]} == {
("ООО А", "60", "ООО Б"),
("ООО А", "62", "ООО Б"),
}
assert (await client.get(f"{_url(world)}?counterparty_id=not-a-uuid")).status_code == 422 # noqa: PLR2004
@pytest.mark.integration
async def test_filter_by_match_kind_repeated_param(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)

View File

@ -902,3 +902,32 @@ async def test_alias_delete_racing_with_move_alias_is_404_without_phantom_event(
]
assert len(removals) == 1 # только удаление при переносе
assert await session.get(LegalEntityAlias, alias_id) is None
@pytest.mark.integration
async def test_counter_pairs_before_and_after_mapping(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Пара «Счастливочная видит РБ, РБ её — нет»: у РБ без ИНН 60/62/66, 76 с ИНН вне справочника (AURORA-1866)."""
world = await _world(seeded)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{world.version.id}/osv"
summary = await _summary(client, world)
assert summary["counter_pairs"] == [
{
"holder_id": str(world.schastl.id), "holder_name": "ООО «Пекарня Счастливочная»",
"missing_id": str(world.rb.id), "missing_name": "АО Русский бисквит",
"holder_rows": 1, "missing_unmapped_rows": 3, "missing_rows_total": 5,
}
] # fmt: skip
unmapped = await client.get(f"{base}?legal_entity_id={world.rb.id}&unmapped=true")
assert unmapped.json()["total"] == 3 # noqa: PLR2004
held = await client.get(f"{base}?legal_entity_id={world.schastl.id}&counterparty_id={world.rb.id}")
assert held.json()["total"] == 1
mapped = await client.post(
_map_url(world, world.entries[("60", 1)]), json={"legal_entity_id": str(world.schastl.id)}
)
assert mapped.status_code == HTTP_OK, mapped.text
assert (await _summary(client, world))["counter_pairs"] == []

View File

@ -162,6 +162,8 @@ async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0
# Числа сверены независимым подсчётом по сырым golden-JSON (склейка Q1–Q3, пробелы по наличию файлов).
assert summary["rows_total"] == len(rows) == ROWS_TOTAL
assert summary["unmapped_total"] == UNMAPPED_TOTAL
# РБ записал «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» без ИНН: Счастливочная видит РБ, а РБ — её нет (§3 п. 9)
assert [(p["holder_id"], p["missing_id"]) for p in summary["counter_pairs"]] == [(str(schastl.id), str(rb.id))]
# строки свода: 60 и 62 у обеих, 76 у Счастливочной
present = {(row["legal_entity_id"], row["account_root"]) for row in rows}
@ -219,6 +221,7 @@ async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0
assert body["propagated"] == PROPAGATED
after = await _get(client, f"{version_url}/osv/summary")
assert after["counter_pairs"] == [] # после сопоставления пара закрыта
rows_after = await _all_rows(client, version_url)
# `propagated` считает записи `osv_entry` (в том числе в парах-пробелах 66/76, строк в своде не дающих), а
# `unmapped_total` — строки свода: квартальные записи одного контрагента склеены (D-2). Поэтому `unmapped_total`

View File

@ -11,6 +11,7 @@ import pytest
from src.domain.consolidated_osv import (
Consolidated,
ConsolidatedRow,
CounterPair,
CounterpartyType,
Entity,
GapItem,
@ -20,6 +21,7 @@ from src.domain.consolidated_osv import (
apply_filter,
build,
classify,
counter_pairs,
needs_mapping,
summary,
)
@ -552,3 +554,94 @@ def test_needs_mapping_is_no_counterparty_and_no_inn(
def test_counterparty_type_is_str_enum() -> None:
assert [t.value for t in CounterpartyType] == ["GSK", "GK", "EXTERNAL"]
def _rb_shape(*, rb_to_sch: uuid.UUID | None = None) -> Consolidated:
sch_60 = _doc(SCH, cumulative=True)
rb_60 = _doc(RB, cumulative=True)
rows = [
_row(sch_60, "Русский бисквит", counterparty_id=RB.id, match_kind=NAME),
_row(
rb_60,
"Пекарня Счастливочная ООО",
row_number=1,
counterparty_id=rb_to_sch,
match_kind=NAME if rb_to_sch else None,
),
_row(rb_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id, match_kind=NAME),
]
return _build([sch_60, rb_60], rows)
def test_counter_pairs_rb_shape() -> None:
result = _rb_shape()
assert counter_pairs(result.rows, MEMBERS) == [
CounterPair(SCH.id, SCH.name, RB.id, RB.name, holder_rows=1, missing_unmapped_rows=1, missing_rows_total=2)
]
def test_counter_pairs_closed_when_both_see_each_other() -> None:
assert counter_pairs(_rb_shape(rb_to_sch=SCH.id).rows, MEMBERS) == []
def test_counter_pairs_unrelated_members_absent() -> None:
sch_60 = _doc(SCH, cumulative=True)
rb_60 = _doc(RB, cumulative=True)
rows = [_row(sch_60, "РБ", counterparty_id=RB.id), _row(rb_60, "Сч", counterparty_id=SCH.id)]
result = _build([sch_60, rb_60], rows)
assert counter_pairs(result.rows, {**MEMBERS, CHKF.id: CHKF}) == []
def test_counter_pairs_missing_side_without_rows() -> None:
sch_60 = _doc(SCH, cumulative=True)
result = _build([sch_60], [_row(sch_60, "РБ", counterparty_id=RB.id)])
pairs = counter_pairs(result.rows, MEMBERS)
assert len(pairs) == 1
assert (pairs[0].missing_rows_total, pairs[0].missing_unmapped_rows) == (0, 0)
def test_counter_pairs_ignore_self_and_non_members() -> None:
rb_60 = _doc(RB, cumulative=True)
rows = [
_row(rb_60, "РБ", row_number=1, counterparty_id=RB.id),
_row(rb_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id),
]
assert counter_pairs(_build([rb_60], rows).rows, MEMBERS) == []
def test_counter_pairs_use_read_time_resolution() -> None:
sch_60 = _doc(SCH, cumulative=True)
rb_60 = _doc(RB, cumulative=True)
rows = [_row(sch_60, "РБ", counterparty_id=RB.id), _row(rb_60, "Пекарня Счастливочная ООО")]
resolver = _Resolver({(None, "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)})
assert counter_pairs(_build([sch_60, rb_60], rows, resolver=resolver).rows, MEMBERS) == []
def test_counter_pairs_order() -> None:
members = {RB.id: RB, SCH.id: SCH, CHKF.id: CHKF}
sch_60 = _doc(SCH, cumulative=True)
chkf_60 = _doc(CHKF, cumulative=True)
rows = [
_row(sch_60, "РБ", row_number=1, counterparty_id=RB.id),
_row(sch_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id),
_row(chkf_60, "РБ", counterparty_id=RB.id),
]
result = build(
year=2024,
quarter=Q3,
group_id=GROUP,
members=members,
entities=ENTITIES,
documents=[sch_60, chkf_60],
rows=rows,
resolver=_Resolver(),
)
got = [(p.missing_id, p.holder_id) for p in counter_pairs(result.rows, members)]
assert got == [(RB.id, SCH.id), (RB.id, CHKF.id), (CHKF.id, SCH.id)]
def test_filter_counterparty_id() -> None:
result = _rb_shape()
kept = apply_filter(result.rows, OsvFilter(counterparty_id=RB.id))
assert kept and all(r.counterparty_id == RB.id for r in kept)
assert len(kept) < len(result.rows)

View File

@ -0,0 +1,233 @@
# Пары участников без встречных записей — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** вместо требования «сопоставьте все строки без ИНН» показать аналитику пары участников периметра без
встречных записей: сводка ядра, режим «Встречные записи» на вкладке «ОСВ», модалка перед шахматкой, новый баннер.
**Architecture:** чистая функция `counter_pairs` в `api/src/domain/consolidated_osv.py` считает пары по уже собранным
строкам свода; `GET …/osv/summary` отдаёт их, `GET …/osv` получает фильтр `counterparty_id`. Фронт берёт строки обеих
сторон пары существующим `GET …/osv` с фильтрами; правило показа модалки — чистые функции в `web/src/utils/` с
`sessionStorage`. В БД ничего не пишется.
**Tech Stack:** Python 3.11, FastAPI, pydantic v2, pytest; React 18 + TS strict, antd 6, react-router 7, vitest.
**Spec:** `openspec/changes/counter-pairs/` (proposal.md, design.md, specs/consolidated-osv, specs/consolidated-osv-ui,
specs/consolidations-ui). Issue — arm-core #195, Jira — AURORA-1746. Ветка `feat/195-counter-pairs`, worktree
`/Users/andrey.epifancev/projects/awp-ca/.wt/counter-pairs/arm-core`. Все git-команды — `git -C <абсолютный путь worktree>`.
## Global Constraints
- Ничего не хранится: ни новых таблиц, ни колонок, ни миграций; DBML v5.23 не меняется.
- Пара (А, Б): А, Б ∈ периметр (`members` версии), А ≠ Б; у А есть строка свода с `counterparty_id = Б`, у Б нет строки с `counterparty_id = А`. Учитываются все строки свода (любой счёт, любые суммы).
- Порядок пар: имя Б (`missing_name`), затем имя А (`holder_name`), затем `missing_id`, `holder_id`.
- Поля пары (API и TS): `holder_id`, `holder_name`, `missing_id`, `missing_name`, `holder_rows`, `missing_unmapped_rows`, `missing_rows_total`.
- Тексты (дословно):
- баннер, заголовок: `Есть организации без ИНН и без сопоставления`; текст: `Осталось: N. Несопоставленные учитываются как внешние; при необходимости укажите организацию кнопкой «Указать».`
- модалка, заголовок: `Проверьте встречные записи участников`; строка 1: `Нет встречных записей: N пар у M участников.` (склонение через `pluralRu`: `['пара','пары','пар']`, `['участника','участников','участников']`); строка 2: `Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте. Ещё K строк без ИНН учтены как внешние.` — при K = 0 без последнего предложения; кнопки `Показать пары`, `Остаться`, `Перейти`.
- переключатель: `Свод` | `Встречные записи (N)`; строка пары: `<missing_name> ← <holder_name>`.
- центр: `У <holder_name> есть записи с <missing_name>`; справа: `У <missing_name> — строки без ИНН и без сопоставления`.
- пусто: `Все участники периметра видят друг друга`; `missing_rows_total = 0`: `У <missing_name> в своде нет строк: ОСВ не привязаны или по ним пробелы`; `missing_unmapped_rows = 0` при строках: `Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте`.
- Модалка не показывается для статусов `VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED`; ключ `sessionStorage` — `armka:counter-pairs:<version_id>`, значение — ключ списка пар.
- Режим — `?view=pairs[&holder=<id>&missing=<id>]`, переключение режима и выбор пары — `replace: true`.
- Комментарии, коммиты, тексты — по-русски; номер § правила (`05-domain-rules.md` §3 п. 9) — в docstring.
## Review Focus
1. Пара с `missing_rows_total = 0` (у Б нет ОСВ) — правая часть показывает текст, а не пустую таблицу и не ошибку → тест в Task 1 (`test_counter_pairs_missing_side_without_rows`) и ветка рендера в Task 5.
2. Сопоставление закрыло выбранную пару — экран выбирает первую оставшуюся, а не висит на исчезнувших `holder/missing` из адреса → `selectPair` в Task 3 (`selectPair falls back to first`).
3. `sessionStorage` бросает исключение (приватный режим) — модалка показывается, страница не падает → тесты Task 3 (`throwing storage`).
4. Дорезолв на чтение закрывает пару без «Связать» — функция работает по строкам после дорезолва → `test_counter_pairs_use_read_time_resolution` в Task 1.
5. Строка Б с ИНН вне справочника — не в правой таблице, пара остаётся (AURORA-1866) → интеграционный тест Task 2 считает `missing_unmapped_rows = 3` при 5 строках РБ.
---
### Task 1: Доменная функция пар и фильтр `counterparty_id` (api, модель: sonnet)
**Files:**
- Modify: `api/src/domain/consolidated_osv.py` (новый `CounterPair`, `counter_pairs`; поле `OsvFilter.counterparty_id`, проверка в `_matches`)
- Test: `api/tests/test_domain_consolidated_osv.py`
**Interfaces:**
- Produces:
```python
@dataclass(frozen=True)
class CounterPair:
holder_id: uuid.UUID
holder_name: str
missing_id: uuid.UUID
missing_name: str
holder_rows: int
missing_unmapped_rows: int
missing_rows_total: int
def counter_pairs(rows: Sequence[ConsolidatedRow], members: Mapping[uuid.UUID, Entity]) -> list[CounterPair]
# OsvFilter.counterparty_id: uuid.UUID | None = None
```
Имена — из `members[id].name`. `missing_unmapped_rows` — через существующую `needs_mapping`.
- [ ] **Step 1: Тесты** (фабрики `_doc`, `_row`, `_build`, сущности `RB`, `SCH`, `CHKF`, `MEMBERS` уже в файле):
- `test_counter_pairs_rb_shape`: `sch_60` со строкой «Русский бисквит» `counterparty_id=RB.id`; `rb_60` со строками «Пекарня Счастливочная ООО» (без id) и «ЧКФ» (`CHKF.id`) → `counter_pairs(result.rows, MEMBERS) == [CounterPair(SCH.id, SCH.name, RB.id, RB.name, holder_rows=1, missing_unmapped_rows=1, missing_rows_total=2)]`.
- `test_counter_pairs_closed_when_both_see_each_other`: та же картина, но строка РБ сопоставлена `counterparty_id=SCH.id` → `[]`.
- `test_counter_pairs_unrelated_members_absent`: строки только РБ↔Счастливочная в обе стороны, `members = {**MEMBERS, CHKF.id: CHKF}` → `[]` (ЧКФ ни с кем не связан).
- `test_counter_pairs_missing_side_without_rows`: только `sch_60` со строкой на РБ → одна пара, `missing_rows_total == 0`, `missing_unmapped_rows == 0`.
- `test_counter_pairs_ignore_self_and_non_members`: строка РБ с `counterparty_id=RB.id` и строка РБ с `CHKF.id` при `MEMBERS` → `[]`.
- `test_counter_pairs_use_read_time_resolution`: строка РБ «Пекарня Счастливочная ООО» без id, `_Resolver({(None, "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)})` (`Resolution(entity_id, kind)` из `src/domain/osv_mapping.py`), у Счастливочной строка на РБ → `[]`.
- `test_counter_pairs_order`: `members` из трёх (RB, SCH, CHKF как участник); ЧКФ и Счастливочная видят РБ, РБ не видит никого; плюс Счастливочная видит ЧКФ, ЧКФ не видит Счастливочную → порядок по `missing_name`, затем `holder_name`: `[(missing=РБ, holder=Счастливочная), (missing=РБ, holder=ЧКФ), (missing=ЧКФ, holder=Счастливочная)]` («АО Русский бисквит» < «ООО ЧКФ»; «ООО Пекарня…» < «ООО ЧКФ»).
- `test_filter_counterparty_id`: `apply_filter(rows, OsvFilter(counterparty_id=RB.id))` оставляет только строки с `counterparty_id == RB.id`.
- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_consolidated_osv.py -q` — новые тесты падают (ImportError/AttributeError).
- [ ] **Step 3:** реализовать `CounterPair`, `counter_pairs` (множество видимых пар `(legal_entity_id, counterparty_id)` по строкам, оба в `members`, различны; счётчики одним проходом), `OsvFilter.counterparty_id` и строку в `_matches`. Docstring — «§3 п. 9 05-domain-rules (AURORA-1746)»; добавить в `__all__`, если он есть.
- [ ] **Step 4:** `pytest tests/test_domain_consolidated_osv.py -q` — зелёно; из корня worktree `make lint` — чисто.
- [ ] **Step 5:** `git -C <worktree> commit -m "feat(#195): доменная функция пар без встречных записей и фильтр counterparty_id"` (+ строка `Co-Authored-By`).
### Task 2: Сводка и фильтр в API (api, модель: sonnet)
**Files:**
- Modify: `api/src/schemas/consolidations.py` (`CounterPairOut`, `OsvSummaryOut.counter_pairs`, `OsvFilters.counterparty_id`)
- Modify: `api/src/services/consolidated_osv.py` (`domain_filter`, `summary`)
- Modify: `api/src/api/endpoints/consolidations/osv.py` — только docstring маршрута (перечень фильтров), если он их перечисляет
- Test: `api/tests/test_api_consolidated_osv.py`, `api/tests/test_api_counterparty_mapping.py`
**Interfaces:**
- Consumes: `domain.counter_pairs`, `domain.CounterPair`, `OsvFilter.counterparty_id` (Task 1).
- Produces: JSON `summary.counter_pairs: [{holder_id, holder_name, missing_id, missing_name, holder_rows, missing_unmapped_rows, missing_rows_total}]` (id — строки UUID); query `counterparty_id=<uuid>` у `GET …/osv` и `GET …/osv/export`.
- [ ] **Step 1: Тесты.**
- `test_api_consolidated_osv.py::test_summary_counts_and_page_total_agree` — в ожидаемый словарь добавить `"counter_pairs": []` (в мире теста А и Б видят друг друга).
- `test_api_consolidated_osv.py::test_filter_by_counterparty_id`: `_page(client, world, f"legal_entity_id={world.a.id}&counterparty_id={world.b.id}")` → `total == 1`, строка `("ООО А", "62", "ООО Б")`; невалидный UUID → 422.
- `test_api_counterparty_mapping.py::test_counter_pairs_before_and_after_mapping`: `world = await _world(seeded)`; `_summary` → `counter_pairs == [{"holder_id": str(world.schastl.id), "holder_name": "ООО «Пекарня Счастливочная»", "missing_id": str(world.rb.id), "missing_name": "АО Русский бисквит", "holder_rows": 1, "missing_unmapped_rows": 3, "missing_rows_total": 5}]` (у РБ строки 60×2, 62, 66, 76; без ИНН — 60/62/66; 76 с ИНН вне справочника — AURORA-1866); `GET …/osv?legal_entity_id=<rb>&unmapped=true` → `total == 3`; `GET …/osv?legal_entity_id=<schastl>&counterparty_id=<rb>` → `total == 1`; затем `POST _map_url(world, world.entries[("60", 1)])` с `legal_entity_id = schastl` → 200, `_summary(...)["counter_pairs"] == []`.
- [ ] **Step 2:** из корня worktree `make test-integration` (или `cd api && .venv/bin/pytest -m integration tests/test_api_consolidated_osv.py tests/test_api_counterparty_mapping.py -q` при поднятом compose, `make up`) — новые тесты падают.
- [ ] **Step 3:** `CounterPairOut(BaseModel)` с полями пары; `OsvSummaryOut.counter_pairs: list[CounterPairOut]`; `OsvFilters.counterparty_id: uuid.UUID | None = None`; `domain_filter` передаёт поле; `summary()` — `counter_pairs=[CounterPairOut.model_validate(p, from_attributes=True) for p in domain.counter_pairs(result.consolidated.rows, result.members)]`.
- [ ] **Step 4:** те же тесты зелёные; `make check` — зелёно.
- [ ] **Step 5:** коммит `feat(#195): counter_pairs в сводке сводной ОСВ и фильтр counterparty_id`.
### Task 3: Типы, клиент API и чистые правила фронта (web, модель: sonnet)
**Files:**
- Modify: `web/src/types/api.ts` (`CounterPairOut`, `OsvSummaryOut.counter_pairs`, `OsvFilters.counterparty_id?: string`)
- Modify: `web/src/services/api/osv.ts` (`buildOsvQuery` — `counterparty_id`), тест `web/src/services/api/osv.test.ts`
- Modify: `web/src/utils/consolidations.ts` (+ `utils/consolidations.test.ts`): удалить `needsUnmappedWarning` и его тест
- Create: `web/src/widgets/ConsolidatedOsv/counterPairs.ts` + `counterPairs.test.ts`
**Interfaces:**
- Produces (`utils/consolidations.ts`):
```ts
export const FINISHED_STATUSES: readonly ConsStatus[]; // ['VERIFIED','CANCELLED','ARCHIVED','REJECTED']
export const COUNTER_PAIRS_STORAGE_PREFIX = 'armka:counter-pairs:';
export const counterPairsKey = (pairs: readonly CounterPairOut[]): string; // `${holder_id}>${missing_id}` отсортированные, через ','
export const shouldWarnCounterPairs = (versionId: string, status: ConsStatus | undefined, pairs: readonly CounterPairOut[], storage: Pick<Storage, 'getItem'> | null): boolean;
export const rememberCounterPairsShown = (versionId: string, pairs: readonly CounterPairOut[], storage: Pick<Storage, 'setItem'> | null): void;
export const counterPairsWarning = (pairs: readonly CounterPairOut[], unmappedTotal: number): { headline: string; details: string };
```
`shouldWarnCounterPairs`: `false` при пустых парах или статусе из `FINISHED_STATUSES`; иначе `true`, если хранилище `null`, бросает или хранит другой ключ. `rememberCounterPairsShown` глотает исключения.
- Produces (`widgets/ConsolidatedOsv/counterPairs.ts`):
```ts
export type PairsView = { view: 'table' | 'pairs'; holder: string | null; missing: string | null };
export const readPairsView = (search: URLSearchParams): PairsView;
export const pairsViewSearch = (v: PairsView): string; // '' для table; '?view=pairs[&holder=..&missing=..]'
export const selectPair = (pairs: readonly CounterPairOut[], holder: string | null, missing: string | null): CounterPairOut | null; // заданная или первая
export const pairLabel = (p: CounterPairOut): string; // `${missing_name} ← ${holder_name}`
```
- [ ] **Step 1: Тесты vitest.**
- `counterPairsKey` не зависит от порядка входа.
- `shouldWarnCounterPairs`: пусто → false; `CANCELLED` → false; хранилище без ключа → true; с тем же ключом → false; с другим ключом → true; `null` → true; `getItem` бросает → true (`throwing storage`).
- `rememberCounterPairsShown` пишет `armka:counter-pairs:<id>` = ключ; бросающий `setItem` не пробрасывает.
- `counterPairsWarning(одна пара, 1260)` → `headline === 'Нет встречных записей: 1 пара у 1 участника.'`, `details` оканчивается `Ещё 1260 строк без ИНН учтены как внешние.`; пять пар у трёх участников → `'Нет встречных записей: 5 пар у 3 участников.'`; `unmappedTotal = 0` → `details === 'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.'`.
- `readPairsView`/`pairsViewSearch` — туда-обратно; `selectPair` — заданная пара; `selectPair falls back to first`, если заданной нет; пустой список → `null`.
- `buildOsvQuery({ counterparty_id: 'x' })` → `?counterparty_id=x`.
- [ ] **Step 2:** `cd web && npm test` — падают.
- [ ] **Step 3:** реализовать; `needsUnmappedWarning` удалить (использование в `OsvTabs` убирает Task 4 — до тех пор `tsc` может падать, поэтому Task 3 и Task 4 коммитятся по очереди, но `npm test` зелёный после Task 3).
- [ ] **Step 4:** `npm test` — зелёно.
- [ ] **Step 5:** коммит `feat(#195): типы и правила показа пар без встречных записей (web)`.
### Task 4: Модалка перед шахматкой и баннер (web, модель: sonnet)
**Files:**
- Modify: `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx` (удалить `UnmappedInnModal`, добавить `CounterPairsModal`)
- Modify: `web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx` (баннер; проп `onSummaryChange` вместо `onUnmappedChange`)
- Modify: `web/src/layouts/ConsolidationReportPage.tsx`
**Interfaces:**
- Consumes: `shouldWarnCounterPairs`, `rememberCounterPairsShown`, `counterPairsWarning`, `pairsViewSearch` (Task 3); `OsvSummaryOut.counter_pairs`.
- Produces: `OsvContent` проп `onSummaryChange?: (summary: OsvSummaryOut | null) => void` (при размонтировании — `null`); `OsvTabs` пропсы `{ summary: OsvSummaryOut | null; versionId: string; status: ConsStatus | undefined }`.
- [ ] **Step 1:** `ConsolidationReportPage`: состояние `osvSummary` вместо `unmappedTotal`, передать в `OsvContent` и `OsvTabs` (`versionId`, `card?.status`).
- [ ] **Step 2:** `OsvTabs.handleTabChange`: с `osv` и `shouldWarnCounterPairs(versionId, status, pairs, safeSessionStorage())` → открыть модалку и сразу `rememberCounterPairsShown`; иначе `go`. `safeSessionStorage()` — `try { return window.sessionStorage } catch { return null }`. Модалка — тексты из Global Constraints через `counterPairsWarning`; «Показать пары» → `navigate({ pathname: location.pathname, search: pairsViewSearch({ view: 'pairs', holder: null, missing: null }) }, { replace: true })`, «Остаться» — закрыть, «Перейти» — `go(pending)`.
- [ ] **Step 3:** баннер — новые заголовок и текст (Global Constraints), кнопка фильтра без изменений.
- [ ] **Step 4:** `make web-check` (из корня worktree) и `cd web && npm test && npm run lint` — зелёно.
- [ ] **Step 5:** ручная проверка на стенде (`../../docs/dev/qa-guide.md` §4, версия РБ 2024 Q3 до сопоставления): баннер с новым текстом; «Шахматка» → модалка «Нет встречных записей: 1 пара у 1 участника.»; повторный переход — без модалки; «Показать пары» оставляет «ОСВ» с `?view=pairs`. Скриншот модалки — в отчёт задачи.
- [ ] **Step 6:** коммит `feat(#195): модалка «Нет встречных записей» перед шахматкой и баннер строк без ИНН`.
### Task 5: Режим «Встречные записи» (web, модель: sonnet)
**Files:**
- Create: `web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx`
- Modify: `web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx` (переключатель режимов)
- Modify: `web/src/widgets/ConsolidatedOsv/OsvTable.tsx` — экспортировать `money` и `group` для повторного использования колонок (без изменения поведения)
**Interfaces:**
- Consumes: `readPairsView`, `pairsViewSearch`, `selectPair`, `pairLabel` (Task 3); `getConsolidatedOsv(versionId, filters, limit, offset)`; `CounterpartyMapModal({ versionId, row, onClose, onMapped })`; `OSV_PAGE_SIZE`; `format` из `useNumberDisplay`.
- Produces: `CounterPairsView({ versionId, pairs, holder, missing, numFormat, onSelect, onMapped })`, где `onSelect: (holder: string, missing: string) => void`, `onMapped: () => void`.
- [ ] **Step 1:** в `OsvContent` — `useSearchParams`; `Segmented` с опциями `Свод` / `Встречные записи (N)` (N = `summary?.counter_pairs.length ?? 0`), смена — `setSearchParams(..., { replace: true })` через `pairsViewSearch`; в режиме `pairs` вместо `OsvGaps` + `OsvTable` рендерить `CounterPairsView`; шапка (единицы, знаки, экспорт) остаётся.
- [ ] **Step 2:** `CounterPairsView`: слева список пар (`pairLabel`, числа `holder_rows` / `missing_unmapped_rows`, подсветка выбранной); выбранная — `selectPair`. Центр — `getConsolidatedOsv(versionId, { legal_entity_id: holder_id, counterparty_id: missing_id }, OSV_PAGE_SIZE, 0)`; справа — `{ legal_entity_id: missing_id, unmapped: true, q }` постранично, над таблицей `Input.Search` (≤ 500 символов), колонка «Указать» открывает `CounterpartyMapModal`. Колонки обеих таблиц: «Счёт», «Контрагент», группы «Обороты за период» (Дт/Кт) и «Сальдо на конец периода» (Дт/Кт) через `money`/`group`. Пустые состояния — тексты из Global Constraints по `missing_rows_total` / `missing_unmapped_rows` / пустому списку пар. Ответы устаревших запросов игнорировать (счётчик запросов, как в `useConsolidatedOsv`).
- [ ] **Step 3:** после `onMapped` — `osv.refresh()` (сводка) и перечитать обе таблицы; если выбранная пара исчезла — `selectPair` вернёт первую, адрес обновить `replace`.
- [ ] **Step 4:** `make web-check`, `npm test`, `npm run lint` — зелёно.
- [ ] **Step 5:** ручной сценарий на стенде (РБ 2024 Q3 до сопоставления): «Встречные записи (1)», пара «АО Русский бисквит ← ООО «Пекарня Счастливочная»», в центре строка Счастливочной на РБ, справа поиск «Пекарня» находит «Пекарня Счастливочная ООО»; «Указать» → «Связать» → «Все участники периметра видят друг друга», «Встречные записи (0)». Скриншоты — в отчёт задачи; сверить стиль с макетом [30] (`../../docs/mockup/INDEX.md`).
- [ ] **Step 6:** коммит `feat(#195): режим «Встречные записи» на вкладке «ОСВ»`.
### Task 6: Документация и финальная проверка (docs + arm-core, модель: sonnet)
**Files:**
- docs (отдельный репозиторий, свой worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/counter-pairs/docs` от `origin/main`, issue в `RSHB/arm-ka-docs` через `tea issues create --login home --repo RSHB/arm-ka-docs`, ветка `feat/<issue>-counter-pairs`):
- `context/05-domain-rules.md` §3 — новый п. 9 «Пары участников без встречных записей» (определение и поля из спеки `consolidated-osv`, решение владельца 27.10.2026, AURORA-1746; строки Б с ИНН вне справочника — AURORA-1866);
- `context/10-user-journey.md` строка 3.1 — баннер, режим «Встречные записи», модалка вместо [31-00];
- `dev/api-contract-slice-4.md` — `counter_pairs[]` в `OsvSummaryOut`, фильтр `counterparty_id`, строка [31-00] → модалка «Нет встречных записей».
- arm-core: `CLAUDE.md` раздел «Консолидации» — одна фраза про `counter_pairs` в `consolidated_osv`.
- [ ] **Step 1:** правки docs, `make -C <docs worktree> check` (если цель есть), коммит, push, PR `tea pulls create` со ссылкой на arm-core #195 и AURORA-1746.
- [ ] **Step 2:** правка `CLAUDE.md` arm-core, коммит.
- [ ] **Step 3:** из корня arm-core worktree `make check-full` — вывод сохранить для описания PR; `openspec validate counter-pairs --strict`.
- [ ] **Step 4:** коммит, если были правки по итогам проверки.
---
## Волна 2 (решение владельца 27.10.2026: всё отложенное закрыть в этой ветке)
Правила те же: sonnet — исполнители и ревью задач, opus — финальное ревью; маркеры T/H/O/R/C-I-M; без push в arm-core. Новые npm-пакеты не добавляем (web/CLAUDE.md, contour-transfer-guide §3.2): компонентные тесты заменяются выносом логики в чистые функции + vitest.
### Task 7: логика перехода и режима пар (web, модель: sonnet)
**Files:** `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx`, `web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx`, `web/src/widgets/ConsolidatedOsv/counterPairs.ts` (+ тест), `web/src/utils/consolidations.ts` (+ тест), хук `useConsolidatedOsv`, `web/src/layouts/ConsolidationReportPage.tsx` при необходимости.
- [ ] T4.1: один вызов `safeSessionStorage()` в `OsvTabs`.
- [ ] T4.2: при уходе с «ОСВ», если `summary === null` — запросить `getOsvSummary(versionId)` и решить по ответу (чистая функция решения: `perform`/`warn`/`go`); при ошибке запроса — переход без модалки; переход без проверки при `null` недопустим.
- [ ] T5.4: `useConsolidatedOsv` не запрашивает строки, пока активен режим пар (сводку — запрашивает); убрать двойной fetch при смене фильтра/пары (`setPage(1)` не должен порождать запрос со старой страницей).
- [ ] T5.5: `holder`/`missing` в адресе не стираются в режиме «Свод»; возврат на «Встречные записи» восстанавливает пару; `readPairsView`/`pairsViewSearch` и тесты поправить.
- [ ] M-4: в режиме пар при `summary = null` — `Spin`; переключатель «Встречные записи» без «(N)» пока сводки нет.
- [ ] T4.3 (часть): логику решений `OsvTabs` вынести в чистые функции и покрыть vitest.
- [ ] `make web-check`, `npm test`, `npm run lint`; коммит `feat(#195): ...`.
### Task 8: раскладка режима «Встречные записи» и чистая логика CounterPairsView (web + спека, модель: sonnet)
**Files:** `web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx` (+ вынесенные чистые функции и тест), `openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md`, `openspec/changes/counter-pairs/design.md`.
- [ ] H-1: вместо трёх колонок — список пар вверху одной строкой «таблеток» с переносом (подпись `записей: N · без ИНН у <missing_name>: M` сохранить), под ним две таблицы на всю ширину одна под другой: «У А есть записи с Б», затем «У Б — строки без ИНН и без сопоставления» с поиском. Все четыре колонки сумм видны без горизонтальной прокрутки при ширине окна 1440.
- [ ] Спека consolidated-osv-ui («слева/в центре/справа») — под новую раскладку; решение — в design.md как `R-5 (design #13)`.
- [ ] T5.6: вынести в чистые функции и покрыть vitest: какой текст пустого состояния показать (пусто / `missing_rows_total = 0` / `missing_unmapped_rows = 0`), выбор пары после сопоставления, ограничение страницы.
- [ ] `make web-check`, `npm test`, `npm run lint`; коммит `feat(#195): ...`.
### Task 9: проверка на стенде и окружение (контроллер/исполнитель, модель: sonnet)
- [ ] O-5: стенд с кодом ветки; проверить три пустых состояния и раскладку на 1440; сверить с макетом [30]. Перед правкой данных — SELECT исходных значений в файл ledger; после — восстановление и сверка SELECT-ом (включая `counterparty_match_kind` у 25 строк РБ, смена с ALIAS на MANUAL/PROPAGATED от O-3 — вернуть прежние значения, если есть бэкап, иначе описать в отчёте).
- [ ] O-1: проверить `make up` из worktree при существующем `.wt/counter-pairs/docs`.
- [ ] M-7: правка Purpose в `openspec/specs/consolidations-ui/spec.md` — пункт чеклиста закрытия (`openspec archive`), не follow-up.
## Волна 3 (решение владельца 27.10.2026 после показа на стенде)
### Task 10: модалка сопоставления в режиме пар сужена до участника пары (web + спека, исполнял основной сеанс)
- [x] `pairCandidateRows(candidates, target)` в `counterpartyMapping.ts` + vitest (участник пары под «Участник пары»; нет — пусто).
- [x] `CounterpartyMapModal` — проп `pairTarget?: PairTarget`: запрос `scope=group&q=<имя А>`, поиск и «Искать во всём справочнике» скрыты, флажок «Показать все организации»; `CounterPairsView` передаёт участника выбранной пары.
- [x] Спека `consolidated-osv-ui` (сценарий «Модалка сопоставления сужена до участника пары»), design.md (решение владельца 7).
- [x] tsc, biome, prettier, `npm test`, `npm run lint`; стенд — модалка с одной строкой «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО».

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-27

View File

@ -0,0 +1,100 @@
# Дизайн: пары участников без встречных записей (AURORA-1746, arm-core #195)
## Контекст и зачем
Перед шахматкой аналитику важно одно: все ли внутригрупповые связи видны с обеих сторон. Шахматка строится по строкам
свода с признаком «ГСК». Если у компании Б контрагент А лежит строкой без ИНН и без сопоставления, он «Внешний»,
и пара А–Б в шахматке выглядит однобокой.
Сейчас это подменено счётчиком всех строк без ИНН. Баннер требует «сопоставьте все», модалка [31-00] всплывает при каждом
уходе с «ОСВ», хотя переход не блокируется (AURORA-1388). Заказчик (Циликов, 23.10.2026) прочитал это как обязанность
прокликать каждую строку, а счётчик на реальных данных до нуля не доходит.
Успех: аналитик видит только существенное — пары участников без встречных записей — и разбирает их в одном месте, видя
обе стороны. Строки без ИНН, не влияющие на пары, ему не мешают.
Антицель: экран, который снова заставляет «прокликать всё», или модалка, которая всплывает при каждом клике.
## Решения владельца (27.10.2026, обсуждение в чате)
1. Проверка — пары участников периметра: у А есть записи с контрагентом Б, у Б нет записей с контрагентом А. Уровень —
компании, не счета.
2. У Б может быть настоящий пропуск в учёте, поэтому переход никогда не блокируется.
3. Модалка — вариант «короткая сводка»: только цифры, разбор — в отдельном представлении («Показать пары»).
4. Представление — отдельный режим вкладки «ОСВ» с таблицами («они любят таблицы»): список пар, строки А с Б, строки Б
без ИНН и без сопоставления.
5. Подсветка кандидата по совпадению суммы — отдельная задача.
6. В БД ничего не хранить. Модалка — один раз за сессию или после исправлений в сводной ОСВ.
7. (после показа на стенде) «Указать» в режиме пар открывает модалку сопоставления, сразу суженную до участника пары, с флажком «Показать все организации» — вариант 1 из двух показанных; кнопку «Связать с <А>» прямо в строке не делаем (легко промахнуться).
## Устройство
### Ядро
- `domain/consolidated_osv.py`: `counter_pairs(rows, members) -> list[CounterPair]` — чистая функция по строкам
`Consolidated.rows` (после дорезолва) и периметру. Множество «кто кого видит» — пары `(row.legal_entity_id,
row.counterparty_id)`, где оба в периметре и различны. Пара (А, Б) в списке ⇔ (А, Б) есть, (Б, А) нет. Счётчики:
`holder_rows` — строки А с контрагентом Б; `missing_unmapped_rows` — строки Б с `needs_mapping`; `missing_rows_total` —
все строки Б. Сортировка — имя Б, имя А (затем id для стабильности).
- `OsvFilter.counterparty_id` + `_matches`; `OsvFilters.counterparty_id` в схеме; экспорт принимает тот же фильтр
автоматически (общая модель фильтров).
- `OsvSummaryOut.counter_pairs: list[CounterPairOut]`; `ConsolidatedOsvService.summary` считает их из того же `_build`,
второго прохода по БД нет.
- Аудит не пишется (чтение).
### Фронт
- `useConsolidatedOsv` / `ConsolidatedOsvContent`: режим из `?view=pairs` (`useSearchParams`, `replace`).
- Новый виджет `widgets/ConsolidatedOsv/CounterPairs*.tsx`: список пар + две таблицы (antd `Table`), колонки — счёт,
контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт; суммы — через существующий `useNumberDisplay` (единицы и знаки
из шапки). Вторая (нижняя) таблица — постранично, `OSV_PAGE_SIZE`, «Указать» → `CounterpartyMapModal` (существующая).
- `ConsolidationReportPage` / `OsvTabs`: вместо `unmappedTotal` получают сводку (пары и `unmapped_total`); правило показа
модалки — чистая функция в `utils/consolidations.ts` (`counterPairsKey(pairs)`, `shouldWarnCounterPairs(versionId,
status, pairs, storage)`), с тестами vitest. Ключ `sessionStorage` — `armka:counter-pairs:<version_id>`, значение — ключ списка
пар (`holder_id>missing_id`, отсортированные, через запятую). Чтение и запись — в `try/catch`.
- `UnmappedInnModal` заменяется модалкой «Проверьте встречные записи участников»; склонение «пара/пары/пар»,
«участника/участников» — существующая `pluralRu` (`web/src/utils/labels.ts`).
- Над второй (нижней) таблицей — поле поиска по контрагенту (`q` существующего фильтра `GET …/osv`, пауза ввода как в своде):
у РБ на фикстуре около тысячи строк без ИНН, листать страницы в поисках одной строки — та же «прокликай всё».
- Модалка не показывается для версий в завершённых статусах (`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED` —
`FINISHED_STATUSES`): править там нечего. Режим «Встречные записи» доступен в любом статусе, «Указать» ведёт себя как
в своде (ошибки 409 — от сервера).
- Баннер — новый текст (спека `consolidated-osv-ui`).
### Документы (отдельный PR в docs)
- `05-domain-rules.md` §3 п. 9 «Пары участников без встречных записей» — определение из спеки `consolidated-osv`.
- `10-user-journey.md` строка 3.1 — новый текст баннера, режим «Встречные записи», модалка вместо [31-00].
- `dev/api-contract-slice-4.md` — `counter_pairs[]` в сводке, фильтр `counterparty_id`.
## Что оставлено исполнителю
Раскладка (пары сверху, две таблицы на всю ширину) и ширины, тексты пустых состояний в пределах спеки, имена компонентов, вынос общих колонок сумм
между сводом и новым режимом.
## Риски
- Объём: `GET …/osv/summary` теперь считает пары — линейный проход по уже собранным строкам, без новых запросов к БД.
- Дорезолв на чтение влияет на пары: появление алиаса может закрыть пару без «Связать». Это корректно — признак считается
от того же свода, что видит аналитик.
## Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 27.10.2026 | «После исправлений» = список пар изменился (ключ списка пар в `sessionStorage`), а не любое «Связать» | Повтор модалки нужен, когда есть что сказать нового; «Связать» вне пар модалку не меняет | Низкая: правило в одной функции фронта |
| 27.10.2026 | Строки А и Б берутся существующим `GET …/osv` с фильтрами (`counterparty_id` — новый), в сводке — только пары и счётчики | Без нового эндпоинта; пагинация и экспорт работают как в своде | Низкая |
| 27.10.2026 | Учитываются все строки свода, без ограничения счетами правил элиминации и без порога по суммам | Владелец согласовал уровень компаний; строка с нулями тоже говорит, что контрагент виден | Средняя: возможны «лишние» пары из-за случайных счетов — правится фильтром в функции |
| 27.10.2026 | Колонки таблиц режима — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт (без сальдо на начало) | Колонки должны уместиться на всю ширину при 1440; для поиска пары важнее обороты и итог | Низкая: добавить колонки |
| 27.10.2026 | Режим — параметр адреса `?view=pairs` (с `holder`/`missing`), переключение без записи в историю | Как вкладки отчёта (AURORA-1851); ссылку на пару можно переслать | Низкая |
| 27.10.2026 | Тексты баннера и модалки — из спек этого change | Владелец согласовал смысл, формулировки мои | Низкая |
| 27.10.2026 | Поиск `q` над второй (нижней) таблицей режима (находка builder check) | У РБ ~1000 строк без ИНН; без поиска разбор пары — листание страниц | Низкая: существующий фильтр API |
| 27.10.2026 | Модалка не показывается для версий в `FINISHED_STATUSES` (находка builder check) | Замороженную версию не исправить — предупреждение было бы шумом | Низкая |
| 27.10.2026 | R-1 (design #9): `needsUnmappedWarning` удаляется в Task 4 вместе с его использованием в `OsvTabs`, а не в Task 3 | Иначе между коммитами Task 3 и 4 сборка (tsc) красная | Нулевая |
| 27.10.2026 | R-2 (design #10): в тексте модалки «Ещё K строк без ИНН…» слово «строк» не склоняется, M — число разных `missing_id` (по спеке) | Спека дословна; склонение K=1 даёт всё равно неграмматичную фразу («учтены») | Низкая |
| 27.10.2026 | R-3 (design #11): отдельное ревью Task 6 не проводится — её diff в arm-core (одна фраза в CLAUDE.md) и docs-PR #176 проверяются финальным ревью всей ветки | Задача не содержит кода; отдельный ревьюер дублировал бы финальный | Низкая |
| 27.10.2026 | R-4 (design #12): подпись под парой в списке — «записей: N · без ИНН у <Б>: M» вместо служебных «держатель»/«Б» | Аналитик не знает ролей «держатель» и «Б»; название организации понятнее | Низкая |
| 27.10.2026 | R-5 (design #13): раскладка режима «Встречные записи» — пары одной строкой «таблеток» с переносом, под ними две таблицы на всю ширину одна под другой (вместо трёх колонок) | В трёх колонках таблицам не хватало ширины: суммы обрезались, видны были только «Обороты Дт» (находка H-1) | Низкая |
| 27.10.2026 | R-6 (design #14): уход с «ОСВ» (продолжение: пока сводки режима нет — Spin и подпись «Встречные записи» без «(N)»; ошибка запроса сводки — Alert с «Повторить»; сводка, догруженная при уходе, не кешируется и живёт только на время модалки) — закрытая версия (FINISHED_STATUSES) переходит без запроса сводки; при `summary = null` запрашивается сводка, при ошибке запроса переход идёт без модалки и без своего сообщения; на время догрузки переключатель вкладок блокируется; выбранная пара хранится в адресе и в режиме «Свод» (`?holder=&missing=` без `view`) | Модалка нужна только открытой версии; ошибку сводки показывает общий слой; блокировка исключает двойные клики; адрес — единственное хранилище состояния режима | Низкая |
Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867.

View File

@ -0,0 +1,67 @@
## Why
AURORA-1746 и замечание заказчика (Циликов А.В., 23.10.2026, строка 10 чек-листа, AURORA-1865): «при загрузке ОСВ без
ИНН необходимо сопоставить все организации без ИНН… нужно, чтобы система несопоставленные организации считала внешними и
позволяла формировать консолидацию без прокликивания каждой».
Расчёт уже так работает: несопоставленный контрагент — «Внешний» (§3 п. 5), переход к шахматке не блокируется (решение
заказчика 16.10.2026, AURORA-1388). Ощущение обязательности создают баннер над сводом «Для перехода к следующему шагу
сопоставьте все внешние организации без ИНН» и модалка [31-00], которая всплывает при каждом уходе с «ОСВ», пока
`unmapped_total > 0`. Счётчик до нуля на реальных данных не доходит: в него попадают зарплата, подотчётные лица и прочие
строки без организации.
Для шахматки важны не все строки без ИНН, а только те, где прячется участник периметра. Признак этого — пара участников,
где у одного есть записи с другим, а у другого встречных записей нет. Такие пары и нужно показать аналитику перед
шахматкой, с местом для разбора (решение владельца 27.10.2026).
## What Changes
Репозиторий — arm-core (`api/`, `web/`). Срез 4 (сводная ОСВ), доработка. Правило — `05-domain-rules.md` §3 п. 9
(отдельный PR в docs).
- **Ядро.** Чистая функция «пары без встречных записей» в `domain/consolidated_osv.py`: для участников периметра А ≠ Б
пара (А, Б) — у А есть строка свода с `counterparty_id = Б`, у Б нет строки с `counterparty_id = А`. `GET …/osv/summary`
отдаёт список `counter_pairs[]`; `GET …/osv` и экспорт получают фильтр `counterparty_id`. Ничего не хранится.
- **Фронт, режим «Встречные записи».** Над сводом — переключатель «Свод | Встречные записи (N)». Во втором режиме: сверху
список пар, под ним две таблицы на всю ширину: строки А с контрагентом Б и строки Б без ИНН и без сопоставления с «Указать» (та же
модалка сопоставления, что в своде).
- **Фронт, модалка перед шахматкой.** Вместо [31-00] «Остались неуказанные ИНН» — «Нет встречных записей: N пар у M
участников», кнопки «Показать пары» / «Остаться» / «Перейти». Показывается при уходе с «ОСВ», если пары есть, один раз
на версию за сессию браузера и снова — если список пар изменился. Строки без ИНН сами по себе модалку не вызывают.
- **Фронт, баннер.** Текст без «сопоставьте все»: несопоставленные учитываются как внешние; счётчик «Осталось: N» и
кнопка «Показать ЮЛ без ИНН» остаются.
## Non-goals
- Подсветка строки-кандидата у Б по совпадению суммы с зеркальной суммой у А (правила элиминации) — отдельная задача.
- Хранение решения аналитика «встречных записей нет, это пропуск в учёте» — в БД ничего не пишется (решение владельца
27.10.2026); DBML не меняется.
- Отсев строк, не являющихся организациями (зарплата, подотчётники), из счётчика `unmapped_total`.
- Сверка по счетам и суммам внутри пары — это делает шахматка (§6).
- Поиск участника, ошибочно сопоставленного с другим ЮЛ: во второй (нижней) таблице показываются только несопоставленные строки без ИНН.
- Строки Б с ИНН из файла, которого нет в справочнике (старый ИНН, опечатка): по решению 08.10.2026 они не требуют
сопоставления, во вторую (нижнюю) таблицу не попадают, и такая пара из режима не закрывается. Оставлено как есть (решение
владельца 27.10.2026), разбор — отдельная задача Jira; модалка и пустое состояние называют эту причину.
## Capabilities
### New Capabilities
### Modified Capabilities
- `consolidated-osv`: ADDED «Пары участников без встречных записей»; MODIFIED «Страница свода и сводка» (фильтр
`counterparty_id`, `counter_pairs[]` в сводке).
- `consolidated-osv-ui`: ADDED «Режим «Встречные записи»», ADDED «Баннер строк без ИНН».
- `consolidations-ui`: REMOVED «Предупреждение «Остались неуказанные ИНН»»; ADDED «Предупреждение «Нет встречных
записей»».
## Impact
- Таблицы DBML v5.23 — только чтение: `consolidation_member`, `osv_entry`, `legal_entity`, `consolidation_document`.
- `api/src/domain/consolidated_osv.py`, `api/src/services/consolidated_osv.py`, `api/src/schemas/consolidations.py`,
`api/src/api/endpoints/consolidations/osv.py` (+ export), тесты `api/tests/`.
- `web/src/widgets/ConsolidatedOsvContent/`, `web/src/widgets/ConsolidatedOsv/` (`OsvTabs.tsx`, новый режим),
`web/src/layouts/ConsolidationReportPage.tsx`, `web/src/utils/consolidations.ts`, `web/src/services/api/osv.ts`,
`web/src/types/api.ts`.
- Документация: `../docs/context/05-domain-rules.md` §3 п. 9, `../docs/context/10-user-journey.md` (строка 3.1),
`../docs/dev/api-contract-slice-4.md` — отдельный PR в docs.

View File

@ -0,0 +1,74 @@
## ADDED Requirements
### Requirement: Баннер строк без ИНН
Пока в сводке `unmapped_total > 0`, над таблицей свода SHALL показываться баннер (стиль `osv-mapping-alert` макета [30]):
заголовок «Есть организации без ИНН и без сопоставления», текст «Осталось: N. Несопоставленные учитываются как внешние;
при необходимости укажите организацию кнопкой «Указать».» и кнопка «Показать ЮЛ без ИНН» / «Показать все строки»
(фильтр `unmapped`, как прежде). Баннер MUST NOT требовать сопоставить все строки для перехода к следующему шагу:
переход не блокируется (решение заказчика 16.10.2026, AURORA-1388). При `unmapped_total = 0` баннера нет.
#### Scenario: Есть строки без ИНН
- **WHEN** открыт свод версии РБ 2024 Q3 до сопоставления (`unmapped_total` = 1260)
- **THEN** баннер «Есть организации без ИНН и без сопоставления», «Осталось: 1260. Несопоставленные учитываются как
внешние…»; слов «Для перехода к следующему шагу сопоставьте все» на экране нет
### Requirement: Режим «Встречные записи»
Над сводом на вкладке «ОСВ» SHALL быть переключатель режимов «Свод | Встречные записи (N)», N — число пар
`summary.counter_pairs`. Режим — параметр адреса `?view=pairs` (без параметра — «Свод»); переключение режима MUST NOT
добавлять запись в историю браузера. Шапка свода (единицы, знаки после запятой) действует в обоих режимах.
В режиме «Встречные записи» экран SHALL показывать сверху список пар, под ним на всю ширину две таблицы одна под другой:
- **сверху** — список пар одной строкой «таблеток» с переносом, в порядке ответа, строка пары «Б ← А» (имена `missing_name` и `holder_name`) с числами
`holder_rows` и `missing_unmapped_rows`; выбранная пара подсвечена; при входе выбрана первая пара либо пара из адреса
(`?view=pairs&holder=<id>&missing=<id>`);
- **первая таблица** — «У <А> есть записи с <Б>»: строки `GET …/osv?legal_entity_id=А&counterparty_id=Б` — счёт, контрагент,
обороты Дт/Кт, сальдо на конец Дт/Кт;
- **вторая таблица** — «У <Б> — строки без ИНН и без сопоставления»: строки `GET …/osv?legal_entity_id=Б&unmapped=true` — счёт,
контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт и «Указать» в каждой строке; над таблицей — поиск по контрагенту
(`q`, ≤ 500 символов, как фильтр свода); постранично, как таблица свода. Все четыре колонки
сумм MUST быть видны без горизонтальной прокрутки при ширине окна 1440 px (на узком окне допустима прокрутка таблицы). Если
`missing_rows_total = 0` — вместо таблицы текст «У <Б> в своде нет строк: ОСВ не привязаны или по ним пробелы»; если
строки у Б есть, а без ИНН и без сопоставления нет (`missing_unmapped_rows = 0`) — текст «Строк без ИНН нет: возможно,
ИНН контрагента из файла не найден в справочнике или это пропуск в учёте».
Обе таблицы и список пар MUST оставаться внутри белой карточки экрана: тело режима прокручивается по вертикали внутри
карточки, а не выходит за её границу.
Пока сводки нет, вместо тела режима показывается индикатор загрузки, а подпись переключателя — «Встречные записи» без «(N)».
Если запрос сводки не удался, показывается ошибка с кнопкой «Повторить», повторяющей запрос.
«Указать» SHALL открывать модалку сопоставления из требования «Ручное сопоставление контрагента» с теми же ответами и
ошибками, но список кандидатов сразу сужен до участника пары А: одна строка под заголовком «Участник пары» с «Связать»
(кандидаты запрашиваются с `scope=group` и `q` = имя А), поле поиска и «Искать во всём справочнике» скрыты; флажок
«Показать все организации» возвращает обычный список модалки (решение владельца 27.10.2026). Если А среди кандидатов не
нашёлся — текст «Участник пары не найден среди кандидатов — включите «Показать все организации»». В обычном своде
«Указать» не меняется. После «Связать» экран SHALL перечитывать сводку и обе таблицы. Если выбранная пара исчезла из списка, выбирается
первая оставшаяся; пустой список — текст «Все участники периметра видят друг друга» (переключатель — «Встречные записи (0)»).
#### Scenario: Пара Русского бисквита
- **WHEN** открыт свод версии РБ 2024 Q3 до сопоставления и выбран режим «Встречные записи»
- **THEN** переключатель «Встречные записи (1)»; сверху пара «Русский бисквит ← Пекарня Счастливочная»; в первой таблице строки
Счастливочной с контрагентом «Русский бисквит»; во второй таблице строки РБ без ИНН, среди них «Пекарня Счастливочная ООО» с
«Указать»
#### Scenario: Сопоставление из режима закрывает пару
- **WHEN** во второй таблице у «Пекарня Счастливочная ООО» нажато «Указать» и выбрана ООО «Пекарня Счастливочная»
- **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель —
«Встречные записи (0)»
#### Scenario: Модалка сопоставления сужена до участника пары
- **WHEN** в паре «Русский бисквит ← Пекарня Счастливочная» во второй таблице нажато «Указать»
- **THEN** в модалке одна строка — ООО «Пекарня Счастливочная» под заголовком «Участник пары» с «Связать»; после
«Показать все организации» — поиск и обычный список кандидатов
#### Scenario: Сводка ещё не получена
- **WHEN** открыт режим «Встречные записи», запрос сводки в пути
- **THEN** показан индикатор загрузки, подпись переключателя — «Встречные записи» без числа
#### Scenario: Ошибка запроса сводки
- **WHEN** запрос сводки в режиме «Встречные записи» завершился ошибкой
- **THEN** показан Alert с текстом ошибки и кнопкой «Повторить»; нажатие повторяет запрос
#### Scenario: Режим из адреса
- **WHEN** открыт адрес `…/versions/{id}/osv?view=pairs`
- **THEN** показан режим «Встречные записи» с первой парой; кнопка «Назад» браузера не проходит по переключениям режимов

View File

@ -0,0 +1,75 @@
## ADDED Requirements
### Requirement: Пары участников без встречных записей
Ядро SHALL вычислять по своду версии пары участников без встречных записей (§3 п. 9 `05-domain-rules.md`, AURORA-1746):
для участников периметра А ≠ Б (`consolidation_member` версии) пара (А, Б) входит в список, если в своде есть хотя бы одна
строка ЮЛ А с `counterparty_id = Б` (сохранённым или дорезолвленным на чтение) и нет ни одной строки ЮЛ Б с
`counterparty_id = А`. А — сторона, у которой записи есть (`holder`), Б — сторона без встречных записей (`missing`).
Пара, где записей нет ни у А, ни у Б, в список MUST NOT входить. Строки ЮЛ с контрагентом — самим собой не
учитываются. Счета, суммы и тип строки (`data_type`) на признак не влияют: учитываются все строки свода. Расчёт —
чистая функция в `domain/` от строк свода и периметра; результат MUST NOT сохраняться. Порядок списка — по имени Б,
затем по имени А.
У каждой пары SHALL быть: `holder_id`, `holder_name`, `missing_id`, `missing_name`, `holder_rows` (число строк А с
контрагентом Б), `missing_unmapped_rows` (число строк Б с `unmapped = true`), `missing_rows_total` (число всех строк Б в
своде; 0 — у Б нет строк, например не привязано ни одной ОСВ или все её пары счетов — пробелы).
#### Scenario: Русский бисквит до сопоставления
- **WHEN** версия ГК «Русский бисквит» 2024 Q3 с периметром {АО «Русский бисквит», ООО «Пекарня Счастливочная»}, 22 ОСВ
из `fixtures/osv/{rb,schastl}/2024-Q{1,2,3}` подтверждены; у Счастливочной контрагент «Русский бисквит» с ИНН
3528052718 сопоставлен по ИНН; у РБ контрагент «Пекарня Счастливочная ООО» без ИНН не сопоставлен
- **THEN** список содержит одну пару: `holder` — ООО «Пекарня Счастливочная», `missing` — АО «Русский бисквит»,
`holder_rows` ≥ 1, `missing_unmapped_rows` ≥ 1
#### Scenario: Пара закрыта ручным сопоставлением
- **WHEN** в той же версии строка РБ «Пекарня Счастливочная ООО» связана с ООО «Пекарня Счастливочная»
(`POST …/osv/entries/{entry_id}/counterparty`)
- **THEN** список пар пуст
#### Scenario: Компании не связаны
- **WHEN** в периметре три участника; у А и Б нет записей друг с другом ни в одну сторону
- **THEN** пары (А, Б) и (Б, А) в списке нет
#### Scenario: У стороны нет строк
- **WHEN** у А есть строки с контрагентом Б, а у Б в своде нет ни одной строки (ОСВ не привязаны)
- **THEN** пара (А, Б) в списке, `missing_rows_total = 0`, `missing_unmapped_rows = 0`
## MODIFIED Requirements
### Requirement: Страница свода и сводка
`GET …/osv` (`CONSOLIDATION_VIEW`, policy) SHALL отдавать `Page` строк свода в порядке: имя ЮЛ, `account_code`,
`row_number`, первый `entry_id`; `limit` ≤ 2000 (по умолчанию 500). Фильтры: `legal_entity_id`, `account_root`,
`counterparty_type`, `counterparty_id` (строка с этим `counterparty_id` после дорезолва; AURORA-1746), `unmapped=true`
(строка требует сопоставления: `counterparty_id` пуст после дорезолва **и** `counterparty_inn` пуст или из пробелов;
строка с ИНН из файла, которого нет в справочнике, сопоставления не требует — решение владельца 08.10.2026, отмена П-1;
`unmapped=false` — все остальные строки), `match_kind` (повторяемый), `q` (подстрока без регистра в `counterparty_name`
или префикс `counterparty_inn`). Те же фильтры SHALL принимать `GET …/osv/export`. `GET …/osv/summary` SHALL отдавать
`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`) и `counter_pairs[]` — пары участников без встречных записей
(требование «Пары участников без встречных записей»). Версия в любом статусе, включая `CANCELLED`, SHALL читаться.
#### Scenario: Только несопоставленные
- **WHEN** запрошено `GET …/osv?unmapped=true`
- **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`
- **THEN** в ответе только строки с этими `match_kind`, `total` = `summary.auto_matched_total`
#### Scenario: Строки одной стороны пары
- **WHEN** в `summary.counter_pairs` есть пара `holder_id = А`, `missing_id = Б` и запрошено
`GET …/osv?legal_entity_id=А&counterparty_id=Б`
- **THEN** `total` = `holder_rows` пары, у всех строк ответа ЮЛ А и `counterparty_id = Б`
#### Scenario: Кандидаты другой стороны пары
- **WHEN** для той же пары запрошено `GET …/osv?legal_entity_id=Б&unmapped=true`
- **THEN** `total` = `missing_unmapped_rows` пары

View File

@ -0,0 +1,49 @@
## REMOVED Requirements
### Requirement: Предупреждение «Остались неуказанные ИНН»
**Reason**: модалка всплывала при каждом уходе с «ОСВ», пока `unmapped_total > 0`, а счётчик на реальных данных до нуля
не доходит (зарплата, подотчётные лица); заказчик воспринимал её как требование сопоставить всё (AURORA-1746, чек-лист
Циликова 23.10.2026). Для шахматки важны только пары участников без встречных записей.
**Migration**: заменено требованием «Предупреждение «Нет встречных записей»».
## ADDED Requirements
### Requirement: Предупреждение «Нет встречных записей»
При уходе со вкладки «ОСВ» на другую вкладку отчёта версии фронт SHALL показывать модалку «Проверьте встречные записи
участников», если в сводке есть пары `counter_pairs` и этот список пар ещё не показывался в модалке для этой версии в
текущей сессии браузера. Текст: «Нет встречных записей: N пар у M участников.» (N — число пар, M — число разных `missing_id`;
слова согласуются с числом: «1 пара у 1 участника», «2 пары у 2 участников», «5 пар у 3 участников»)
и «Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте. Ещё K строк без
ИНН учтены как внешние.» (K —
`unmapped_total`; при K = 0 вторая фраза без последнего предложения). Кнопки:
- «Показать пары» — закрыть модалку без перехода и открыть на вкладке «ОСВ» режим «Встречные записи» с первой парой;
- «Остаться» — закрыть модалку без перехода;
- «Перейти» — выполнить переход.
Показ запоминается в `sessionStorage` браузера по версии вместе со списком пар (упорядоченные пары `holder_id → missing_id`);
повторный уход с «ОСВ» в той же сессии модалку не показывает, пока список пар не изменился (например, сопоставление
закрыло одну из пар — модалка покажет оставшиеся). Недоступное хранилище — модалка показывается при каждом уходе, ошибкой это
не считается. Без пар (`counter_pairs` пуст) переход SHALL выполняться без модалки при любом `unmapped_total`. В БД ничего
не пишется (решение владельца 27.10.2026). Для версии в завершённом статусе (`VERIFIED`, `CANCELLED`, `ARCHIVED`,
`REJECTED`) модалка не показывается. Пока шагов после ОСВ нет, их вкладки неактивны и модалка не вызывается.
#### Scenario: Пара без встречных записей
- **WHEN** в сводке одна пара (Русский бисквит ← Пекарня Счастливочная), `unmapped_total = 1260`, и аналитик выбирает
вкладку «Шахматка»
- **THEN** показана модалка «Нет встречных записей: 1 пара у 1 участника…»; «Перейти» открывает шахматку
#### Scenario: Повтор в той же сессии
- **WHEN** аналитик вернулся на «ОСВ» и снова выбирает «Шахматку», список пар не изменился
- **THEN** шахматка открывается без модалки
#### Scenario: Список пар изменился
- **WHEN** после показа модалки аналитик сопоставил контрагента, закрыв одну из двух пар, и выбирает «Шахматку»
- **THEN** модалка показана снова с «Нет встречных записей: 1 пара у 1 участника…»
#### Scenario: Только строки без ИНН
- **WHEN** `counter_pairs` пуст, `unmapped_total = 214`, и аналитик выбирает «Шахматку»
- **THEN** шахматка открывается без модалки
#### Scenario: Показать пары
- **WHEN** в модалке нажато «Показать пары»
- **THEN** вкладка «ОСВ» остаётся, открыт режим «Встречные записи» с первой парой

View File

@ -30,7 +30,9 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне
| `ConsoleTable/LockModal.tsx` | [35] | «Взять в работу» (`POST …/lock`) и перехват с причиной (`takeover_reason`) |
| `../utils/consolidations.ts` | — | правила без React: подписи и пояснения статусов версии, ручные переходы (`MANUAL_TRANSITIONS` — зеркало бэка), своя блокировка, «Версия», `in_work_only`, причина ≤ 1000 |
| `ConsoleTable/consts.ts` | — | цвета бейджей статусов версии |
| `ConsolidatedOsv/OsvTabs.tsx` | [30], [31-00] | вкладки шагов отчёта и «Остались неуказанные ИНН» при уходе с «ОСВ» |
| `ConsolidatedOsv/OsvTabs.tsx` | [30] | вкладки шагов отчёта и модалка «Проверьте встречные записи участников» при уходе с «ОСВ» (список пар — `counter_pairs` сводки) |
| `ConsolidatedOsv/CounterPairsView.tsx` | [30] | режим «Встречные записи» вкладки «ОСВ»: пары, строки держателя, строки Б без ИНН с «Указать» |
| `ConsolidatedOsv/counterPairs.ts` | — | правила без React: подпись пары, параметры адреса режима (`?view=pairs&holder=&missing=`) |
| `ConsolidatedOsv/useNumberDisplay.tsx` | [30] | «Единицы» и «Знаков после запятой» сводной ОСВ (`formatScaledAmount` из `utils/money.ts`) |
| `../hooks/useBeforeUnload.ts` | — | предупреждение браузера о несохранённых правках в открытой модалке |
| `ConsoleLegalEntities/` | [34] | панель «Организации» выбранной версии и чек-лист |

View File

@ -7,6 +7,7 @@ import PageTitle from '../components/PageTitle';
import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets';
import { getVersionCard } from '../services/api/consolidations';
import type { OsvSummaryOut } from '../types/api';
import type { ConsRussianStatus } from '../types/consolidations/common_types';
import type { VersionCard } from '../types/consolidations/version_members';
import { CONS_STATUS_LABEL, canChangeStatus, consStatusDescription, isLockedBy } from '../utils/consolidations';
@ -20,14 +21,14 @@ import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal';
function ReportContent() {
const [card, setCard] = useState<VersionCard | null>(null);
const [statusModalOpen, setModalOpen] = useState(false);
// unmapped_total сводки ОСВ — для [31-00] при уходе с вкладки «ОСВ»
const [unmappedTotal, setUnmappedTotal] = useState(0);
// сводка ОСВ (пары без встречных записей) — для модалки при уходе с вкладки «ОСВ»
const [osvSummary, setOsvSummary] = useState<OsvSummaryOut | null>(null);
const currentUser = useCurrentUser();
const { version_id, tab } = useParams<{ tab?: string; version_id: string }>();
const navigation = useNavigate();
const { pathname } = useLocation();
const TAB_COMPONENTS: Record<string, ReactNode> = {
osv: <OsvContent card={card} onUnmappedChange={setUnmappedTotal} />,
osv: <OsvContent card={card} onSummaryChange={setOsvSummary} />,
chess: <ChessContent card={card} />,
};
@ -108,7 +109,7 @@ function ReportContent() {
)
}
/>
<OsvTabs unmappedTotal={unmappedTotal} />
<OsvTabs summary={osvSummary} versionId={version_id ?? ''} status={card?.status} />
</div>
{ActiveTab}
{card && <ChangeStatusModal open={statusModalOpen} card={card} onSuccess={setCard} onClose={() => setModalOpen(false)} />}

View File

@ -15,4 +15,7 @@ describe('buildOsvQuery', () => {
it('страница', () => {
expect(buildOsvQuery({ counterparty_type: 'GSK' }, { limit: 500, offset: 1000 })).toBe('?counterparty_type=GSK&limit=500&offset=1000');
});
it('counterparty_id', () => {
expect(buildOsvQuery({ counterparty_id: 'x' })).toBe('?counterparty_id=x');
});
});

View File

@ -15,6 +15,7 @@ export function buildOsvQuery(filters: OsvFilters, page?: { limit: number; offse
if (filters.account_root) params.set('account_root', filters.account_root);
if (filters.counterparty_type) params.set('counterparty_type', filters.counterparty_type);
if (filters.unmapped) params.set('unmapped', 'true');
if (filters.counterparty_id) params.set('counterparty_id', filters.counterparty_id);
const q = filters.q?.trim();
if (q) params.set('q', q);
if (page) {

View File

@ -357,18 +357,30 @@ export interface GapOut {
account_code: string;
missing_quarters: Quarter[];
}
/** Пара участников без встречных записей: у `holder` есть строки с `missing`, у `missing` — нет строк с `holder`. */
export interface CounterPairOut {
holder_id: string;
holder_name: string;
missing_id: string;
missing_name: string;
holder_rows: number;
missing_unmapped_rows: number;
missing_rows_total: number;
}
export interface OsvSummaryOut {
rows_total: number;
unmapped_total: number;
auto_matched_total: number;
documents_total: number;
gaps: GapOut[];
counter_pairs: CounterPairOut[];
}
export interface OsvFilters {
legal_entity_id?: string;
account_root?: string;
counterparty_type?: CounterpartyType;
unmapped?: boolean;
counterparty_id?: string;
q?: string;
}

View File

@ -1,19 +1,26 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { CounterPairOut } from '../types/api';
import type { ConsStatus, LockOut } from '../types/consolidations/common_types';
import {
CONS_STATUS_LABEL,
FINISHED_STATUSES,
MANUAL_TRANSITIONS,
canChangeStatus,
canSubmitStatusChange,
canTakeInWork,
consStatusDescription,
counterPairsKey,
counterPairsWarning,
decideOsvLeave,
endSentence,
isLockedBy,
isValidReason,
manualTransitions,
needsUnmappedWarning,
parseInWorkOnly,
rememberCounterPairsShown,
reportTabNavigation,
resolveLeaveAfterFetch,
shouldWarnCounterPairs,
versionLabel,
} from './consolidations';
@ -127,14 +134,6 @@ describe('endSentence', () => {
});
});
describe('модалка [31-00]', () => {
it('только при несопоставленных строках', () => {
expect(needsUnmappedWarning(2)).toBe(true);
expect(needsUnmappedWarning(0)).toBe(false);
expect(needsUnmappedWarning(undefined)).toBe(false);
});
});
describe('вкладки отчёта (AURORA-1851)', () => {
it('смена вкладки заменяет запись истории: «Назад» ведёт со страницы отчёта, а не по вкладкам', () => {
expect(reportTabNavigation('/groups/g1/versions/v1/osv', 'osv', 'chess')).toEqual({
@ -143,3 +142,127 @@ describe('вкладки отчёта (AURORA-1851)', () => {
});
});
});
const pair = (holder: string, missing: string): CounterPairOut => ({
holder_id: holder,
holder_name: `H${holder}`,
missing_id: missing,
missing_name: `M${missing}`,
holder_rows: 1,
missing_unmapped_rows: 0,
missing_rows_total: 0,
});
describe('counterPairsKey', () => {
it('не зависит от порядка', () => {
expect(counterPairsKey([pair('a', 'b'), pair('c', 'd')])).toBe(counterPairsKey([pair('c', 'd'), pair('a', 'b')]));
expect(counterPairsKey([pair('c', 'd'), pair('a', 'b')])).toBe('a>b,c>d');
});
});
describe('shouldWarnCounterPairs', () => {
const pairs = [pair('a', 'b')];
const withKey = (v: string | null) => ({ getItem: () => v });
it('пусто или завершённый статус — false', () => {
expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', [], withKey(null))).toBe(false);
for (const s of FINISHED_STATUSES) expect(shouldWarnCounterPairs('v', s, pairs, withKey(null))).toBe(false);
expect(shouldWarnCounterPairs('v', 'CANCELLED', pairs, withKey(null))).toBe(false);
});
it('ключ в хранилище', () => {
expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, withKey(null))).toBe(true);
expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, withKey('a>b'))).toBe(false);
expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, withKey('x>y'))).toBe(true);
});
it('null и throwing storage — true', () => {
expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, null)).toBe(true);
const throwing = {
getItem: () => {
throw new Error('denied');
},
};
expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, throwing)).toBe(true);
});
});
describe('rememberCounterPairsShown', () => {
it('пишет ключ', () => {
const setItem = vi.fn();
rememberCounterPairsShown('v1', [pair('a', 'b')], { setItem });
expect(setItem).toHaveBeenCalledWith('armka:counter-pairs:v1', 'a>b');
});
it('глотает исключения и null', () => {
const throwing = {
setItem: () => {
throw new Error('denied');
},
};
expect(() => rememberCounterPairsShown('v1', [pair('a', 'b')], throwing)).not.toThrow();
expect(() => rememberCounterPairsShown('v1', [pair('a', 'b')], null)).not.toThrow();
});
});
describe('counterPairsWarning', () => {
it('одна пара у одного участника', () => {
const w = counterPairsWarning([pair('a', 'b')], 1260);
expect(w.headline).toBe('Нет встречных записей: 1 пара у 1 участника.');
expect(w.details.endsWith('Ещё 1260 строк без ИНН учтены как внешние.')).toBe(true);
});
it('пять пар у трёх участников (M — разные missing_id)', () => {
const ps = [pair('a', 'c'), pair('a', 'd'), pair('a', 'e'), pair('b', 'c'), pair('b', 'd')];
expect(counterPairsWarning(ps, 5).headline).toBe('Нет встречных записей: 5 пар у 3 участников.');
});
it('M считает missing_id, а не holder_id', () => {
const ps = [pair('a', 'b'), pair('a', 'c'), pair('d', 'b'), pair('e', 'b'), pair('f', 'b')];
expect(counterPairsWarning(ps, 5).headline).toBe('Нет встречных записей: 5 пар у 2 участников.');
});
it('две пары у двух участников', () => {
expect(counterPairsWarning([pair('a', 'b'), pair('a', 'c')], 1).headline).toBe('Нет встречных записей: 2 пары у 2 участников.');
});
it('без строк без ИНН', () => {
expect(counterPairsWarning([pair('a', 'b')], 0).details).toBe(
'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.',
);
});
});
describe('decideOsvLeave (T4.2)', () => {
const pairs = [{ holder_id: 'a', missing_id: 'b' } as CounterPairOut];
const noStorage = { getItem: () => null };
const base = { versionId: 'v', status: 'CONSOLIDATION_RF' as ConsStatus, storage: noStorage };
it('сводки нет и версия открыта — запросить', () => {
expect(decideOsvLeave({ ...base, summary: null })).toBe('perform');
});
it('версия закрыта — переход без запроса', () => {
expect(decideOsvLeave({ ...base, status: 'VERIFIED', summary: null })).toBe('go');
});
it('сводка есть, пары есть и не показывали — предупредить', () => {
expect(decideOsvLeave({ ...base, summary: { counter_pairs: pairs } })).toBe('warn');
});
it('сводка есть, пар нет — перейти', () => {
expect(decideOsvLeave({ ...base, summary: { counter_pairs: [] } })).toBe('go');
});
it('список уже показывали — перейти', () => {
const shown = { getItem: () => 'a>b' };
expect(decideOsvLeave({ ...base, storage: shown, summary: { counter_pairs: pairs } })).toBe('go');
});
});
describe('resolveLeaveAfterFetch (T7.3)', () => {
const pairs = [{ holder_id: 'a', missing_id: 'b' } as CounterPairOut];
const args = { versionId: 'v', status: 'CONSOLIDATION_RF' as ConsStatus, storage: { getItem: () => null } };
it('успех с парами — warn', () => {
expect(resolveLeaveAfterFetch({ ...args, summary: { counter_pairs: pairs } })).toBe('warn');
});
it('успех без пар — go', () => {
expect(resolveLeaveAfterFetch({ ...args, summary: { counter_pairs: [] } })).toBe('go');
});
it('ошибка запроса (null) — go', () => {
expect(resolveLeaveAfterFetch({ ...args, summary: null })).toBe('go');
});
});
describe('reportTabNavigation: адрес без search (T7.4)', () => {
it('?holder=&missing= не переносятся на другую вкладку', () => {
expect(reportTabNavigation('/groups/g/versions/v/osv', 'osv', 'chess').to).toBe('/groups/g/versions/v/chess');
});
});

View File

@ -1,6 +1,8 @@
// Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные
// переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API.
import type { CounterPairOut, OsvSummaryOut } from '../types/api';
import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types';
import { pluralRu } from './labels';
/** Подписи статусов версии — одни для списка, фильтра «Статус», шапки карточки и модалки «Изменить статус». */
export const CONS_STATUS_LABEL: Record<ConsStatus, ConsRussianStatus> = {
@ -89,9 +91,6 @@ export const endSentence = (text: string): string => (text.endsWith('.') ? text
/** «+ Создать» в модалке «Новая консолидация»: в консолидации не меньше двух ЮЛ (иначе 422 `PERIMETER_TOO_SMALL`). */
export const MIN_PERIMETER = 2;
/** Модалка [31-00] нужна при уходе со вкладки «ОСВ», пока есть несопоставленные строки. */
export const needsUnmappedWarning = (unmappedTotal: number | null | undefined): boolean => (unmappedTotal ?? 0) > 0;
/**
* Переход на другую вкладку отчёта (`/versions/:version_id/:tab`): сегмент вкладки заменяется, запись истории — тоже,
* чтобы «Назад» вёл со страницы отчёта, а не по переключениям вкладок (AURORA-1851, как переключатель документа в AURORA-1692).
@ -100,3 +99,85 @@ export const reportTabNavigation = (pathname: string, activeKey: string, newKey:
to: pathname.replace(new RegExp(`/${activeKey}/?$`), `/${newKey}`),
options: { replace: true },
});
/** Статусы, в которых версия закрыта для правок: модалку о парах без встречных записей не показываем. */
export const FINISHED_STATUSES: readonly ConsStatus[] = ['VERIFIED', 'CANCELLED', 'ARCHIVED', 'REJECTED'];
/** Префикс ключа `sessionStorage`: `armka:counter-pairs:<version_id>`, значение — `counterPairsKey`. */
export const COUNTER_PAIRS_STORAGE_PREFIX = 'armka:counter-pairs:';
/** Ключ списка пар — не зависит от порядка: `holder>missing`, отсортированные, через запятую. */
export const counterPairsKey = (pairs: readonly CounterPairOut[]): string =>
pairs
.map((p) => `${p.holder_id}>${p.missing_id}`)
.sort()
.join(',');
/**
* Показывать ли модалку о парах без встречных записей (`05-domain-rules.md` §3 п. 9): есть пары, версия не закрыта и
* этот же список пар в сессии ещё не показывали. Хранилище недоступно или бросает — показываем.
*/
export const shouldWarnCounterPairs = (
versionId: string,
status: ConsStatus | undefined,
pairs: readonly CounterPairOut[],
storage: Pick<Storage, 'getItem'> | null,
): boolean => {
if (pairs.length === 0) return false;
if (status && FINISHED_STATUSES.includes(status)) return false;
try {
return storage?.getItem(`${COUNTER_PAIRS_STORAGE_PREFIX}${versionId}`) !== counterPairsKey(pairs);
} catch {
return true;
}
};
/** Запоминает показанный список пар; ошибки хранилища (приватный режим) глотаем. */
export const rememberCounterPairsShown = (
versionId: string,
pairs: readonly CounterPairOut[],
storage: Pick<Storage, 'setItem'> | null,
): void => {
try {
storage?.setItem(`${COUNTER_PAIRS_STORAGE_PREFIX}${versionId}`, counterPairsKey(pairs));
} catch {
// хранилище недоступно — модалка просто покажется снова
}
};
/** Тексты модалки «Проверьте встречные записи участников». */
export const counterPairsWarning = (pairs: readonly CounterPairOut[], unmappedTotal: number): { headline: string; details: string } => {
const missing = new Set(pairs.map((p) => p.missing_id)).size;
const base = 'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.';
return {
headline: `Нет встречных записей: ${pairs.length} ${pluralRu(pairs.length, ['пара', 'пары', 'пар'])} у ${missing} ${pluralRu(missing, ['участника', 'участников', 'участников'])}.`,
details: unmappedTotal > 0 ? `${base} Ещё ${unmappedTotal} строк без ИНН учтены как внешние.` : base,
};
};
/** Решение при уходе с вкладки «ОСВ»: `perform` — сводки ещё нет, запросить; `warn` — показать модалку; `go` — перейти. */
export type OsvLeaveDecision = 'perform' | 'warn' | 'go';
export const decideOsvLeave = (args: {
summary: Pick<OsvSummaryOut, 'counter_pairs'> | null;
versionId: string;
status: ConsStatus | undefined;
storage: Pick<Storage, 'getItem'> | null;
}): OsvLeaveDecision => {
const { summary, versionId, status, storage } = args;
// закрытой версии модалка не нужна — и запрашивать нечего
if (status && FINISHED_STATUSES.includes(status)) return 'go';
if (summary === null) return 'perform';
return shouldWarnCounterPairs(versionId, status, summary.counter_pairs, storage) ? 'warn' : 'go';
};
/** Решение после догрузки сводки при уходе с «ОСВ»: `summary === null` — запрос не удался, не держим пользователя (`go`). */
export const resolveLeaveAfterFetch = (args: {
summary: Pick<OsvSummaryOut, 'counter_pairs'> | null;
versionId: string;
status: ConsStatus | undefined;
storage: Pick<Storage, 'getItem'> | null;
}): 'warn' | 'go' => {
if (args.summary === null) return 'go';
return decideOsvLeave(args) === 'warn' ? 'warn' : 'go';
};

View File

@ -0,0 +1,252 @@
// Режим «Встречные записи» вкладки «ОСВ»: сверху пары «таблетками», под ними на всю ширину две таблицы —
// строки держателя с Б и строки Б без ИНН и сопоставления.
// Правило: 05-domain-rules.md §3 п. 9.
import { Alert, Button, Input, Table } from 'antd';
import type { TableColumnsType } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react';
import { getConsolidatedOsv } from '../../services/api/osv';
import type { ConsolidatedRowOut, CounterPairOut, OsvFilters } from '../../types/api';
import { CounterpartyMapModal } from './CounterpartyMapModal';
import { group, money } from './OsvTable';
import { clampPage, missingSideEmptyText, pairLabel, syncPageState } from './counterPairs';
import { mapEntryId } from './counterpartyMapping';
import { OSV_PAGE_SIZE, osvErrorText } from './useConsolidatedOsv';
type Props = {
versionId: string;
pairs: readonly CounterPairOut[];
/** Выбранная пара; `null` — пар нет. */
selected: CounterPairOut | null;
numFormat: (raw: string | null) => string;
onSelect: (holder: string, missing: string) => void;
/** После сопоставления: перечитать сводку (список пар) наверху. */
onMapped: () => void;
};
const MAX_QUERY = 500;
const SEARCH_DELAY_MS = 300;
const MUTED = '#667085';
const PANEL: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0 };
const PANEL_TITLE: React.CSSProperties = { fontWeight: 700, fontSize: 13, color: '#344054' };
/** Страница строк свода по фильтру; ответы устаревших запросов игнорируются. */
function usePagedRows(versionId: string, filters: OsvFilters | null, reloadKey: number) {
const [rows, setRows] = useState<ConsolidatedRowOut[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestRef = useRef(0);
const key = JSON.stringify(filters);
// ключ и страница — одно состояние: смена фильтра даёт первую страницу и один запрос, а не два
const [rawPageState, setPageState] = useState({ key, page: 1 });
const pageState = syncPageState(rawPageState, key);
// ключ сменился — состояние сразу пересоздаётся (возврат к прежнему ключу не вернёт старую страницу)
if (pageState !== rawPageState) setPageState(pageState);
const page = pageState.page;
const setPage = useCallback((p: number) => setPageState({ key, page: p }), [key]);
// biome-ignore lint/correctness/useExhaustiveDependencies: key — сериализованный filters; reloadKey — перечитать после сопоставления
useEffect(() => {
const requestId = ++requestRef.current;
if (!filters) {
setRows([]);
setTotal(0);
setLoading(false);
return;
}
setLoading(true);
getConsolidatedOsv(versionId, filters, OSV_PAGE_SIZE, (page - 1) * OSV_PAGE_SIZE)
.then((r) => {
if (requestId !== requestRef.current) return;
// страница ушла за конец (последнюю строку сопоставили) — сразу на последнюю существующую
if (r.items.length === 0 && page > 1) {
setPage(clampPage(page, r.total, OSV_PAGE_SIZE));
return;
}
setRows(r.items);
setTotal(r.total);
setError(null);
})
.catch((e: unknown) => {
if (requestId !== requestRef.current) return;
setRows([]);
setTotal(0);
setError(osvErrorText(e));
})
.finally(() => {
if (requestId === requestRef.current) setLoading(false);
});
}, [versionId, key, page, reloadKey]);
return { rows, total, page, setPage, loading, error };
}
export function CounterPairsView({ versionId, pairs, selected, numFormat, onSelect, onMapped }: Props) {
const [q, setQ] = useState('');
const [debouncedQ, setDebouncedQ] = useState('');
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q.trim()), SEARCH_DELAY_MS);
return () => clearTimeout(t);
}, [q]);
// другая пара — поиск начинается заново
const selectedKey = selected ? `${selected.holder_id}|${selected.missing_id}` : '';
// biome-ignore lint/correctness/useExhaustiveDependencies: сброс при смене пары
useEffect(() => {
setQ('');
setDebouncedQ('');
setMapping(null);
}, [selectedKey]);
const centerFilters: OsvFilters | null = selected ? { legal_entity_id: selected.holder_id, counterparty_id: selected.missing_id } : null;
// нижнюю таблицу не запрашиваем, если у Б нет строк или нет строк без ИНН: показываем текст
const rightHasRows = !!selected && missingSideEmptyText(selected) === null;
const rightFilters: OsvFilters | null =
selected && rightHasRows ? { legal_entity_id: selected.missing_id, unmapped: true, ...(debouncedQ ? { q: debouncedQ } : {}) } : null;
const center = usePagedRows(versionId, centerFilters, reloadKey);
const right = usePagedRows(versionId, rightFilters, reloadKey);
const baseColumns: TableColumnsType<ConsolidatedRowOut> = [
{ key: 'account_code', title: 'Счёт', dataIndex: 'account_code', width: 90 },
{
key: 'counterparty',
title: 'Контрагент',
ellipsis: true,
render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—',
},
group('Обороты за период', [money('turnover_debit', 'Дебет', numFormat), money('turnover_credit', 'Кредит', numFormat)]),
group('Сальдо на конец периода', [money('closing_debit', 'Дебет', numFormat), money('closing_credit', 'Кредит', numFormat)]),
];
const rightColumns: TableColumnsType<ConsolidatedRowOut> = [
...baseColumns,
{
key: 'map',
title: 'ИНН',
align: 'center',
width: 100,
render: (_: unknown, r) => (
<Button type='link' style={{ padding: 0, height: 'auto', color: '#b42318', fontWeight: 700 }} onClick={() => setMapping(r)}>
Указать
</Button>
),
},
];
const mapped = useCallback(() => {
setMapping(null);
setReloadKey((k) => k + 1);
onMapped();
}, [onMapped]);
if (!selected) {
return <div style={{ padding: 32, textAlign: 'center', color: MUTED, fontSize: 14 }}>Все участники периметра видят друг друга</div>;
}
const rightEmpty = missingSideEmptyText(selected);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: '1 1 auto', minWidth: 0, minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{pairs.map((p) => {
const active = p.holder_id === selected.holder_id && p.missing_id === selected.missing_id;
return (
<button
type='button'
key={`${p.holder_id}|${p.missing_id}`}
onClick={() => onSelect(p.holder_id, p.missing_id)}
style={{
textAlign: 'left',
maxWidth: 360,
cursor: 'pointer',
padding: '8px 10px',
borderRadius: 10,
border: `1px solid ${active ? '#86d19a' : '#e4e7ec'}`,
background: active ? '#eefbf1' : '#fff',
color: '#101828',
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{pairLabel(p)}</div>
<div style={{ fontSize: 11, color: MUTED }}>
записей: {p.holder_rows} · без ИНН у {p.missing_name}: {p.missing_unmapped_rows}
</div>
</button>
);
})}
</div>
<div style={PANEL}>
<div style={PANEL_TITLE}>
У {selected.holder_name} есть записи с {selected.missing_name}
</div>
{center.error && <Alert type='error' showIcon title={center.error} />}
<Table<ConsolidatedRowOut>
bordered
size='small'
rowKey={(r) => r.entry_ids.join(',')}
loading={center.loading}
columns={baseColumns}
dataSource={center.rows}
tableLayout='fixed'
scroll={{ x: 750 }}
pagination={{
current: center.page,
pageSize: OSV_PAGE_SIZE,
total: center.total,
showSizeChanger: false,
onChange: center.setPage,
hideOnSinglePage: true,
showTotal: (t) => `Строк: ${t}`,
}}
/>
</div>
<div style={PANEL}>
<div style={PANEL_TITLE}>У {selected.missing_name} — строки без ИНН и без сопоставления</div>
{rightEmpty ? (
<div style={{ padding: 16, color: MUTED, fontSize: 13, background: '#f9fafb', borderRadius: 10 }}>{rightEmpty}</div>
) : (
<>
<Input.Search
allowClear
placeholder='Поиск по названию контрагента'
maxLength={MAX_QUERY}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
{right.error && <Alert type='error' showIcon title={right.error} />}
<Table<ConsolidatedRowOut>
bordered
size='small'
rowKey={(r) => r.entry_ids.join(',')}
loading={right.loading}
columns={rightColumns}
dataSource={right.rows}
tableLayout='fixed'
scroll={{ x: 850 }}
locale={{ emptyText: debouncedQ ? 'По поиску строк нет' : 'Строк нет' }}
pagination={{
current: right.page,
pageSize: OSV_PAGE_SIZE,
total: right.total,
showSizeChanger: false,
onChange: right.setPage,
hideOnSinglePage: true,
showTotal: (t) => `Строк: ${t}`,
}}
/>
</>
)}
</div>
{mapping && (
<CounterpartyMapModal
key={mapEntryId(mapping)}
versionId={versionId}
row={mapping}
onClose={() => setMapping(null)}
onMapped={mapped}
pairTarget={selected ? { id: selected.holder_id, name: selected.holder_name } : undefined}
/>
)}
</div>
);
}

View File

@ -9,6 +9,7 @@ import type { CandidateOut, CandidateScope, ConsolidatedRowOut, RefOut } from '.
import {
ALL_SCOPE_MIN_QUERY,
type CandidateRow,
type PairTarget,
aliasOwners,
buildCandidatesQuery,
candidateRows,
@ -16,6 +17,7 @@ import {
mapEntryId,
mapErrorKind,
mapResultText,
pairCandidateRows,
} from './counterpartyMapping';
const SEARCH_DELAY_MS = 300;
@ -55,12 +57,16 @@ type Props = {
onClose: () => void;
/** Сопоставление записано: перечитать строки и сводку. */
onMapped: () => void;
/** Режим «Встречные записи»: список сразу сужен до участника пары, «Показать все организации» — обычный список. */
pairTarget?: PairTarget;
};
/** Модалка «Выбор организации для связи с …» [32-osv-map-counterparty]: кандидаты, поиск и «Связать». */
export function CounterpartyMapModal({ versionId, row, onClose, onMapped }: Props) {
export function CounterpartyMapModal({ versionId, row, onClose, onMapped, pairTarget }: Props) {
const [q, setQ] = useState('');
const [scope, setScope] = useState<CandidateScope>('group');
const [showAll, setShowAll] = useState(false);
const pairOnly = !!pairTarget && !showAll;
const [candidates, setCandidates] = useState<CandidateOut[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -70,7 +76,8 @@ export function CounterpartyMapModal({ versionId, row, onClose, onMapped }: Prop
const requestRef = useRef(0);
const entryId = mapEntryId(row);
const sourceName = row.counterparty_name?.trim() || '—';
const query = buildCandidatesQuery(entryId, scope, q);
// участник пары ищется по имени в группе: в первые 100 кандидатов без поиска он может не попасть
const query = pairOnly && pairTarget ? buildCandidatesQuery(entryId, 'group', pairTarget.name) : buildCandidatesQuery(entryId, scope, q);
useEffect(() => {
const requestId = ++requestRef.current;
@ -211,10 +218,11 @@ export function CounterpartyMapModal({ versionId, row, onClose, onMapped }: Prop
},
];
const tooShort = scope === 'all' && query === null;
const emptyText = tooShort
? `Для поиска по всему справочнику введите не меньше ${ALL_SCOPE_MIN_QUERY} символов`
: 'Организации не найдены';
const tooShort = !pairOnly && scope === 'all' && query === null;
let emptyText = 'Организации не найдены';
if (pairOnly) emptyText = 'Участник пары не найден среди кандидатов — включите «Показать все организации»';
else if (tooShort) emptyText = `Для поиска по всему справочнику введите не меньше ${ALL_SCOPE_MIN_QUERY} символов`;
const rows = pairOnly && pairTarget ? pairCandidateRows(candidates, pairTarget) : candidateRows(candidates);
return (
<Modal
open
@ -228,26 +236,38 @@ export function CounterpartyMapModal({ versionId, row, onClose, onMapped }: Prop
}>
{contextHolder}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Input
allowClear
autoFocus
size='large'
prefix={<SearchOutlined style={{ color: '#667085' }} />}
placeholder='Поиск'
maxLength={500}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<Checkbox checked={scope === 'all'} onChange={(e) => setScope(e.target.checked ? 'all' : 'group')}>
Искать во всём справочнике (не только в группе)
</Checkbox>
{!pairOnly && (
<Input
allowClear
autoFocus
size='large'
prefix={<SearchOutlined style={{ color: '#667085' }} />}
placeholder='Поиск'
maxLength={500}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
)}
{/* флажки — одной строкой */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px 24px' }}>
{pairTarget && (
<Checkbox checked={showAll} onChange={(e) => setShowAll(e.target.checked)}>
Показать все организации
</Checkbox>
)}
{!pairOnly && (
<Checkbox checked={scope === 'all'} onChange={(e) => setScope(e.target.checked ? 'all' : 'group')}>
Искать во всём справочнике (не только в группе)
</Checkbox>
)}
</div>
{error && <div style={{ color: '#b42318' }}>{error}</div>}
<Table<CandidateRow>
size='small'
rowKey='key'
loading={loading}
columns={columns}
dataSource={candidateRows(candidates)}
dataSource={rows}
pagination={false}
scroll={{ y: 420 }}
locale={{ emptyText }}

View File

@ -11,7 +11,7 @@ import type { OsvFilterDraft } from './osvFilterState';
type MoneyKey = 'opening_debit' | 'opening_credit' | 'turnover_debit' | 'turnover_credit' | 'closing_debit' | 'closing_credit';
const money = (key: MoneyKey, title: string, format: (raw: string | null) => string) => ({
export const money = (key: MoneyKey, title: string, format: (raw: string | null) => string) => ({
key,
title,
dataIndex: key,
@ -20,7 +20,7 @@ const money = (key: MoneyKey, title: string, format: (raw: string | null) => str
render: (v: string | null) => format(v),
});
const group = (title: string, children: TableColumnsType<ConsolidatedRowOut>): TableColumnsType<ConsolidatedRowOut>[number] => ({
export const group = (title: string, children: TableColumnsType<ConsolidatedRowOut>): TableColumnsType<ConsolidatedRowOut>[number] => ({
title,
align: 'center',
children,

View File

@ -1,8 +1,18 @@
import { WarningOutlined } from '@ant-design/icons';
import { Button, Modal, Segmented, Tooltip } from 'antd';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { needsUnmappedWarning, reportTabNavigation } from '../../utils/consolidations';
import { getOsvSummary } from '../../services/api/osv';
import type { CounterPairOut, OsvSummaryOut } from '../../types/api';
import type { ConsStatus } from '../../types/consolidations/common_types';
import {
counterPairsWarning,
decideOsvLeave,
rememberCounterPairsShown,
reportTabNavigation,
resolveLeaveAfterFetch,
} from '../../utils/consolidations';
import { pairsViewSearch } from './counterPairs';
const TAB_CONFIG = [
{ key: 'osv', label: 'ОСВ' },
@ -40,16 +50,28 @@ const TAB_CONFIG = [
];
type Props = {
/** `unmapped_total` сводки ОСВ: больше нуля — уход с «ОСВ» через модалку [31-00]. */
unmappedTotal?: number;
/** Сводка ОСВ: есть пары без встречных записей — переход «ОСВ» → «Шахматка» через модалку. */
summary: OsvSummaryOut | null;
versionId: string;
status: ConsStatus | undefined;
};
/** `sessionStorage` может бросать (приватный режим) — тогда `null`. */
const safeSessionStorage = (): Storage | null => {
try {
return window.sessionStorage;
} catch {
return null;
}
};
/** Ряд вкладок шагов консолидации (макет [30]); вкладка — сегмент адреса `/versions/:version_id/:tab`. */
export function OsvTabs({ unmappedTotal = 0 }: Props) {
export function OsvTabs({ summary, versionId, status }: Props) {
const navigate = useNavigate();
const location = useLocation();
const { tab } = useParams<{ tab?: string }>();
const [pending, setPending] = useState<string | null>(null);
// переход, ожидающий решения в модалке; сводка — та, по которой модалка показана (только на время модалки)
const [pending, setPending] = useState<{ key: string; summary: OsvSummaryOut | null } | null>(null);
const activeKey = tab || 'osv';
@ -58,11 +80,46 @@ export function OsvTabs({ unmappedTotal = 0 }: Props) {
navigate(to, options);
};
const handleTabChange = (value: string | number) => {
const [checking, setChecking] = useState(false);
// актуальные версия и признак «компонент жив» — ответ запроса, пришедший после смены версии/размонтирования, игнорируется
const liveRef = useRef({ versionId, mounted: true });
liveRef.current.versionId = versionId;
useEffect(() => {
liveRef.current.mounted = true;
return () => {
liveRef.current.mounted = false;
};
}, []);
const handleTabChange = async (value: string | number) => {
const newKey = String(value);
if (newKey === activeKey) return;
if (activeKey === 'osv' && needsUnmappedWarning(unmappedTotal)) {
setPending(newKey);
if (newKey === activeKey || checking) return;
if (activeKey !== 'osv') {
go(newKey);
return;
}
const storage = safeSessionStorage();
let found: OsvSummaryOut | null = summary;
let decision: 'warn' | 'go' | 'perform' = decideOsvLeave({ summary: found, versionId, status, storage });
if (decision === 'perform') {
setChecking(true);
const requestedFor = versionId;
let result: OsvSummaryOut | null = null;
try {
result = await getOsvSummary(requestedFor);
} catch {
// не удалось проверить — не держим пользователя на вкладке
}
if (!liveRef.current.mounted) return;
setChecking(false);
if (liveRef.current.versionId !== requestedFor) return;
found = result;
decision = resolveLeaveAfterFetch({ summary: result, versionId, status, storage });
}
if (decision === 'warn' && found) {
rememberCounterPairsShown(versionId, found.counter_pairs, storage);
setPending({ key: newKey, summary: found });
} else {
go(newKey);
}
@ -94,15 +151,24 @@ export function OsvTabs({ unmappedTotal = 0 }: Props) {
<Segmented
value={activeKey}
onChange={handleTabChange}
disabled={checking}
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }}
options={options}
/>
<UnmappedInnModal
<CounterPairsModal
open={pending !== null}
unmappedTotal={unmappedTotal}
pairs={pending?.summary?.counter_pairs ?? []}
unmappedTotal={pending?.summary?.unmapped_total ?? 0}
onShow={() => {
setPending(null);
navigate(
{ pathname: location.pathname, search: pairsViewSearch({ view: 'pairs', holder: null, missing: null }) },
{ replace: true },
);
}}
onStay={() => setPending(null)}
onGo={() => {
const next = pending;
const next = pending?.key;
setPending(null);
if (next) go(next);
}}
@ -111,25 +177,33 @@ export function OsvTabs({ unmappedTotal = 0 }: Props) {
);
}
/** [31-00] «Остались неуказанные ИНН»: «Остаться» — без перехода, «Перейти» — переход. */
export function UnmappedInnModal({
/** Модалка перед шахматкой: у части участников нет встречных записей (`05-domain-rules.md` §3 п. 9). */
export function CounterPairsModal({
open,
pairs,
unmappedTotal,
onShow,
onStay,
onGo,
}: {
open: boolean;
pairs: readonly CounterPairOut[];
unmappedTotal: number;
onShow: () => void;
onStay: () => void;
onGo: () => void;
}) {
const { headline, details } = counterPairsWarning(pairs, unmappedTotal);
return (
<Modal
open={open}
title='Остались неуказанные ИНН'
title='Проверьте встречные записи участников'
centered
onCancel={onStay}
footer={[
<Button key='show' onClick={onShow}>
Показать пары
</Button>,
<Button key='stay' onClick={onStay}>
Остаться
</Button>,
@ -138,11 +212,10 @@ export function UnmappedInnModal({
</Button>,
]}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
{/* треугольник с «!», как в макете [31-00] */}
<WarningOutlined style={{ color: '#dc6803', fontSize: 20, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 600 }}>В ОСВ остались неуказанные ИНН</div>
<div style={{ color: '#667085' }}>Не заполнено ИНН: {unmappedTotal}. Точно хотите перейти в другой раздел?</div>
<div style={{ fontWeight: 600 }}>{headline}</div>
<div style={{ color: '#667085' }}>{details}</div>
</div>
</div>
</Modal>

View File

@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import type { CounterPairOut } from '../../types/api';
import {
clampPage,
missingSideEmptyText,
pairLabel,
pairsViewSearch,
readPairsView,
selectPair,
switchPairsView,
syncPageState,
} from './counterPairs';
const pair = (holder: string, missing: string): CounterPairOut => ({
holder_id: holder,
holder_name: `H${holder}`,
missing_id: missing,
missing_name: `M${missing}`,
holder_rows: 1,
missing_unmapped_rows: 0,
missing_rows_total: 0,
});
describe('readPairsView / pairsViewSearch', () => {
it('table по умолчанию', () => {
expect(readPairsView(new URLSearchParams(''))).toEqual({ view: 'table', holder: null, missing: null });
expect(pairsViewSearch({ view: 'table', holder: null, missing: null })).toBe('');
});
it('пара сохраняется в режиме «Свод» (T5.5)', () => {
const v = { view: 'table' as const, holder: 'a', missing: 'b' };
const s = pairsViewSearch(v);
expect(s).toBe('?holder=a&missing=b');
expect(readPairsView(new URLSearchParams(s))).toEqual(v);
// переключение «Свод» → «Встречные записи» возвращает ту же пару
expect(switchPairsView({ view: 'pairs', holder: 'a', missing: 'b' }, 'table')).toEqual(v);
expect(switchPairsView(v, 'pairs')).toEqual({ view: 'pairs', holder: 'a', missing: 'b' });
});
it('туда-обратно', () => {
const v = { view: 'pairs' as const, holder: 'a', missing: 'b' };
const s = pairsViewSearch(v);
expect(s).toBe('?view=pairs&holder=a&missing=b');
expect(readPairsView(new URLSearchParams(s))).toEqual(v);
expect(pairsViewSearch({ view: 'pairs', holder: null, missing: null })).toBe('?view=pairs');
});
});
describe('selectPair', () => {
const pairs = [pair('a', 'b'), pair('c', 'd')];
it('заданная пара', () => {
expect(selectPair(pairs, 'c', 'd')).toBe(pairs[1]);
});
it('selectPair falls back to first', () => {
expect(selectPair(pairs, 'x', 'y')).toBe(pairs[0]);
expect(selectPair(pairs, null, null)).toBe(pairs[0]);
});
it('пусто — null', () => {
expect(selectPair([], 'a', 'b')).toBeNull();
});
});
describe('pairLabel', () => {
it('missing ← holder', () => {
expect(pairLabel(pair('a', 'b'))).toBe('Mb ← Ha');
});
});
describe('missingSideEmptyText (T5.6)', () => {
const p = (total: number, unmapped: number): CounterPairOut => ({
...pair('a', 'b'),
missing_rows_total: total,
missing_unmapped_rows: unmapped,
});
it('у Б нет строк в своде', () => {
expect(missingSideEmptyText(p(0, 0))).toBe('У Mb в своде нет строк: ОСВ не привязаны или по ним пробелы');
});
it('строки есть, без ИНН нет', () => {
expect(missingSideEmptyText(p(5, 0))).toBe(
'Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте',
);
});
it('есть строки без ИНН — текста нет, показываем таблицу', () => {
expect(missingSideEmptyText(p(5, 2))).toBeNull();
});
});
describe('clampPage (T5.6)', () => {
it('не выходит за последнюю страницу', () => {
expect(clampPage(3, 500, 500)).toBe(1);
expect(clampPage(5, 1001, 500)).toBe(3);
});
it('оставляет страницу в пределах', () => {
expect(clampPage(2, 1001, 500)).toBe(2);
});
it('пустой результат — первая страница', () => {
expect(clampPage(4, 0, 500)).toBe(1);
});
});
describe('syncPageState (M-8, M-13)', () => {
it('ключ тот же — состояние то же', () => {
const s = { key: 'a', page: 3 };
expect(syncPageState(s, 'a')).toBe(s);
});
it('ключ сменился — {новый ключ, 1}', () => {
expect(syncPageState({ key: 'a', page: 3 }, 'b')).toEqual({ key: 'b', page: 1 });
});
it('A (стр. 3) → B → A: снова первая страница, а не прежняя', () => {
const onB = syncPageState({ key: 'a', page: 3 }, 'b');
expect(syncPageState(onB, 'a')).toEqual({ key: 'a', page: 1 });
});
});

View File

@ -0,0 +1,46 @@
// Режим «Встречные записи» вкладки «ОСВ»: состояние в адресной строке и выбор пары. Без React и API.
import type { CounterPairOut } from '../../types/api';
export type PairsView = { view: 'table' | 'pairs'; holder: string | null; missing: string | null };
/** `?view=pairs[&holder=..&missing=..]`; всё остальное — обычная таблица свода. */
export const readPairsView = (search: URLSearchParams): PairsView => ({
view: search.get('view') === 'pairs' ? 'pairs' : 'table',
holder: search.get('holder'),
missing: search.get('missing'),
});
/** Переключение «Свод» / «Встречные записи»: выбранная пара не теряется. */
export const switchPairsView = (v: PairsView, view: PairsView['view']): PairsView => ({ ...v, view });
/** Строка запроса для режима; пара (`holder`/`missing`) хранится и в режиме «Свод» — для возврата. */
export const pairsViewSearch = (v: PairsView): string => {
const params = new URLSearchParams();
if (v.view === 'pairs') params.set('view', 'pairs');
if (v.holder) params.set('holder', v.holder);
if (v.missing) params.set('missing', v.missing);
const text = params.toString();
return text ? `?${text}` : '';
};
/** Заданная пара; нет такой (закрыта сопоставлением) — первая оставшаяся; список пуст — `null`. */
export const selectPair = (pairs: readonly CounterPairOut[], holder: string | null, missing: string | null): CounterPairOut | null =>
pairs.find((p) => p.holder_id === holder && p.missing_id === missing) ?? pairs[0] ?? null;
export const pairLabel = (p: CounterPairOut): string => `${p.missing_name} ← ${p.holder_name}`;
/** Текст вместо нижней таблицы (строки Б без ИНН); `null` — таблицу показываем. */
export const missingSideEmptyText = (p: CounterPairOut): string | null => {
if (p.missing_rows_total === 0) return `У ${p.missing_name} в своде нет строк: ОСВ не привязаны или по ним пробелы`;
if (p.missing_unmapped_rows === 0)
return 'Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте';
return null;
};
/** Страница после ответа: не дальше последней существующей (последнюю строку сопоставили), не меньше первой. */
export const clampPage = (page: number, total: number, pageSize: number): number =>
Math.min(page, Math.max(1, Math.ceil(total / pageSize)));
/** Состояние страницы под ключом фильтра: другой ключ — `{ключ, 1}`; прежнее состояние не хранится (A→B→A даёт первую страницу). */
export const syncPageState = (state: { key: string; page: number }, key: string): { key: string; page: number } =>
state.key === key ? state : { key, page: 1 };

View File

@ -12,6 +12,7 @@ import {
mapErrorKind,
mapResultText,
needsMapping,
pairCandidateRows,
} from './counterpartyMapping';
const row = (patch: Partial<ConsolidatedRowOut> = {}): ConsolidatedRowOut => ({
@ -88,6 +89,21 @@ describe('needsMapping / mapEntryId', () => {
});
});
describe('pairCandidateRows', () => {
const cand = (id: string, recommended = false) => ({ id, recommended }) as unknown as CandidateOut;
it('оставляет только участника пары под заголовком «Участник пары»', () => {
const rows = pairCandidateRows([cand('a', true), cand('b'), cand('c')], { id: 'b', name: 'Б' });
expect(rows.map((r) => r.key)).toEqual(['section:pair', 'b']);
expect(rows[0]).toMatchObject({ kind: 'section', label: 'Участник пары' });
expect(rows[1]).toMatchObject({ kind: 'candidate', recommended: true });
});
it('участника нет среди кандидатов — пустой список', () => {
expect(pairCandidateRows([cand('a')], { id: 'b', name: 'Б' })).toEqual([]);
});
});
describe('buildCandidatesQuery', () => {
it('scope=group: q необязателен, entry_id всегда', () => {
expect(buildCandidatesQuery('e1', 'group', ' ')).toBe('?scope=group&entry_id=e1');

View File

@ -47,6 +47,17 @@ export function candidateRows(candidates: CandidateOut[]): CandidateRow[] {
return [...section, ...recommended, ...rest];
}
/** Участник пары «Встречных записей», с которым, скорее всего, надо связать строку (AURORA-1746). */
export type PairTarget = { id: string; name: string };
/** Режим пар: список кандидатов сужен до участника пары, под заголовком «Участник пары»; не нашёлся — пусто. */
export function pairCandidateRows(candidates: CandidateOut[], target: PairTarget): CandidateRow[] {
const found: CandidateRow[] = candidates
.filter((c) => c.id === target.id)
.map((c) => ({ kind: 'candidate', key: c.id, candidate: c, recommended: true }));
return found.length > 0 ? [{ kind: 'section', key: 'section:pair', label: 'Участник пары' }, ...found] : [];
}
export type MapErrorKind = 'ALIAS_TAKEN' | 'LOCK_REQUIRED' | 'LOCKED_BY_OTHER' | 'VERSION_NOT_EDITABLE' | 'OTHER';
const KNOWN_CONFLICTS: readonly MapErrorKind[] = ['ALIAS_TAKEN', 'LOCK_REQUIRED', 'LOCKED_BY_OTHER', 'VERSION_NOT_EDITABLE'];

View File

@ -2,8 +2,8 @@ import { message } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError } from '../../api/client';
import { getConsolidatedOsv, getOsvSummary } from '../../services/api/osv';
import type { ConsolidatedRowOut, OsvFilters, OsvSummaryOut } from '../../types/api';
import { type OsvFilterDraft, applyFilterPatch } from './osvFilterState';
import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api';
import { type OsvFilterDraft, type OsvFilterState, applyFilterPatch } from './osvFilterState';
export const OSV_PAGE_SIZE = 500;
@ -17,14 +17,21 @@ export function osvErrorText(e: unknown): string {
return 'Не удалось получить сводную ОСВ';
}
export function useConsolidatedOsv(versionId: string) {
const [filters, setFilters] = useState<OsvFilters>({});
const [page, setPage] = useState(1);
/**
* @param pairsMode режим «Встречные записи»: строки свода не нужны и не запрашиваются (сводка — запрашивается).
* Фильтры и страница — одно состояние: смена фильтра с возвратом на 1-ю страницу даёт один запрос, а не два.
*/
export function useConsolidatedOsv(versionId: string, pairsMode = false) {
const [query, setQuery] = useState<OsvFilterState>({ filters: {}, page: 1 });
const { filters, page } = query;
const setPage = useCallback((p: number) => setQuery((q) => ({ ...q, page: p })), []);
const [rows, setRows] = useState<ConsolidatedRowOut[]>([]);
const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<OsvSummaryOut | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ошибка загрузки сводки (для режима пар: без неё там не на что смотреть)
const [summaryError, setSummaryError] = useState<string | null>(null);
// номер последнего запроса: ответы более старых запросов игнорируются
const requestRef = useRef(0);
@ -35,9 +42,15 @@ export function useConsolidatedOsv(versionId: string) {
const requestId = ++summaryRef.current;
try {
const s = await getOsvSummary(versionId);
if (requestId === summaryRef.current) setSummary(s);
if (requestId === summaryRef.current) {
setSummary(s);
setSummaryError(null);
}
} catch (e: unknown) {
if (requestId === summaryRef.current) message.error(osvErrorText(e));
if (requestId === summaryRef.current) {
message.error(osvErrorText(e));
setSummaryError(osvErrorText(e));
}
}
}, [versionId]);
@ -71,20 +84,24 @@ export function useConsolidatedOsv(versionId: string) {
}, [versionId, filters, page]);
useEffect(() => {
if (pairsMode) {
// ответ ещё летящего запроса строк больше не нужен
requestRef.current++;
setLoading(false);
return;
}
void load();
}, [load]);
}, [load, pairsMode]);
const patchFilters = (patch: OsvFilterDraft) => {
const next = applyFilterPatch({ filters, page }, patch);
setFilters(next.filters);
setPage(next.page);
setQuery((q) => applyFilterPatch(q, patch));
};
/** После ручного сопоставления: «Осталось N» и строки меняются сразу у нескольких строк (автоприменение) — перечитать обе. */
const refresh = () => {
void load();
if (!pairsMode) void load();
void loadSummary();
};
return { filters, patchFilters, page, setPage, rows, total, summary, loading, error, refresh };
return { filters, patchFilters, page, setPage, rows, total, summary, summaryError, loadSummary, loading, error, refresh };
}

View File

@ -1,32 +1,38 @@
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
import { Alert, Button, Select, message } from 'antd';
import { Alert, Button, Segmented, Select, Spin, message } from 'antd';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useParams, useSearchParams } from 'react-router-dom';
import { ApiError } from '../../api/client';
import { exportOsv } from '../../services/api/osv';
import type { ConsolidatedRowOut } from '../../types/api';
import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api';
import type { VersionCard } from '../../types/consolidations/version_members';
import { exportFilename, saveBlob } from '../../utils/download';
import { CounterPairsView } from '../ConsolidatedOsv/CounterPairsView';
import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal';
import { OsvGaps } from '../ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../ConsolidatedOsv/OsvTable';
import { pairsViewSearch, readPairsView, selectPair, switchPairsView } from '../ConsolidatedOsv/counterPairs';
import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv';
import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
type Props = {
card: VersionCard | null;
/** Сообщает странице отчёта `unmapped_total` сводки — для модалки [31-00] при уходе с вкладки «ОСВ». */
onUnmappedChange?: (total: number) => void;
/** Сообщает странице отчёта сводку ОСВ (пары без встречных записей — для модалки при уходе с «ОСВ»); при размонтировании — `null`. */
onSummaryChange?: (summary: OsvSummaryOut | null) => void;
};
export function OsvContent({ card, onUnmappedChange }: Props) {
export function OsvContent({ card, onSummaryChange }: Props) {
const [exporting, setExporting] = useState(false);
const { version_id } = useParams<{ version_id: string }>();
// строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ)
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
const osv = useConsolidatedOsv(version_id ?? '');
const [searchParams, setSearchParams] = useSearchParams();
const pairsView = readPairsView(searchParams);
const inPairs = pairsView.view === 'pairs';
const osv = useConsolidatedOsv(version_id ?? '', inPairs);
const s = osv.summary;
const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay();
const download = async () => {
@ -44,15 +50,56 @@ export function OsvContent({ card, onUnmappedChange }: Props) {
}
};
const s = osv.summary;
const pairs = s?.counter_pairs ?? [];
const selectedPair = selectPair(pairs, pairsView.holder, pairsView.missing);
const setView = (v: Parameters<typeof pairsViewSearch>[0]) => setSearchParams(pairsViewSearch(v), { replace: true });
const unmapped = s?.unmapped_total ?? 0;
const onlyUnmapped = osv.filters.unmapped === true;
// выбранную пару закрыли сопоставлением — в адресе первая оставшаяся
useEffect(() => {
onUnmappedChange?.(unmapped);
}, [unmapped, onUnmappedChange]);
if (!inPairs || !s) return;
if (selectedPair && (selectedPair.holder_id !== pairsView.holder || selectedPair.missing_id !== pairsView.missing)) {
setView({ view: 'pairs', holder: selectedPair.holder_id, missing: selectedPair.missing_id });
}
// biome-ignore lint/correctness/useExhaustiveDependencies: setView пересоздаётся каждый рендер, нужны только эти значения
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inPairs, s, selectedPair, pairsView.holder, pairsView.missing]);
useEffect(() => {
onSummaryChange?.(s);
}, [s, onSummaryChange]);
// вне вкладки «ОСВ» предупреждать не о чем
useEffect(() => () => onUnmappedChange?.(0), [onUnmappedChange]);
useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]);
const renderPairsBody = () => {
if (s) {
return (
<CounterPairsView
versionId={version_id ?? ''}
pairs={pairs}
selected={selectedPair}
numFormat={format}
onSelect={(holder, missing) => setView({ view: 'pairs', holder, missing })}
onMapped={osv.refresh}
/>
);
}
if (osv.summaryError) {
return (
<Alert
type='error'
showIcon
title={osv.summaryError}
action={
<Button size='small' onClick={() => void osv.loadSummary()}>
Повторить
</Button>
}
/>
);
}
return <Spin style={{ alignSelf: 'center', margin: 24 }} />;
};
return (
<>
@ -84,12 +131,25 @@ export function OsvContent({ card, onUnmappedChange }: Props) {
/>
</div>
</div>
<Button icon={<DownloadOutlined />} loading={exporting} onClick={download}>
Экспорт
</Button>
{/* выгружает весь свод, а не пару — в режиме пар не нужна */}
{!inPairs && (
<Button icon={<DownloadOutlined />} loading={exporting} onClick={download}>
Экспорт
</Button>
)}
</div>
<Segmented
value={pairsView.view}
onChange={(v) => setView(switchPairsView(pairsView, v === 'pairs' ? 'pairs' : 'table'))}
options={[
{ label: 'Свод', value: 'table' },
// пока сводки нет — без числа
{ label: s ? `Встречные записи (${pairs.length})` : 'Встречные записи', value: 'pairs' },
]}
style={{ alignSelf: 'flex-start' }}
/>
{osv.error && <Alert type='error' showIcon title={osv.error} />}
{unmapped > 0 && (
{!inPairs && unmapped > 0 && (
// `osv-mapping-alert` макета [30] / [32-osv-no-inn]
<div
style={{
@ -104,8 +164,10 @@ export function OsvContent({ card, onUnmappedChange }: Props) {
color: '#93370d',
fontSize: 13,
}}>
<strong style={{ color: '#7a2e0e' }}>Требуется указать соответствия</strong>
<span>Для перехода к следующему шагу сопоставьте все внешние организации без ИНН. Осталось: {unmapped}.</span>
<strong style={{ color: '#7a2e0e' }}>Есть организации без ИНН и без сопоставления</strong>
<span>
Осталось: {unmapped}. Несопоставленные учитываются как внешние; при необходимости укажите организацию кнопкой «Указать».
</span>
{/* макет оставляет кнопку как есть, снимать фильтр — воронкой ИНН или «Сбросить фильтры» (панель — позже) */}
<Button
style={{ marginLeft: 'auto', minHeight: 30, borderColor: '#f4b740', background: '#fff', color: '#93370d' }}
@ -115,20 +177,26 @@ export function OsvContent({ card, onUnmappedChange }: Props) {
</Button>
</div>
)}
<OsvGaps gaps={s?.gaps ?? []} />
<OsvTable
rows={osv.rows}
total={osv.total}
page={osv.page}
pageSize={OSV_PAGE_SIZE}
loading={osv.loading}
filters={osv.filters}
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
onPage={osv.setPage}
numFormat={format}
onFilter={osv.patchFilters}
onMap={setMapping}
/>
{inPairs ? (
renderPairsBody()
) : (
<>
<OsvGaps gaps={s?.gaps ?? []} />
<OsvTable
rows={osv.rows}
total={osv.total}
page={osv.page}
pageSize={OSV_PAGE_SIZE}
loading={osv.loading}
filters={osv.filters}
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
onPage={osv.setPage}
numFormat={format}
onFilter={osv.patchFilters}
onMap={setMapping}
/>
</>
)}
</main>
{mapping && version_id && (
<CounterpartyMapModal