feat(osv): сопоставление контрагента в сводной ОСВ — «Указать» → «Связать» по макету [32] (#14)

- ИНН-ячейка строк без сопоставления подсвечена, ссылка «Указать» рядом с ИНН из файла [32-osv-no-inn].
- Модалка «Выбор организации для связи с …» [32-osv-map-counterparty]: поиск с паузой 300 мс,
  scope group / весь справочник (от 3 символов), блок «Рекомендуемые», «Связать».
- POST …/osv/entries/{последняя entry_id}/counterparty; 409 ALIAS_TAKEN → перенос алиаса (move_alias),
  LOCK_REQUIRED → «Взять версию в работу?» → POST …/lock → повтор, LOCKED_BY_OTHER → имя держателя.
- После успеха перечитываются строки и сводка («Осталось N»).
- Чистые функции counterpartyMapping.ts + тесты; спека consolidated-osv-ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-08 17:39:23 +04:00
parent 403f086bb1
commit 1249e2ff3b
10 changed files with 618 additions and 19 deletions

View File

@ -1,14 +1,14 @@
# consolidated-osv-ui Specification # consolidated-osv-ui Specification
## Purpose ## Purpose
Аналитик видит сводную ОСВ версии [30]: сводку с несопоставленными строками, пробелы по кварталам, таблицу с фильтрами в заголовках колонок и экспорт в xlsx. Аналитик видит сводную ОСВ версии [30]: сводку с несопоставленными строками, пробелы по кварталам, таблицу с фильтрами в заголовках колонок и экспорт в xlsx; сопоставляет контрагента строки с ЮЛ справочника [32].
## Requirements ## Requirements
### Requirement: Сводка версии ### Requirement: Сводка версии
Экран «Сводная ОСВ» [30] SHALL показывать шапку версии (ГК, период, номер, статус) и сводку Экран «Сводная ОСВ» [30] SHALL показывать шапку версии (ГК, период, номер, статус) и сводку
`GET /consolidations/versions/{id}/osv/summary`: строк всего, документов, несопоставленных (`unmapped_total` — счётчик `GET /consolidations/versions/{id}/osv/summary`: строк всего, документов, несопоставленных (`unmapped_total` — счётчик
«Осталось N» без перехода к сопоставлению), автоматически сопоставленных. «Осталось N»; сопоставление — «Указать» в строке), автоматически сопоставленных.
#### Scenario: Контрольные цифры РБ 2024 Q3 #### Scenario: Контрольные цифры РБ 2024 Q3
- **WHEN** открыт свод версии РБ 2024 Q3 после подтверждения 22 ОСВ из `fixtures/osv/{rb,schastl}/2024-Q{1,2,3}` - **WHEN** открыт свод версии РБ 2024 Q3 после подтверждения 22 ОСВ из `fixtures/osv/{rb,schastl}/2024-Q{1,2,3}`
@ -28,6 +28,32 @@
- **THEN** строка `60.01` показывает сальдо нач. Кт 4 454 151.93, обороты Дт 128 058 143.65, Кт 125 950 450.77, - **THEN** строка `60.01` показывает сальдо нач. Кт 4 454 151.93, обороты Дт 128 058 143.65, Кт 125 950 450.77,
сальдо кон. Кт 2 346 459.05, признак «ГСК» сальдо кон. Кт 2 346 459.05, признак «ГСК»
### Requirement: Ручное сопоставление контрагента
У строки без сопоставленного ЮЛ (`counterparty_id = null`) ячейка ИНН SHALL быть подсвечена и содержать ссылку
«Указать» (рядом с ИНН из файла, если он есть), как в макете [32-osv-no-inn]. «Указать» SHALL открывать модалку
«Выбор организации для связи с <имя контрагента из файла>» [32-osv-map-counterparty]: поиск, таблица ИНН / Организация /
ГК / ГСК / ГСЗ / «Связать», рекомендуемые (`recommended`) — первыми под заголовком «Рекомендуемые» с зелёной подсветкой.
Кандидаты — `GET …/counterparty-candidates?scope=&entry_id=&q=` с паузой 300 мс после ввода: по умолчанию `scope=group`
(периметр и ГК); галочка «Искать во всём справочнике» — `scope=all`, при поиске короче 3 символов запрос не отправляется
и показывается подсказка.
«Связать» SHALL отправлять `POST …/osv/entries/{entry_ids.at(-1)}/counterparty {legal_entity_id}`, после успеха —
сообщение (с кем связано, сколько записей ОСВ сопоставлено автоприменением, создан или перенесён алиас), закрывать
модалку и перечитывать страницу свода и сводку («Осталось N» на клиенте не вычисляется). Ответы 409:
- `ALIAS_TAKEN` — подтверждение «Имя уже закреплено за <владельцы>. Перенести?» → повтор с `move_alias = true`;
- `LOCK_REQUIRED` — подтверждение «Взять версию в работу?» → `POST …/lock` → повтор; отказ — ничего не меняется;
- `LOCKED_BY_OTHER` (от правки или от `POST …/lock`) — «Версию держит <имя>» (имя — из ответа `POST …/lock` или из
`lock` карточки версии), без перехвата;
- `VERSION_NOT_EDITABLE` и прочие ошибки — текстом сервера.
#### Scenario: Сопоставление «Пекарня Счастливочная ООО» в ОСВ РБ
- **WHEN** в своде РБ 2024 Q3 у строки контрагента «Пекарня Счастливочная ООО» без сопоставления нажато «Указать» и у
рекомендуемой «Пекарни Счастливочной» — «Связать» (версия не взята в работу → «Взять в работу»)
- **THEN** строка показывает ЮЛ и тип «ГСК», «Осталось N» уменьшается на число сопоставленных строк, у ЮЛ появляется алиас
#### Scenario: Алиас занят
- **WHEN** имя строки уже алиас другого ЮЛ и аналитик выбирает третье ЮЛ
- **THEN** показан вопрос о переносе с именами владельцев; «Перенести» повторяет запрос с `move_alias = true`
### Requirement: Пробелы ### Requirement: Пробелы
Экран SHALL показывать `gaps[]` сводки списком «ЮЛ — счёт — недостающие кварталы» с пояснением, что строки этих пар в Экран SHALL показывать `gaps[]` сводки списком «ЮЛ — счёт — недостающие кварталы» с пояснением, что строки этих пар в
своде отсутствуют. своде отсутствуют.

View File

@ -8,19 +8,24 @@ import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets'; import { TAG_PRESETS } from '../components/TableTag/presets';
import { getVersionCard } from '../services/api/consolidations'; import { getVersionCard } from '../services/api/consolidations';
import { exportOsv } from '../services/api/osv'; import { exportOsv } from '../services/api/osv';
import type { ConsolidatedRowOut } from '../types/api';
import type { ConsRussianStatus } from '../types/consolidations/common_types'; import type { ConsRussianStatus } from '../types/consolidations/common_types';
import type { VersionCard } from '../types/consolidations/version_members'; import type { VersionCard } from '../types/consolidations/version_members';
import { exportFilename, saveBlob } from '../utils/download'; import { exportFilename, saveBlob } from '../utils/download';
import { quarterShortLabel } from '../utils/labels'; import { quarterShortLabel } from '../utils/labels';
import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts'; import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts';
import { CounterpartyMapModal } from '../widgets/ConsolidatedOsv/CounterpartyMapModal';
import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps'; import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable'; import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable';
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv'; import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv';
function OsvContent({ versionId }: { versionId: string }) { function OsvContent({ versionId }: { versionId: string }) {
const [card, setCard] = useState<VersionCard | null>(null); const [card, setCard] = useState<VersionCard | null>(null);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
// строка, для которой открыт выбор организации («Указать»)
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
const osv = useConsolidatedOsv(versionId); const osv = useConsolidatedOsv(versionId);
useEffect(() => { useEffect(() => {
@ -125,8 +130,18 @@ function OsvContent({ versionId }: { versionId: string }) {
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))} members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
onPage={osv.setPage} onPage={osv.setPage}
onFilter={osv.patchFilters} onFilter={osv.patchFilters}
onMap={setMapping}
/> />
</main> </main>
{mapping && (
<CounterpartyMapModal
key={mapEntryId(mapping)}
versionId={versionId}
row={mapping}
onClose={() => setMapping(null)}
onMapped={osv.refresh}
/>
)}
</div> </div>
); );
} }

View File

@ -1,5 +1,6 @@
import { api, apiFetchBlob } from '../../api/client'; import { api, apiFetchBlob } from '../../api/client';
import type { ConsolidationCreate } from '../../types/api'; import type { ConsolidationCreate } from '../../types/api';
import type { LockOut } from '../../types/consolidations/common_types';
import type { IConsolidation } from '../../types/consolidations/consolidations'; import type { IConsolidation } from '../../types/consolidations/consolidations';
import type { VersionCard } from '../../types/consolidations/version_members'; import type { VersionCard } from '../../types/consolidations/version_members';
@ -7,3 +8,5 @@ export const getConsolidations = (groupId: string) => api.get<IConsolidation[]>(
export const getVersionCard = (versionId: string) => api.get<VersionCard>(`/consolidations/versions/${versionId}`); export const getVersionCard = (versionId: string) => api.get<VersionCard>(`/consolidations/versions/${versionId}`);
export const createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body); export const createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body);
export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`); export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`);
/** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */
export const lockVersion = (versionId: string) => api.post<LockOut>(`/consolidations/versions/${versionId}/lock`);

View File

@ -1,5 +1,13 @@
import { api, apiFetchBlob } from '../../api/client'; import { api, apiFetchBlob } from '../../api/client';
import type { ConsolidatedRowOut, OsvFilters, OsvSummaryOut, Page } from '../../types/api'; import type {
CandidateOut,
ConsolidatedRowOut,
MapCounterpartyIn,
MapCounterpartyOut,
OsvFilters,
OsvSummaryOut,
Page,
} from '../../types/api';
export function buildOsvQuery(filters: OsvFilters, page?: { limit: number; offset: number }): string { export function buildOsvQuery(filters: OsvFilters, page?: { limit: number; offset: number }): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -23,3 +31,9 @@ export const getConsolidatedOsv = (versionId: string, filters: OsvFilters, limit
api.get<Page<ConsolidatedRowOut>>(`${base(versionId)}${buildOsvQuery(filters, { limit, offset })}`); api.get<Page<ConsolidatedRowOut>>(`${base(versionId)}${buildOsvQuery(filters, { limit, offset })}`);
export const getOsvSummary = (versionId: string) => api.get<OsvSummaryOut>(`${base(versionId)}/summary`); export const getOsvSummary = (versionId: string) => api.get<OsvSummaryOut>(`${base(versionId)}/summary`);
export const exportOsv = (versionId: string, filters: OsvFilters) => apiFetchBlob(`${base(versionId)}/export${buildOsvQuery(filters)}`); export const exportOsv = (versionId: string, filters: OsvFilters) => apiFetchBlob(`${base(versionId)}/export${buildOsvQuery(filters)}`);
/** `query` — из `buildCandidatesQuery` (виджет ConsolidatedOsv). */
export const getCounterpartyCandidates = (versionId: string, query: string) =>
api.get<CandidateOut[]>(`/consolidations/versions/${versionId}/counterparty-candidates${query}`);
export const mapCounterparty = (versionId: string, entryId: string, body: MapCounterpartyIn) =>
api.post<MapCounterpartyOut>(`${base(versionId)}/entries/${entryId}/counterparty`, body);

View File

@ -197,6 +197,33 @@ export interface ConsolidatedRowOut {
closing_credit: string | null; closing_credit: string | null;
warnings: string[]; warnings: string[];
} }
export interface RefOut {
id: string;
name: string;
}
export type CandidateScope = 'group' | 'all';
/** Кандидат ручного сопоставления (`GET …/counterparty-candidates`). */
export interface CandidateOut {
id: string;
inn: string;
name: string;
company_group: RefOut | null;
gsk: RefOut | null;
gsz: RefOut | null;
counterparty_type: CounterpartyType;
recommended: boolean;
}
export interface MapCounterpartyIn {
legal_entity_id: string;
move_alias?: boolean;
}
/** `row = null` — строка в свод не попадает; `propagated` — число записей osv_entry, а не строк свода. */
export interface MapCounterpartyOut {
row: ConsolidatedRowOut | null;
propagated: number;
alias_created: boolean;
alias_moved_from: string | null;
}
export interface GapOut { export interface GapOut {
legal_entity_id: string; legal_entity_id: string;
legal_entity_name: string; legal_entity_name: string;

View File

@ -0,0 +1,251 @@
import { SearchOutlined } from '@ant-design/icons';
import { Button, Checkbox, Input, Modal, Table, message } from 'antd';
import type { TableColumnsType } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { ApiError } from '../../api/client';
import { getVersionCard, lockVersion } from '../../services/api/consolidations';
import { getCounterpartyCandidates, mapCounterparty } from '../../services/api/osv';
import type { CandidateOut, CandidateScope, ConsolidatedRowOut, RefOut } from '../../types/api';
import {
ALL_SCOPE_MIN_QUERY,
type CandidateRow,
aliasOwners,
buildCandidatesQuery,
candidateRows,
lockedByName,
mapEntryId,
mapErrorKind,
mapResultText,
} from './counterpartyMapping';
const SEARCH_DELAY_MS = 300;
// Цвета — из `counterparty-map-modal__*` макета [32-osv-map-counterparty]
const TITLE_COLOR = '#1f7a3a';
const RECOMMENDED_BG = '#f5fcf6';
const SECTION_STYLE: React.CSSProperties = { background: '#eefbf1', color: '#15803d', fontSize: 12, fontWeight: 700, padding: '6px 10px' };
const RELATION_BADGE: Record<'gsk' | 'gsz', React.CSSProperties> = {
gsk: { borderColor: '#d7e1f2', background: '#f8faff', color: '#5b6888' },
gsz: { borderColor: '#d8c8ff', background: '#f5f0ff', color: '#6941c6' },
};
function RelationBadge({ kind, value }: { kind: 'gsk' | 'gsz'; value: RefOut | null }) {
if (!value) return <>—</>;
return (
<span
style={{
display: 'inline-flex',
maxWidth: '100%',
padding: '3px 9px',
border: '1px solid',
borderRadius: 999,
fontSize: 12,
fontWeight: 500,
lineHeight: 1.25,
...RELATION_BADGE[kind],
}}>
{value.name}
</span>
);
}
type Props = {
versionId: string;
row: ConsolidatedRowOut;
onClose: () => void;
/** Сопоставление записано: перечитать строки и сводку. */
onMapped: () => void;
};
/** Модалка «Выбор организации для связи с …» [32-osv-map-counterparty]: кандидаты, поиск и «Связать». */
export function CounterpartyMapModal({ versionId, row, onClose, onMapped }: Props) {
const [q, setQ] = useState('');
const [scope, setScope] = useState<CandidateScope>('group');
const [candidates, setCandidates] = useState<CandidateOut[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submittingId, setSubmittingId] = useState<string | null>(null);
// confirm через хук, а не статический Modal.confirm: в приложении нет antd <App> (arm-web#7)
const [modal, contextHolder] = Modal.useModal();
const requestRef = useRef(0);
const entryId = mapEntryId(row);
const sourceName = row.counterparty_name?.trim() || '—';
const query = buildCandidatesQuery(entryId, scope, q);
useEffect(() => {
const requestId = ++requestRef.current;
if (query === null) {
setCandidates([]);
setLoading(false);
return;
}
setLoading(true);
// поиск — после паузы в наборе, ответ устаревшего запроса игнорируется
const timer = setTimeout(() => {
getCounterpartyCandidates(versionId, query)
.then((list) => {
if (requestId !== requestRef.current) return;
setCandidates(list);
setError(null);
})
.catch((e: unknown) => {
if (requestId !== requestRef.current) return;
setCandidates([]);
setError(e instanceof ApiError ? e.message : 'Не удалось получить список организаций');
})
.finally(() => {
if (requestId === requestRef.current) setLoading(false);
});
}, SEARCH_DELAY_MS);
return () => clearTimeout(timer);
}, [versionId, query]);
const lockedByOther = async (e: unknown) => {
let name = lockedByName(e);
if (!name)
name = await getVersionCard(versionId).then(
(c) => c.lock?.locked_by_name ?? null,
() => null,
);
message.error(`Версию держит ${name ?? 'другой пользователь'}: правки недоступны, пока блокировка не снята`);
};
/** «Связать»: на 409 спрашивает о переносе алиаса / взятии в работу и повторяет запрос. */
const link = async (c: CandidateOut, moveAlias: boolean, lockTaken: boolean): Promise<void> => {
setSubmittingId(c.id);
try {
const out = await mapCounterparty(versionId, entryId, { legal_entity_id: c.id, move_alias: moveAlias });
message.success(mapResultText(out, c.name));
onMapped();
onClose();
} catch (e: unknown) {
const kind = mapErrorKind(e);
if (kind === 'ALIAS_TAKEN') {
const owners = aliasOwners(e)
.map((o) => o.name)
.join(', ');
const confirmed = await modal.confirm({
title: 'Имя уже закреплено за другой организацией',
content: `«${sourceName}» — алиас ${owners || 'другой организации'}. Перенести алиас на ${c.name}?`,
okText: 'Перенести',
cancelText: 'Отмена',
});
if (confirmed) await link(c, true, lockTaken);
} else if (kind === 'LOCK_REQUIRED' && !lockTaken) {
const confirmed = await modal.confirm({
title: 'Взять версию в работу?',
content:
'Сопоставление — правка версии, для неё нужна ваша блокировка. Пока она у вас, другие пользователи не смогут править версию.',
okText: 'Взять в работу',
cancelText: 'Отмена',
});
if (!confirmed) return;
try {
await lockVersion(versionId);
} catch (le: unknown) {
if (mapErrorKind(le) === 'LOCKED_BY_OTHER') await lockedByOther(le);
else message.error(le instanceof ApiError ? le.message : 'Не удалось взять версию в работу');
return;
}
await link(c, moveAlias, true);
} else if (kind === 'LOCKED_BY_OTHER') {
await lockedByOther(e);
} else if (kind === 'VERSION_NOT_EDITABLE') {
message.error(`Сопоставление недоступно: ${(e as ApiError).message}`);
} else {
message.error(e instanceof ApiError ? e.message : 'Не удалось связать организацию');
}
} finally {
setSubmittingId(null);
}
};
// Строка-заголовок «Рекомендуемые» растягивается на все колонки, у остальных колонок она скрыта
const sectionSpan = (r: CandidateRow, first = false): React.TdHTMLAttributes<HTMLTableCellElement> =>
r.kind === 'section'
? { colSpan: first ? 6 : 0, style: SECTION_STYLE }
: { style: r.recommended ? { background: RECOMMENDED_BG, boxShadow: first ? 'inset 3px 0 #63b77c' : undefined } : undefined };
const cell = (render: (c: CandidateOut) => React.ReactNode) => (_: unknown, r: CandidateRow) =>
r.kind === 'section' ? r.label : render(r.candidate);
const columns: TableColumnsType<CandidateRow> = [
{ key: 'inn', title: 'ИНН', width: 132, onCell: (r) => sectionSpan(r, true), render: cell((c) => c.inn) },
{ key: 'name', title: 'Организация', onCell: (r) => sectionSpan(r), render: cell((c) => <strong>{c.name}</strong>) },
{ key: 'company_group', title: 'ГК', width: 150, onCell: (r) => sectionSpan(r), render: cell((c) => c.company_group?.name ?? '—') },
{
key: 'gsk',
title: 'ГСК',
width: 170,
onCell: (r) => sectionSpan(r),
render: cell((c) => <RelationBadge kind='gsk' value={c.gsk} />),
},
{
key: 'gsz',
title: 'ГСЗ',
width: 170,
onCell: (r) => sectionSpan(r),
render: cell((c) => <RelationBadge kind='gsz' value={c.gsz} />),
},
{
key: 'action',
width: 110,
align: 'center',
onCell: (r) => sectionSpan(r),
render: cell((c) => (
<Button
type='primary'
size='small'
loading={submittingId === c.id}
disabled={submittingId !== null && submittingId !== c.id}
onClick={() => void link(c, false, false)}>
Связать
</Button>
)),
},
];
const tooShort = scope === 'all' && query === null;
const emptyText = tooShort
? `Для поиска по всему справочнику введите не меньше ${ALL_SCOPE_MIN_QUERY} символов`
: 'Организации не найдены';
return (
<Modal
open
width={1040}
footer={null}
onCancel={onClose}
title={
<>
Выбор организации для связи с <span style={{ color: TITLE_COLOR }}>{sourceName}</span>
</>
}>
{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>
{error && <div style={{ color: '#b42318' }}>{error}</div>}
<Table<CandidateRow>
size='small'
rowKey='key'
loading={loading}
columns={columns}
dataSource={candidateRows(candidates)}
pagination={false}
scroll={{ y: 420 }}
locale={{ emptyText }}
/>
</div>
</Modal>
);
}

View File

@ -6,6 +6,7 @@ import { TableTag } from '../../components/TableTag/TableTag';
import type { ConsolidatedRowOut, CounterpartyType, OsvFilters } from '../../types/api'; import type { ConsolidatedRowOut, CounterpartyType, OsvFilters } from '../../types/api';
import { counterpartyTypeLabel, osvPeriodLabel } from '../../utils/labels'; import { counterpartyTypeLabel, osvPeriodLabel } from '../../utils/labels';
import { formatAmount } from '../../utils/money'; import { formatAmount } from '../../utils/money';
import { needsMapping } from './counterpartyMapping';
import { COUNTERPARTY_TYPES, counterpartyTypeBadge } from './counterpartyTypeBadge'; import { COUNTERPARTY_TYPES, counterpartyTypeBadge } from './counterpartyTypeBadge';
import type { OsvFilterDraft } from './osvFilterState'; import type { OsvFilterDraft } from './osvFilterState';
@ -172,9 +173,27 @@ type Props = {
members: Member[]; members: Member[];
onPage: (p: number) => void; onPage: (p: number) => void;
onFilter: OnChange; onFilter: OnChange;
/** «Указать» у строки без сопоставленного ЮЛ — открыть выбор организации [32-osv-map-counterparty]. */
onMap: (row: ConsolidatedRowOut) => void;
}; };
export function OsvTable({ rows, total, page, pageSize, loading, filters, members, onPage, onFilter }: Props) { // Подсветка ячейки и ссылки «Указать» — `osv-table__inn-cell--required` / `osv-inn-link-button` макета [32-osv-no-inn]
const REQUIRED_CELL: React.CSSProperties = { background: '#fff4ed', boxShadow: 'inset 0 0 0 1px #fdba74' };
const MAP_LINK: React.CSSProperties = { padding: 0, height: 'auto', color: '#b42318', fontSize: 13, fontWeight: 700 };
function InnCell({ row, onMap }: { row: ConsolidatedRowOut; onMap: (row: ConsolidatedRowOut) => void }) {
if (!needsMapping(row)) return <>{row.counterparty_inn}</>;
return (
<Space size={8} wrap>
{row.counterparty_inn}
<Button type='link' style={MAP_LINK} onClick={() => onMap(row)}>
Указать
</Button>
</Space>
);
}
export function OsvTable({ rows, total, page, pageSize, loading, filters, members, onPage, onFilter, onMap }: Props) {
const filtered = Object.keys(filters).length > 0; const filtered = Object.keys(filters).length > 0;
const columns: TableColumnsType<ConsolidatedRowOut> = [ const columns: TableColumnsType<ConsolidatedRowOut> = [
{ {
@ -214,7 +233,14 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member
filterIcon: filterIcon(!!filters.q), filterIcon: filterIcon(!!filters.q),
filterDropdown: ({ close }) => <CounterpartyFilter key={filters.q ?? ''} q={filters.q} onChange={onFilter} close={close} />, filterDropdown: ({ close }) => <CounterpartyFilter key={filters.q ?? ''} q={filters.q} onChange={onFilter} close={close} />,
}, },
{ key: 'counterparty_inn', title: 'ИНН', dataIndex: 'counterparty_inn', width: 130 }, {
key: 'counterparty_inn',
title: 'ИНН',
// шире макетных 130: ИНН из файла и «Указать» в одну строку
width: 190,
onCell: (r) => (needsMapping(r) ? { style: REQUIRED_CELL } : {}),
render: (_: unknown, r) => <InnCell row={r} onMap={onMap} />,
},
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]), group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
group('Обороты за период', [money('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]), group('Обороты за период', [money('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]),
group('Сальдо на конец периода', [money('closing_debit', 'Дебет'), money('closing_credit', 'Кредит')]), group('Сальдо на конец периода', [money('closing_debit', 'Дебет'), money('closing_credit', 'Кредит')]),
@ -241,7 +267,7 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member
locale={{ locale={{
emptyText: filtered ? 'По заданным фильтрам строк нет' : 'В своде пока нет строк: загрузите и подтвердите ОСВ организаций', emptyText: filtered ? 'По заданным фильтрам строк нет' : 'В своде пока нет строк: загрузите и подтвердите ОСВ организаций',
}} }}
scroll={{ x: 1830, y: 'calc(100vh - 460px)' }} scroll={{ x: 1890, y: 'calc(100vh - 460px)' }}
pagination={{ current: page, pageSize, total, showSizeChanger: false, onChange: onPage, showTotal: (t) => `Строк: ${t}` }} pagination={{ current: page, pageSize, total, showSizeChanger: false, onChange: onPage, showTotal: (t) => `Строк: ${t}` }}
/> />
); );

View File

@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest';
import { ApiError } from '../../api/client';
import type { CandidateOut, ConsolidatedRowOut } from '../../types/api';
import {
ALL_SCOPE_MIN_QUERY,
aliasOwners,
buildCandidatesQuery,
candidateRows,
lockedByName,
mapEntryId,
mapErrorKind,
mapResultText,
needsMapping,
} from './counterpartyMapping';
const row = (patch: Partial<ConsolidatedRowOut> = {}): ConsolidatedRowOut => ({
entry_ids: ['e1'],
legal_entity_id: 'le',
legal_entity_name: 'ООО «Русский бисквит»',
legal_entity_inn: '3528052718',
account_code: '60.01',
account_root: '60',
period: '2024 Q3',
is_cumulative: true,
counterparty_name: 'Пекарня Счастливочная ООО',
counterparty_inn: null,
counterparty_id: null,
counterparty_entity_name: null,
counterparty_type: 'EXTERNAL',
match_kind: null,
opening_debit: null,
opening_credit: null,
turnover_debit: null,
turnover_credit: null,
closing_debit: null,
closing_credit: null,
warnings: [],
...patch,
});
const candidate = (id: string, recommended = false): CandidateOut => ({
id,
inn: `inn-${id}`,
name: `ООО ${id}`,
company_group: null,
gsk: null,
gsz: null,
counterparty_type: 'GK',
recommended,
});
describe('needsMapping / mapEntryId', () => {
it('«Указать» — только у строки без сопоставленного ЮЛ', () => {
expect(needsMapping(row())).toBe(true);
expect(needsMapping(row({ counterparty_id: 'x' }))).toBe(false);
});
it('склеенная из кварталов строка сопоставляется по последней entry_id', () => {
expect(mapEntryId(row({ entry_ids: ['q1', 'q2', 'q3'] }))).toBe('q3');
});
});
describe('buildCandidatesQuery', () => {
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&q=%D0%B1%D0%B8%D1%81%D0%BA%D0%B2%D0%B8%D1%82');
});
it(`scope=all короче ${ALL_SCOPE_MIN_QUERY} символов — запроса нет (сервер ответил бы 422 QUERY_TOO_SHORT)`, () => {
expect(buildCandidatesQuery('e1', 'all', ' аб ')).toBeNull();
expect(buildCandidatesQuery('e1', 'all', 'абв')).toBe('?scope=all&entry_id=e1&q=%D0%B0%D0%B1%D0%B2');
});
it('q обрезается до 500 символов', () => {
const query = buildCandidatesQuery('e1', 'group', 'я'.repeat(600));
expect(new URLSearchParams(query ?? '').get('q')).toHaveLength(500);
});
});
describe('candidateRows', () => {
it('рекомендуемые — под заголовком «Рекомендуемые», остальные — следом без заголовка', () => {
const rows = candidateRows([candidate('a', true), candidate('b'), candidate('c', true)]);
expect(rows.map((r) => (r.kind === 'section' ? `#${r.label}` : r.candidate.id))).toEqual(['#Рекомендуемые', 'a', 'c', 'b']);
expect(rows.filter((r) => r.kind === 'candidate' && r.recommended).map((r) => r.key)).toEqual(['a', 'c']);
});
it('без рекомендуемых заголовка нет', () => {
expect(candidateRows([candidate('b')]).map((r) => r.kind)).toEqual(['candidate']);
expect(candidateRows([])).toEqual([]);
});
});
describe('mapErrorKind / aliasOwners', () => {
const conflict = (code: string, extra: Record<string, unknown> = {}) =>
new ApiError(409, code, 'текст', { detail: { code, message: 'текст', ...extra } });
it('распознаёт 409 сопоставления', () => {
expect(mapErrorKind(conflict('ALIAS_TAKEN'))).toBe('ALIAS_TAKEN');
expect(mapErrorKind(conflict('LOCK_REQUIRED'))).toBe('LOCK_REQUIRED');
expect(mapErrorKind(conflict('LOCKED_BY_OTHER'))).toBe('LOCKED_BY_OTHER');
expect(mapErrorKind(conflict('VERSION_NOT_EDITABLE'))).toBe('VERSION_NOT_EDITABLE');
expect(mapErrorKind(new ApiError(404, 'NOT_FOUND', 'нет'))).toBe('OTHER');
expect(mapErrorKind(new Error('x'))).toBe('OTHER');
});
it('владельцы алиаса — из detail.owners', () => {
const e = conflict('ALIAS_TAKEN', { owners: [{ legal_entity_id: 'o1', name: 'ООО «Ромашка»' }] });
expect(aliasOwners(e)).toEqual([{ legal_entity_id: 'o1', name: 'ООО «Ромашка»' }]);
});
it('без owners или с мусором — пустой список', () => {
expect(aliasOwners(conflict('ALIAS_TAKEN'))).toEqual([]);
expect(aliasOwners(conflict('ALIAS_TAKEN', { owners: [{ name: 1 }, 'x'] }))).toEqual([]);
expect(aliasOwners(new Error('x'))).toEqual([]);
});
});
describe('lockedByName', () => {
it('имя есть только в ответе POST …/lock; в 409 правки — null (имя берётся из карточки)', () => {
const lock = new ApiError(409, 'LOCKED_BY_OTHER', 'занято', {
detail: { code: 'LOCKED_BY_OTHER', message: 'занято', locked_by: 'u1', locked_by_name: 'Иванова А. А.', locked_at: 't' },
});
const edit = new ApiError(409, 'LOCKED_BY_OTHER', 'занято', { detail: { code: 'LOCKED_BY_OTHER', locked_by: 'u1', locked_at: 't' } });
expect(lockedByName(lock)).toBe('Иванова А. А.');
expect(lockedByName(edit)).toBeNull();
expect(lockedByName(new Error('x'))).toBeNull();
});
});
describe('mapResultText', () => {
it('связано, без автоприменения, алиас создан', () => {
expect(mapResultText({ row: null, propagated: 0, alias_created: true, alias_moved_from: null }, 'ООО «Ромашка»')).toBe(
'Связано с ООО «Ромашка». Имя из ОСВ сохранено как алиас организации.',
);
});
it('автоприменение и перенос алиаса', () => {
expect(mapResultText({ row: null, propagated: 3, alias_created: true, alias_moved_from: 'o1' }, 'ООО «Ромашка»')).toBe(
'Связано с ООО «Ромашка». Также сопоставлено строк ОСВ: 3. Алиас перенесён от другой организации.',
);
});
it('алиас уже был — про алиас не пишем', () => {
expect(mapResultText({ row: null, propagated: 1, alias_created: false, alias_moved_from: null }, 'ООО «Ромашка»')).toBe(
'Связано с ООО «Ромашка». Также сопоставлено строк ОСВ: 1.',
);
});
});

View File

@ -0,0 +1,76 @@
import { ApiError } from '../../api/client';
import type { CandidateOut, CandidateScope, ConsolidatedRowOut, MapCounterpartyOut } from '../../types/api';
/** `scope=all` с более коротким `q` сервер отклоняет (422 `QUERY_TOO_SHORT`). */
export const ALL_SCOPE_MIN_QUERY = 3;
const MAX_QUERY = 500;
/** «Указать» — у строки без сопоставленного ЮЛ (как баннер «Осталось N» = `unmapped_total`). */
export const needsMapping = (row: ConsolidatedRowOut): boolean => row.counterparty_id === null;
/** Склеенная из кварталов строка несёт несколько `entry_ids` (от раннего к позднему); сопоставляется последняя. */
export const mapEntryId = (row: ConsolidatedRowOut): string =>
// biome-ignore lint/nursery/useAtIndex: lib tsconfig — ES2020, Array.prototype.at недоступен
row.entry_ids[row.entry_ids.length - 1] ?? '';
/** Query `GET …/counterparty-candidates`; `null` — запрос не отправлять (`scope=all` без 3 символов поиска). */
export function buildCandidatesQuery(entryId: string, scope: CandidateScope, q: string): string | null {
const query = q.trim().slice(0, MAX_QUERY);
if (scope === 'all' && query.length < ALL_SCOPE_MIN_QUERY) return null;
const params = new URLSearchParams({ scope, entry_id: entryId });
if (query) params.set('q', query);
return `?${params.toString()}`;
}
export type CandidateRow =
| { kind: 'section'; key: string; label: string }
| { kind: 'candidate'; key: string; candidate: CandidateOut; recommended: boolean };
/** Строки таблицы модалки: заголовок «Рекомендуемые» и рекомендуемые кандидаты, затем остальные (макет [32-osv-map-counterparty]). */
export function candidateRows(candidates: CandidateOut[]): CandidateRow[] {
const toRow = (c: CandidateOut): CandidateRow => ({ kind: 'candidate', key: c.id, candidate: c, recommended: c.recommended });
const recommended = candidates.filter((c) => c.recommended).map(toRow);
const rest = candidates.filter((c) => !c.recommended).map(toRow);
const section: CandidateRow[] = recommended.length > 0 ? [{ kind: 'section', key: 'section:recommended', label: 'Рекомендуемые' }] : [];
return [...section, ...recommended, ...rest];
}
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'];
/** Вид ошибки `POST …/osv/entries/{entry_id}/counterparty`, на который интерфейс отвечает по-разному. */
export function mapErrorKind(e: unknown): MapErrorKind {
if (e instanceof ApiError && e.status === 409) {
const kind = KNOWN_CONFLICTS.find((k) => k === e.code);
if (kind) return kind;
}
return 'OTHER';
}
export type AliasOwner = { legal_entity_id: string; name: string };
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
/** Владельцы алиаса из `409 ALIAS_TAKEN` (`detail.owners[]`); непохожие элементы отбрасываются. */
export function aliasOwners(e: unknown): AliasOwner[] {
if (!(e instanceof ApiError) || !isRecord(e.body) || !isRecord(e.body.detail)) return [];
const owners = e.body.detail.owners;
if (!Array.isArray(owners)) return [];
return owners.filter((o): o is AliasOwner => isRecord(o) && typeof o.legal_entity_id === 'string' && typeof o.name === 'string');
}
/** Имя держателя блокировки: есть только в 409 `LOCKED_BY_OTHER` от `POST …/lock`; у правок версии — нет (берётся из карточки). */
export function lockedByName(e: unknown): string | null {
if (!(e instanceof ApiError) || !isRecord(e.body) || !isRecord(e.body.detail)) return null;
const name = e.body.detail.locked_by_name;
return typeof name === 'string' && name ? name : null;
}
/** Текст после успешного «Связать»: с кем связано, сколько строк сопоставлено автоприменением, что с алиасом. */
export function mapResultText(out: MapCounterpartyOut, entityName: string): string {
const parts = [`Связано с ${entityName}.`];
if (out.propagated > 0) parts.push(`Также сопоставлено строк ОСВ: ${out.propagated}.`);
if (out.alias_moved_from) parts.push('Алиас перенесён от другой организации.');
else if (out.alias_created) parts.push('Имя из ОСВ сохранено как алиас организации.');
return parts.join(' ');
}

View File

@ -28,20 +28,27 @@ export function useConsolidatedOsv(versionId: string) {
// номер последнего запроса: ответы более старых запросов игнорируются // номер последнего запроса: ответы более старых запросов игнорируются
const requestRef = useRef(0); const requestRef = useRef(0);
useEffect(() => { // номер последнего запроса сводки: перечитывание после сопоставления не перетирается более старым ответом
let actual = true; const summaryRef = useRef(0);
getOsvSummary(versionId)
.then((s) => { const loadSummary = useCallback(async () => {
if (actual) setSummary(s); const requestId = ++summaryRef.current;
}) try {
.catch((e: unknown) => { const s = await getOsvSummary(versionId);
if (actual) message.error(osvErrorText(e)); if (requestId === summaryRef.current) setSummary(s);
}); } catch (e: unknown) {
return () => { if (requestId === summaryRef.current) message.error(osvErrorText(e));
actual = false; }
};
}, [versionId]); }, [versionId]);
useEffect(() => {
void loadSummary();
return () => {
// размонтирование: ответ уже отправленного запроса игнорируется
summaryRef.current++;
};
}, [loadSummary]);
const load = useCallback(async () => { const load = useCallback(async () => {
const requestId = ++requestRef.current; const requestId = ++requestRef.current;
setLoading(true); setLoading(true);
@ -73,5 +80,11 @@ export function useConsolidatedOsv(versionId: string) {
setPage(next.page); setPage(next.page);
}; };
return { filters, patchFilters, page, setPage, rows, total, summary, loading, error }; /** После ручного сопоставления: «Осталось N» и строки меняются сразу у нескольких строк (автоприменение) — перечитать обе. */
const refresh = () => {
void load();
void loadSummary();
};
return { filters, patchFilters, page, setPage, rows, total, summary, loading, error, refresh };
} }