feat(#195): модалка сопоставления в режиме пар сразу сужена до участника пары
Решение владельца после показа на стенде: «Указать» во «Встречных записях» открывает список из одного участника пары; «Показать все организации» — обычный список. Чистая pairCandidateRows + vitest, спека и design.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d230fb13ee
commit
93ee8b2352
@ -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`; стенд — модалка с одной строкой «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО».
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
без ИНН и без сопоставления.
|
||||
5. Подсветка кандидата по совпадению суммы — отдельная задача.
|
||||
6. В БД ничего не хранить. Модалка — один раз за сессию или после исправлений в сводной ОСВ.
|
||||
7. (после показа на стенде) «Указать» в режиме пар открывает модалку сопоставления, сразу суженную до участника пары, с флажком «Показать все организации» — вариант 1 из двух показанных; кнопку «Связать с <А>» прямо в строке не делаем (легко промахнуться).
|
||||
|
||||
## Устройство
|
||||
|
||||
|
||||
@ -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** показан индикатор загрузки, подпись переключателя — «Встречные записи» без числа
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</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,35 @@ 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)}
|
||||
/>
|
||||
<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>}
|
||||
<Table<CandidateRow>
|
||||
size='small'
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={candidateRows(candidates)}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
scroll={{ y: 420 }}
|
||||
locale={{ emptyText }}
|
||||
|
||||
@ -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'];
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user