docs(#195): правки ревью Task 8 — формулировки раскладки, таблица решений

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 21:56:59 +04:00
parent e2e416282f
commit 95171f4f50
8 changed files with 123 additions and 39 deletions

View File

@ -53,7 +53,7 @@
пар (`holder_id>missing_id`, отсортированные, через запятую). Чтение и запись — в `try/catch`. пар (`holder_id>missing_id`, отсортированные, через запятую). Чтение и запись — в `try/catch`.
- `UnmappedInnModal` заменяется модалкой «Проверьте встречные записи участников»; склонение «пара/пары/пар», - `UnmappedInnModal` заменяется модалкой «Проверьте встречные записи участников»; склонение «пара/пары/пар»,
«участника/участников» — существующая `pluralRu` (`web/src/utils/labels.ts`). «участника/участников» — существующая `pluralRu` (`web/src/utils/labels.ts`).
- Над правой таблицей — поле поиска по контрагенту (`q` существующего фильтра `GET …/osv`, пауза ввода как в своде): - Над второй (нижней) таблицей — поле поиска по контрагенту (`q` существующего фильтра `GET …/osv`, пауза ввода как в своде):
у РБ на фикстуре около тысячи строк без ИНН, листать страницы в поисках одной строки — та же «прокликай всё». у РБ на фикстуре около тысячи строк без ИНН, листать страницы в поисках одной строки — та же «прокликай всё».
- Модалка не показывается для версий в завершённых статусах (`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED` — - Модалка не показывается для версий в завершённых статусах (`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED` —
`FINISHED_STATUSES`): править там нечего. Режим «Встречные записи» доступен в любом статусе, «Указать» ведёт себя как `FINISHED_STATUSES`): править там нечего. Режим «Встречные записи» доступен в любом статусе, «Указать» ведёт себя как
@ -84,10 +84,10 @@
| 27.10.2026 | «После исправлений» = список пар изменился (ключ списка пар в `sessionStorage`), а не любое «Связать» | Повтор модалки нужен, когда есть что сказать нового; «Связать» вне пар модалку не меняет | Низкая: правило в одной функции фронта | | 27.10.2026 | «После исправлений» = список пар изменился (ключ списка пар в `sessionStorage`), а не любое «Связать» | Повтор модалки нужен, когда есть что сказать нового; «Связать» вне пар модалку не меняет | Низкая: правило в одной функции фронта |
| 27.10.2026 | Строки А и Б берутся существующим `GET …/osv` с фильтрами (`counterparty_id` — новый), в сводке — только пары и счётчики | Без нового эндпоинта; пагинация и экспорт работают как в своде | Низкая | | 27.10.2026 | Строки А и Б берутся существующим `GET …/osv` с фильтрами (`counterparty_id` — новый), в сводке — только пары и счётчики | Без нового эндпоинта; пагинация и экспорт работают как в своде | Низкая |
| 27.10.2026 | Учитываются все строки свода, без ограничения счетами правил элиминации и без порога по суммам | Владелец согласовал уровень компаний; строка с нулями тоже говорит, что контрагент виден | Средняя: возможны «лишние» пары из-за случайных счетов — правится фильтром в функции | | 27.10.2026 | Учитываются все строки свода, без ограничения счетами правил элиминации и без порога по суммам | Владелец согласовал уровень компаний; строка с нулями тоже говорит, что контрагент виден | Средняя: возможны «лишние» пары из-за случайных счетов — правится фильтром в функции |
| 27.10.2026 | Колонки таблиц режима — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт (без сальдо на начало) | Три колонки на экране; для поиска пары важнее обороты и итог | Низкая: добавить колонки | | 27.10.2026 | Колонки таблиц режима — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт (без сальдо на начало) | Колонки должны уместиться на всю ширину при 1440; для поиска пары важнее обороты и итог | Низкая: добавить колонки |
| 27.10.2026 | Режим — параметр адреса `?view=pairs` (с `holder`/`missing`), переключение без записи в историю | Как вкладки отчёта (AURORA-1851); ссылку на пару можно переслать | Низкая | | 27.10.2026 | Режим — параметр адреса `?view=pairs` (с `holder`/`missing`), переключение без записи в историю | Как вкладки отчёта (AURORA-1851); ссылку на пару можно переслать | Низкая |
| 27.10.2026 | Тексты баннера и модалки — из спек этого change | Владелец согласовал смысл, формулировки мои | Низкая | | 27.10.2026 | Тексты баннера и модалки — из спек этого change | Владелец согласовал смысл, формулировки мои | Низкая |
| 27.10.2026 | Поиск `q` над правой таблицей режима (находка builder check) | У РБ ~1000 строк без ИНН; без поиска разбор пары — листание страниц | Низкая: существующий фильтр API | | 27.10.2026 | Поиск `q` над второй (нижней) таблицей режима (находка builder check) | У РБ ~1000 строк без ИНН; без поиска разбор пары — листание страниц | Низкая: существующий фильтр API |
| 27.10.2026 | Модалка не показывается для версий в `FINISHED_STATUSES` (находка builder check) | Замороженную версию не исправить — предупреждение было бы шумом | Низкая | | 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-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-2 (design #10): в тексте модалки «Ещё K строк без ИНН…» слово «строк» не склоняется, M — число разных `missing_id` (по спеке) | Спека дословна; склонение K=1 даёт всё равно неграмматичную фразу («учтены») | Низкая |
@ -95,5 +95,6 @@
| 27.10.2026 | R-4 (design #12): подпись под парой в списке — «записей: N · без ИНН у <Б>: M» вместо служебных «держатель»/«Б» | Аналитик не знает ролей «держатель» и «Б»; название организации понятнее | Низкая | | 27.10.2026 | R-4 (design #12): подпись под парой в списке — «записей: N · без ИНН у <Б>: M» вместо служебных «держатель»/«Б» | Аналитик не знает ролей «держатель» и «Б»; название организации понятнее | Низкая |
| 27.10.2026 | R-5 (design #13): раскладка режима «Встречные записи» — пары одной строкой «таблеток» с переносом, под ними две таблицы на всю ширину одна под другой (вместо трёх колонок) | В трёх колонках таблицам не хватало ширины: суммы обрезались, видны были только «Обороты Дт» (находка H-1) | Низкая | | 27.10.2026 | R-5 (design #13): раскладка режима «Встречные записи» — пары одной строкой «таблеток» с переносом, под ними две таблицы на всю ширину одна под другой (вместо трёх колонок) | В трёх колонках таблицам не хватало ширины: суммы обрезались, видны были только «Обороты Дт» (находка H-1) | Низкая |
Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867.
| 27.10.2026 | R-6 (design #14): уход с «ОСВ» — закрытая версия (FINISHED_STATUSES) переходит без запроса сводки; при `summary = null` запрашивается сводка, при ошибке запроса переход идёт без модалки и без своего сообщения; на время догрузки переключатель вкладок блокируется; выбранная пара хранится в адресе и в режиме «Свод» (`?holder=&missing=` без `view`) | Модалка нужна только открытой версии; ошибку сводки показывает общий слой; блокировка исключает двойные клики; адрес — единственное хранилище состояния режима | Низкая | | 27.10.2026 | R-6 (design #14): уход с «ОСВ» — закрытая версия (FINISHED_STATUSES) переходит без запроса сводки; при `summary = null` запрашивается сводка, при ошибке запроса переход идёт без модалки и без своего сообщения; на время догрузки переключатель вкладок блокируется; выбранная пара хранится в адресе и в режиме «Свод» (`?holder=&missing=` без `view`) | Модалка нужна только открытой версии; ошибку сводки показывает общий слой; блокировка исключает двойные клики; адрес — единственное хранилище состояния режима | Низкая |
Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867.

View File

@ -38,7 +38,7 @@ AURORA-1746 и замечание заказчика (Циликов А.В., 23.
27.10.2026); DBML не меняется. 27.10.2026); DBML не меняется.
- Отсев строк, не являющихся организациями (зарплата, подотчётники), из счётчика `unmapped_total`. - Отсев строк, не являющихся организациями (зарплата, подотчётники), из счётчика `unmapped_total`.
- Сверка по счетам и суммам внутри пары — это делает шахматка (§6). - Сверка по счетам и суммам внутри пары — это делает шахматка (§6).
- Поиск участника, ошибочно сопоставленного с другим ЮЛ: справа показываются только несопоставленные строки без ИНН. - Поиск участника, ошибочно сопоставленного с другим ЮЛ: во второй (нижней) таблице показываются только несопоставленные строки без ИНН.
- Строки Б с ИНН из файла, которого нет в справочнике (старый ИНН, опечатка): по решению 08.10.2026 они не требуют - Строки Б с ИНН из файла, которого нет в справочнике (старый ИНН, опечатка): по решению 08.10.2026 они не требуют
сопоставления, в правую таблицу не попадают, и такая пара из режима не закрывается. Оставлено как есть (решение сопоставления, в правую таблицу не попадают, и такая пара из режима не закрывается. Оставлено как есть (решение
владельца 27.10.2026), разбор — отдельная задача Jira; модалка и пустое состояние называют эту причину. владельца 27.10.2026), разбор — отдельная задача Jira; модалка и пустое состояние называют эту причину.

View File

@ -42,7 +42,7 @@
«Указать» «Указать»
#### Scenario: Сопоставление из режима закрывает пару #### Scenario: Сопоставление из режима закрывает пару
- **WHEN** в правой таблице у «Пекарня Счастливочная ООО» нажато «Указать» и выбрана ООО «Пекарня Счастливочная» - **WHEN** во второй таблице у «Пекарня Счастливочная ООО» нажато «Указать» и выбрана ООО «Пекарня Счастливочная»
- **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель — - **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель —
«Встречные записи (0)» «Встречные записи (0)»

View File

@ -19,6 +19,7 @@ import {
parseInWorkOnly, parseInWorkOnly,
rememberCounterPairsShown, rememberCounterPairsShown,
reportTabNavigation, reportTabNavigation,
resolveLeaveAfterFetch,
shouldWarnCounterPairs, shouldWarnCounterPairs,
versionLabel, versionLabel,
} from './consolidations'; } from './consolidations';
@ -245,3 +246,23 @@ describe('decideOsvLeave (T4.2)', () => {
expect(decideOsvLeave({ ...base, storage: shown, summary: { counter_pairs: pairs } })).toBe('go'); 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');
});
});

View File

@ -170,3 +170,14 @@ export const decideOsvLeave = (args: {
if (summary === null) return 'perform'; if (summary === null) return 'perform';
return shouldWarnCounterPairs(versionId, status, summary.counter_pairs, storage) ? 'warn' : 'go'; 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';
};

View File

@ -1,11 +1,17 @@
import { WarningOutlined } from '@ant-design/icons'; import { WarningOutlined } from '@ant-design/icons';
import { Button, Modal, Segmented, Tooltip } from 'antd'; 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 { useLocation, useNavigate, useParams } from 'react-router-dom';
import { getOsvSummary } from '../../services/api/osv'; import { getOsvSummary } from '../../services/api/osv';
import type { CounterPairOut, OsvSummaryOut } from '../../types/api'; import type { CounterPairOut, OsvSummaryOut } from '../../types/api';
import type { ConsStatus } from '../../types/consolidations/common_types'; import type { ConsStatus } from '../../types/consolidations/common_types';
import { counterPairsWarning, decideOsvLeave, rememberCounterPairsShown, reportTabNavigation } from '../../utils/consolidations'; import {
counterPairsWarning,
decideOsvLeave,
rememberCounterPairsShown,
reportTabNavigation,
resolveLeaveAfterFetch,
} from '../../utils/consolidations';
import { pairsViewSearch } from './counterPairs'; import { pairsViewSearch } from './counterPairs';
const TAB_CONFIG = [ const TAB_CONFIG = [
@ -73,10 +79,22 @@ export function OsvTabs({ summary, versionId, status }: Props) {
navigate(to, options); navigate(to, options);
}; };
// сводка, догруженная при уходе, пока страница её не передала (или запрос страницы упал) // сводка, догруженная при уходе, пока страница её не передала (или запрос страницы упал); привязана к версии
const [fetched, setFetched] = useState<OsvSummaryOut | null>(null); const [fetched, setFetched] = useState<{ versionId: string; summary: OsvSummaryOut } | null>(null);
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const shownPairs = (summary ?? fetched)?.counter_pairs ?? []; const fetchedSummary = fetched?.versionId === versionId ? fetched.summary : null;
const known = summary ?? fetchedSummary;
const shownPairs = known?.counter_pairs ?? [];
// актуальные версия и признак «компонент жив» — ответ запроса, пришедший после смены версии/размонтирования, игнорируется
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 handleTabChange = async (value: string | number) => {
const newKey = String(value); const newKey = String(value);
@ -86,23 +104,26 @@ export function OsvTabs({ summary, versionId, status }: Props) {
return; return;
} }
const storage = safeSessionStorage(); const storage = safeSessionStorage();
let known = summary ?? fetched; let found: OsvSummaryOut | null = known;
let decision = decideOsvLeave({ summary: known, versionId, status, storage }); let decision: 'warn' | 'go' | 'perform' = decideOsvLeave({ summary: found, versionId, status, storage });
if (decision === 'perform') { if (decision === 'perform') {
setChecking(true); setChecking(true);
const requestedFor = versionId;
let result: OsvSummaryOut | null = null;
try { try {
known = await getOsvSummary(versionId); result = await getOsvSummary(requestedFor);
setFetched(known);
decision = decideOsvLeave({ summary: known, versionId, status, storage });
} catch { } catch {
// не удалось проверить — не держим пользователя на вкладке // не удалось проверить — не держим пользователя на вкладке
decision = 'go';
} finally {
setChecking(false);
} }
if (!liveRef.current.mounted) return;
setChecking(false);
if (liveRef.current.versionId !== requestedFor) return;
if (result) setFetched({ versionId: requestedFor, summary: result });
found = result;
decision = resolveLeaveAfterFetch({ summary: result, versionId, status, storage });
} }
if (decision === 'warn' && known) { if (decision === 'warn' && found) {
rememberCounterPairsShown(versionId, known.counter_pairs, storage); rememberCounterPairsShown(versionId, found.counter_pairs, storage);
setPending(newKey); setPending(newKey);
} else { } else {
go(newKey); go(newKey);
@ -142,7 +163,7 @@ export function OsvTabs({ summary, versionId, status }: Props) {
<CounterPairsModal <CounterPairsModal
open={pending !== null} open={pending !== null}
pairs={shownPairs} pairs={shownPairs}
unmappedTotal={(summary ?? fetched)?.unmapped_total ?? 0} unmappedTotal={known?.unmapped_total ?? 0}
onShow={() => { onShow={() => {
setPending(null); setPending(null);
navigate( navigate(

View File

@ -30,6 +30,8 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) {
const [summary, setSummary] = useState<OsvSummaryOut | null>(null); const [summary, setSummary] = useState<OsvSummaryOut | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// ошибка загрузки сводки (для режима пар: без неё там не на что смотреть)
const [summaryError, setSummaryError] = useState<string | null>(null);
// номер последнего запроса: ответы более старых запросов игнорируются // номер последнего запроса: ответы более старых запросов игнорируются
const requestRef = useRef(0); const requestRef = useRef(0);
@ -40,9 +42,15 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) {
const requestId = ++summaryRef.current; const requestId = ++summaryRef.current;
try { try {
const s = await getOsvSummary(versionId); const s = await getOsvSummary(versionId);
if (requestId === summaryRef.current) setSummary(s); if (requestId === summaryRef.current) {
setSummary(s);
setSummaryError(null);
}
} catch (e: unknown) { } catch (e: unknown) {
if (requestId === summaryRef.current) message.error(osvErrorText(e)); if (requestId === summaryRef.current) {
message.error(osvErrorText(e));
setSummaryError(osvErrorText(e));
}
} }
}, [versionId]); }, [versionId]);
@ -76,7 +84,12 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) {
}, [versionId, filters, page]); }, [versionId, filters, page]);
useEffect(() => { useEffect(() => {
if (pairsMode) return; if (pairsMode) {
// ответ ещё летящего запроса строк больше не нужен
requestRef.current++;
setLoading(false);
return;
}
void load(); void load();
}, [load, pairsMode]); }, [load, pairsMode]);
@ -90,5 +103,5 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) {
void loadSummary(); 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 };
} }

View File

@ -71,18 +71,35 @@ export function OsvContent({ card, onSummaryChange }: Props) {
// вне вкладки «ОСВ» предупреждать не о чем // вне вкладки «ОСВ» предупреждать не о чем
useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]); useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]);
const pairsBody = s ? ( const renderPairsBody = () => {
<CounterPairsView if (s) {
versionId={version_id ?? ''} return (
pairs={pairs} <CounterPairsView
selected={selectedPair} versionId={version_id ?? ''}
numFormat={format} pairs={pairs}
onSelect={(holder, missing) => setView({ view: 'pairs', holder, missing })} selected={selectedPair}
onMapped={osv.refresh} numFormat={format}
/> onSelect={(holder, missing) => setView({ view: 'pairs', holder, missing })}
) : ( onMapped={osv.refresh}
!osv.error && <Spin style={{ alignSelf: 'center', margin: 24 }} /> />
); );
}
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 ( return (
<> <>
@ -161,7 +178,7 @@ export function OsvContent({ card, onSummaryChange }: Props) {
</div> </div>
)} )}
{inPairs ? ( {inPairs ? (
pairsBody renderPairsBody()
) : ( ) : (
<> <>
<OsvGaps gaps={s?.gaps ?? []} /> <OsvGaps gaps={s?.gaps ?? []} />