feat(#195): модалка сопоставления в режиме пар сразу сужена до участника пары

Решение владельца после показа на стенде: «Указать» во «Встречных записях»
открывает список из одного участника пары; «Показать все организации» —
обычный список. Чистая pairCandidateRows + vitest, спека и design.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 22:58:50 +04:00
parent d230fb13ee
commit 93ee8b2352
7 changed files with 85 additions and 21 deletions

View File

@ -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-5: стенд с кодом ветки; проверить три пустых состояния и раскладку на 1440; сверить с макетом [30]. Перед правкой данных — SELECT исходных значений в файл ledger; после — восстановление и сверка SELECT-ом (включая `counterparty_match_kind` у 25 строк РБ, смена с ALIAS на MANUAL/PROPAGATED от O-3 — вернуть прежние значения, если есть бэкап, иначе описать в отчёте).
- [ ] O-1: проверить `make up` из worktree при существующем `.wt/counter-pairs/docs`. - [ ] O-1: проверить `make up` из worktree при существующем `.wt/counter-pairs/docs`.
- [ ] M-7: правка Purpose в `openspec/specs/consolidations-ui/spec.md` — пункт чеклиста закрытия (`openspec archive`), не follow-up. - [ ] M-7: правка Purpose в `openspec/specs/consolidations-ui/spec.md` — пункт чеклиста закрытия (`openspec archive`), не follow-up.
## Волна 3 (решение владельца 27.10.2026 после показа на стенде)
### Task 10: модалка сопоставления в режиме пар сужена до участника пары (web + спека, исполнял основной сеанс)
- [x] `pairCandidateRows(candidates, target)` в `counterpartyMapping.ts` + vitest (участник пары под «Участник пары»; нет — пусто).
- [x] `CounterpartyMapModal` — проп `pairTarget?: PairTarget`: запрос `scope=group&q=<имя А>`, поиск и «Искать во всём справочнике» скрыты, флажок «Показать все организации»; `CounterPairsView` передаёт участника выбранной пары.
- [x] Спека `consolidated-osv-ui` (сценарий «Модалка сопоставления сужена до участника пары»), design.md (решение владельца 7).
- [x] tsc, biome, prettier, `npm test`, `npm run lint`; стенд — модалка с одной строкой «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО».

View File

@ -25,6 +25,7 @@
без ИНН и без сопоставления. без ИНН и без сопоставления.
5. Подсветка кандидата по совпадению суммы — отдельная задача. 5. Подсветка кандидата по совпадению суммы — отдельная задача.
6. В БД ничего не хранить. Модалка — один раз за сессию или после исправлений в сводной ОСВ. 6. В БД ничего не хранить. Модалка — один раз за сессию или после исправлений в сводной ОСВ.
7. (после показа на стенде) «Указать» в режиме пар открывает модалку сопоставления, сразу суженную до участника пары, с флажком «Показать все организации» — вариант 1 из двух показанных; кнопку «Связать с <А>» прямо в строке не делаем (легко промахнуться).
## Устройство ## Устройство

View File

@ -38,7 +38,11 @@
Если запрос сводки не удался, показывается ошибка с кнопкой «Повторить», повторяющей запрос. Если запрос сводки не удался, показывается ошибка с кнопкой «Повторить», повторяющей запрос.
«Указать» SHALL открывать модалку сопоставления из требования «Ручное сопоставление контрагента» с теми же ответами и «Указать» SHALL открывать модалку сопоставления из требования «Ручное сопоставление контрагента» с теми же ответами и
ошибками; после «Связать» экран SHALL перечитывать сводку и обе таблицы. Если выбранная пара исчезла из списка, выбирается ошибками, но список кандидатов сразу сужен до участника пары А: одна строка под заголовком «Участник пары» с «Связать»
(кандидаты запрашиваются с `scope=group` и `q` = имя А), поле поиска и «Искать во всём справочнике» скрыты; флажок
«Показать все организации» возвращает обычный список модалки (решение владельца 27.10.2026). Если А среди кандидатов не
нашёлся — текст «Участник пары не найден среди кандидатов — включите «Показать все организации»». В обычном своде
«Указать» не меняется. После «Связать» экран SHALL перечитывать сводку и обе таблицы. Если выбранная пара исчезла из списка, выбирается
первая оставшаяся; пустой список — текст «Все участники периметра видят друг друга» (переключатель — «Встречные записи (0)»). первая оставшаяся; пустой список — текст «Все участники периметра видят друг друга» (переключатель — «Встречные записи (0)»).
#### Scenario: Пара Русского бисквита #### Scenario: Пара Русского бисквита
@ -52,6 +56,11 @@
- **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель — - **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель —
«Встречные записи (0)» «Встречные записи (0)»
#### Scenario: Модалка сопоставления сужена до участника пары
- **WHEN** в паре «Русский бисквит ← Пекарня Счастливочная» во второй таблице нажато «Указать»
- **THEN** в модалке одна строка — ООО «Пекарня Счастливочная» под заголовком «Участник пары» с «Связать»; после
«Показать все организации» — поиск и обычный список кандидатов
#### Scenario: Сводка ещё не получена #### Scenario: Сводка ещё не получена
- **WHEN** открыт режим «Встречные записи», запрос сводки в пути - **WHEN** открыт режим «Встречные записи», запрос сводки в пути
- **THEN** показан индикатор загрузки, подпись переключателя — «Встречные записи» без числа - **THEN** показан индикатор загрузки, подпись переключателя — «Встречные записи» без числа

View File

@ -244,6 +244,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele
row={mapping} row={mapping}
onClose={() => setMapping(null)} onClose={() => setMapping(null)}
onMapped={mapped} onMapped={mapped}
pairTarget={selected ? { id: selected.holder_id, name: selected.holder_name } : undefined}
/> />
)} )}
</div> </div>

View File

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

View File

@ -12,6 +12,7 @@ import {
mapErrorKind, mapErrorKind,
mapResultText, mapResultText,
needsMapping, needsMapping,
pairCandidateRows,
} from './counterpartyMapping'; } from './counterpartyMapping';
const row = (patch: Partial<ConsolidatedRowOut> = {}): ConsolidatedRowOut => ({ 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', () => { describe('buildCandidatesQuery', () => {
it('scope=group: q необязателен, entry_id всегда', () => { it('scope=group: q необязателен, entry_id всегда', () => {
expect(buildCandidatesQuery('e1', 'group', ' ')).toBe('?scope=group&entry_id=e1'); expect(buildCandidatesQuery('e1', 'group', ' ')).toBe('?scope=group&entry_id=e1');

View File

@ -47,6 +47,17 @@ export function candidateRows(candidates: CandidateOut[]): CandidateRow[] {
return [...section, ...recommended, ...rest]; 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'; 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']; const KNOWN_CONFLICTS: readonly MapErrorKind[] = ['ALIAS_TAKEN', 'LOCK_REQUIRED', 'LOCKED_BY_OTHER', 'VERSION_NOT_EDITABLE'];