Merge pull request 'Сводная ОСВ: пары участников без встречных записей вместо «сопоставьте все» (#195, AURORA-1746)' (#196) from feat/195-counter-pairs into main
This commit is contained in:
commit
3ed70c2996
@ -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; для шахматки
|
||||
|
||||
@ -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 | ручное сопоставление контрагента строки свода |
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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`)."""
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"] == []
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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)
|
||||
|
||||
233
docs/superpowers/plans/2026-10-27-counter-pairs.md
Normal file
233
docs/superpowers/plans/2026-10-27-counter-pairs.md
Normal 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`; стенд — модалка с одной строкой «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО».
|
||||
2
openspec/changes/counter-pairs/.openspec.yaml
Normal file
2
openspec/changes/counter-pairs/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-27
|
||||
100
openspec/changes/counter-pairs/design.md
Normal file
100
openspec/changes/counter-pairs/design.md
Normal 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.
|
||||
67
openspec/changes/counter-pairs/proposal.md
Normal file
67
openspec/changes/counter-pairs/proposal.md
Normal 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.
|
||||
@ -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** показан режим «Встречные записи» с первой парой; кнопка «Назад» браузера не проходит по переключениям режимов
|
||||
@ -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` пары
|
||||
@ -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** вкладка «ОСВ» остаётся, открыт режим «Встречные записи» с первой парой
|
||||
@ -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] | панель «Организации» выбранной версии и чек-лист |
|
||||
|
||||
@ -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)} />}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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';
|
||||
};
|
||||
|
||||
252
web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx
Normal file
252
web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 }}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
111
web/src/widgets/ConsolidatedOsv/counterPairs.test.ts
Normal file
111
web/src/widgets/ConsolidatedOsv/counterPairs.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
46
web/src/widgets/ConsolidatedOsv/counterPairs.ts
Normal file
46
web/src/widgets/ConsolidatedOsv/counterPairs.ts
Normal 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 };
|
||||
@ -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');
|
||||
|
||||
@ -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'];
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user