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-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`; стенд — модалка с одной строкой «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО».
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
без ИНН и без сопоставления.
|
без ИНН и без сопоставления.
|
||||||
5. Подсветка кандидата по совпадению суммы — отдельная задача.
|
5. Подсветка кандидата по совпадению суммы — отдельная задача.
|
||||||
6. В БД ничего не хранить. Модалка — один раз за сессию или после исправлений в сводной ОСВ.
|
6. В БД ничего не хранить. Модалка — один раз за сессию или после исправлений в сводной ОСВ.
|
||||||
|
7. (после показа на стенде) «Указать» в режиме пар открывает модалку сопоставления, сразу суженную до участника пары, с флажком «Показать все организации» — вариант 1 из двух показанных; кнопку «Связать с <А>» прямо в строке не делаем (легко промахнуться).
|
||||||
|
|
||||||
## Устройство
|
## Устройство
|
||||||
|
|
||||||
|
|||||||
@ -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** показан индикатор загрузки, подпись переключателя — «Встречные записи» без числа
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 }}
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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'];
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user