From 93ee8b2352cb3d7bbf2d969ab9034c39481e671c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 22:58:50 +0400 Subject: [PATCH] =?UTF-8?q?feat(#195):=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D0=BE=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D1=80=D0=B5=D0=B6?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=20=D0=BF=D0=B0=D1=80=20=D1=81=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D1=83=20=D1=81=D1=83=D0=B6=D0=B5=D0=BD=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=BE=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решение владельца после показа на стенде: «Указать» во «Встречных записях» открывает список из одного участника пары; «Показать все организации» — обычный список. Чистая pairCandidateRows + vitest, спека и design.md. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-27-counter-pairs.md | 9 +++ openspec/changes/counter-pairs/design.md | 1 + .../specs/consolidated-osv-ui/spec.md | 11 +++- .../ConsolidatedOsv/CounterPairsView.tsx | 1 + .../ConsolidatedOsv/CounterpartyMapModal.tsx | 57 ++++++++++++------- .../counterpartyMapping.test.ts | 16 ++++++ .../ConsolidatedOsv/counterpartyMapping.ts | 11 ++++ 7 files changed, 85 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/plans/2026-10-27-counter-pairs.md b/docs/superpowers/plans/2026-10-27-counter-pairs.md index c50247b..c778b10 100644 --- a/docs/superpowers/plans/2026-10-27-counter-pairs.md +++ b/docs/superpowers/plans/2026-10-27-counter-pairs.md @@ -222,3 +222,12 @@ specs/consolidations-ui). Issue — arm-core #195, Jira — AURORA-1746. Вет - [ ] 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`; стенд — модалка с одной строкой «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО». diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md index cb701cd..c964efe 100644 --- a/openspec/changes/counter-pairs/design.md +++ b/openspec/changes/counter-pairs/design.md @@ -25,6 +25,7 @@ без ИНН и без сопоставления. 5. Подсветка кандидата по совпадению суммы — отдельная задача. 6. В БД ничего не хранить. Модалка — один раз за сессию или после исправлений в сводной ОСВ. +7. (после показа на стенде) «Указать» в режиме пар открывает модалку сопоставления, сразу суженную до участника пары, с флажком «Показать все организации» — вариант 1 из двух показанных; кнопку «Связать с <А>» прямо в строке не делаем (легко промахнуться). ## Устройство diff --git a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md index 69cfd5d..87f3c28 100644 --- a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md +++ b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md @@ -38,7 +38,11 @@ Если запрос сводки не удался, показывается ошибка с кнопкой «Повторить», повторяющей запрос. «Указать» SHALL открывать модалку сопоставления из требования «Ручное сопоставление контрагента» с теми же ответами и -ошибками; после «Связать» экран SHALL перечитывать сводку и обе таблицы. Если выбранная пара исчезла из списка, выбирается +ошибками, но список кандидатов сразу сужен до участника пары А: одна строка под заголовком «Участник пары» с «Связать» +(кандидаты запрашиваются с `scope=group` и `q` = имя А), поле поиска и «Искать во всём справочнике» скрыты; флажок +«Показать все организации» возвращает обычный список модалки (решение владельца 27.10.2026). Если А среди кандидатов не +нашёлся — текст «Участник пары не найден среди кандидатов — включите «Показать все организации»». В обычном своде +«Указать» не меняется. После «Связать» экран SHALL перечитывать сводку и обе таблицы. Если выбранная пара исчезла из списка, выбирается первая оставшаяся; пустой список — текст «Все участники периметра видят друг друга» (переключатель — «Встречные записи (0)»). #### Scenario: Пара Русского бисквита @@ -52,6 +56,11 @@ - **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель — «Встречные записи (0)» +#### Scenario: Модалка сопоставления сужена до участника пары +- **WHEN** в паре «Русский бисквит ← Пекарня Счастливочная» во второй таблице нажато «Указать» +- **THEN** в модалке одна строка — ООО «Пекарня Счастливочная» под заголовком «Участник пары» с «Связать»; после + «Показать все организации» — поиск и обычный список кандидатов + #### Scenario: Сводка ещё не получена - **WHEN** открыт режим «Встречные записи», запрос сводки в пути - **THEN** показан индикатор загрузки, подпись переключателя — «Встречные записи» без числа diff --git a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx index f6d9743..1f5c721 100644 --- a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx +++ b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx @@ -244,6 +244,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele row={mapping} onClose={() => setMapping(null)} onMapped={mapped} + pairTarget={selected ? { id: selected.holder_id, name: selected.holder_name } : undefined} /> )} diff --git a/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx b/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx index 5c7b22e..351f64d 100644 --- a/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx +++ b/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx @@ -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('group'); + const [showAll, setShowAll] = useState(false); + const pairOnly = !!pairTarget && !showAll; const [candidates, setCandidates] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(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 ( {contextHolder}
- } - placeholder='Поиск' - maxLength={500} - value={q} - onChange={(e) => setQ(e.target.value)} - /> - setScope(e.target.checked ? 'all' : 'group')}> - Искать во всём справочнике (не только в группе) - + {!pairOnly && ( + <> + } + placeholder='Поиск' + maxLength={500} + value={q} + onChange={(e) => setQ(e.target.value)} + /> + setScope(e.target.checked ? 'all' : 'group')}> + Искать во всём справочнике (не только в группе) + + + )} + {pairTarget && ( + setShowAll(e.target.checked)}> + Показать все организации + + )} {error &&
{error}
} size='small' rowKey='key' loading={loading} columns={columns} - dataSource={candidateRows(candidates)} + dataSource={rows} pagination={false} scroll={{ y: 420 }} locale={{ emptyText }} diff --git a/web/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts b/web/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts index cf9f8dc..54b9583 100644 --- a/web/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts +++ b/web/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts @@ -12,6 +12,7 @@ import { mapErrorKind, mapResultText, needsMapping, + pairCandidateRows, } from './counterpartyMapping'; const row = (patch: Partial = {}): 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'); diff --git a/web/src/widgets/ConsolidatedOsv/counterpartyMapping.ts b/web/src/widgets/ConsolidatedOsv/counterpartyMapping.ts index 469eefa..3ff25a7 100644 --- a/web/src/widgets/ConsolidatedOsv/counterpartyMapping.ts +++ b/web/src/widgets/ConsolidatedOsv/counterpartyMapping.ts @@ -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'];