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:
parent
403f086bb1
commit
1249e2ff3b
@ -1,14 +1,14 @@
|
||||
# consolidated-osv-ui Specification
|
||||
|
||||
## Purpose
|
||||
Аналитик видит сводную ОСВ версии [30]: сводку с несопоставленными строками, пробелы по кварталам, таблицу с фильтрами в заголовках колонок и экспорт в xlsx.
|
||||
Аналитик видит сводную ОСВ версии [30]: сводку с несопоставленными строками, пробелы по кварталам, таблицу с фильтрами в заголовках колонок и экспорт в xlsx; сопоставляет контрагента строки с ЮЛ справочника [32].
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Сводка версии
|
||||
Экран «Сводная ОСВ» [30] SHALL показывать шапку версии (ГК, период, номер, статус) и сводку
|
||||
`GET /consolidations/versions/{id}/osv/summary`: строк всего, документов, несопоставленных (`unmapped_total` — счётчик
|
||||
«Осталось N» без перехода к сопоставлению), автоматически сопоставленных.
|
||||
«Осталось N»; сопоставление — «Указать» в строке), автоматически сопоставленных.
|
||||
|
||||
#### Scenario: Контрольные цифры РБ 2024 Q3
|
||||
- **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,
|
||||
сальдо кон. Кт 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: Пробелы
|
||||
Экран SHALL показывать `gaps[]` сводки списком «ЮЛ — счёт — недостающие кварталы» с пояснением, что строки этих пар в
|
||||
своде отсутствуют.
|
||||
|
||||
@ -8,19 +8,24 @@ import { TableTag } from '../components/TableTag/TableTag';
|
||||
import { TAG_PRESETS } from '../components/TableTag/presets';
|
||||
import { getVersionCard } from '../services/api/consolidations';
|
||||
import { exportOsv } from '../services/api/osv';
|
||||
import type { ConsolidatedRowOut } from '../types/api';
|
||||
import type { ConsRussianStatus } from '../types/consolidations/common_types';
|
||||
import type { VersionCard } from '../types/consolidations/version_members';
|
||||
import { exportFilename, saveBlob } from '../utils/download';
|
||||
import { quarterShortLabel } from '../utils/labels';
|
||||
import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts';
|
||||
import { CounterpartyMapModal } from '../widgets/ConsolidatedOsv/CounterpartyMapModal';
|
||||
import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps';
|
||||
import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable';
|
||||
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
|
||||
import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping';
|
||||
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv';
|
||||
|
||||
function OsvContent({ versionId }: { versionId: string }) {
|
||||
const [card, setCard] = useState<VersionCard | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
// строка, для которой открыт выбор организации («Указать»)
|
||||
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
|
||||
const osv = useConsolidatedOsv(versionId);
|
||||
|
||||
useEffect(() => {
|
||||
@ -125,8 +130,18 @@ function OsvContent({ versionId }: { versionId: string }) {
|
||||
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
|
||||
onPage={osv.setPage}
|
||||
onFilter={osv.patchFilters}
|
||||
onMap={setMapping}
|
||||
/>
|
||||
</main>
|
||||
{mapping && (
|
||||
<CounterpartyMapModal
|
||||
key={mapEntryId(mapping)}
|
||||
versionId={versionId}
|
||||
row={mapping}
|
||||
onClose={() => setMapping(null)}
|
||||
onMapped={osv.refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { api, apiFetchBlob } from '../../api/client';
|
||||
import type { ConsolidationCreate } from '../../types/api';
|
||||
import type { LockOut } from '../../types/consolidations/common_types';
|
||||
import type { IConsolidation } from '../../types/consolidations/consolidations';
|
||||
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 createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body);
|
||||
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`);
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
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 {
|
||||
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 })}`);
|
||||
export const getOsvSummary = (versionId: string) => api.get<OsvSummaryOut>(`${base(versionId)}/summary`);
|
||||
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);
|
||||
|
||||
@ -197,6 +197,33 @@ export interface ConsolidatedRowOut {
|
||||
closing_credit: string | null;
|
||||
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 {
|
||||
legal_entity_id: string;
|
||||
legal_entity_name: string;
|
||||
|
||||
251
src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx
Normal file
251
src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import { TableTag } from '../../components/TableTag/TableTag';
|
||||
import type { ConsolidatedRowOut, CounterpartyType, OsvFilters } from '../../types/api';
|
||||
import { counterpartyTypeLabel, osvPeriodLabel } from '../../utils/labels';
|
||||
import { formatAmount } from '../../utils/money';
|
||||
import { needsMapping } from './counterpartyMapping';
|
||||
import { COUNTERPARTY_TYPES, counterpartyTypeBadge } from './counterpartyTypeBadge';
|
||||
import type { OsvFilterDraft } from './osvFilterState';
|
||||
|
||||
@ -172,9 +173,27 @@ type Props = {
|
||||
members: Member[];
|
||||
onPage: (p: number) => void;
|
||||
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 columns: TableColumnsType<ConsolidatedRowOut> = [
|
||||
{
|
||||
@ -214,7 +233,14 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member
|
||||
filterIcon: filterIcon(!!filters.q),
|
||||
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('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]),
|
||||
group('Сальдо на конец периода', [money('closing_debit', 'Дебет'), money('closing_credit', 'Кредит')]),
|
||||
@ -241,7 +267,7 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member
|
||||
locale={{
|
||||
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}` }}
|
||||
/>
|
||||
);
|
||||
|
||||
148
src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts
Normal file
148
src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts
Normal 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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
76
src/widgets/ConsolidatedOsv/counterpartyMapping.ts
Normal file
76
src/widgets/ConsolidatedOsv/counterpartyMapping.ts
Normal 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(' ');
|
||||
}
|
||||
@ -28,20 +28,27 @@ export function useConsolidatedOsv(versionId: string) {
|
||||
// номер последнего запроса: ответы более старых запросов игнорируются
|
||||
const requestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let actual = true;
|
||||
getOsvSummary(versionId)
|
||||
.then((s) => {
|
||||
if (actual) setSummary(s);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (actual) message.error(osvErrorText(e));
|
||||
});
|
||||
return () => {
|
||||
actual = false;
|
||||
};
|
||||
// номер последнего запроса сводки: перечитывание после сопоставления не перетирается более старым ответом
|
||||
const summaryRef = useRef(0);
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
const requestId = ++summaryRef.current;
|
||||
try {
|
||||
const s = await getOsvSummary(versionId);
|
||||
if (requestId === summaryRef.current) setSummary(s);
|
||||
} catch (e: unknown) {
|
||||
if (requestId === summaryRef.current) message.error(osvErrorText(e));
|
||||
}
|
||||
}, [versionId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSummary();
|
||||
return () => {
|
||||
// размонтирование: ответ уже отправленного запроса игнорируется
|
||||
summaryRef.current++;
|
||||
};
|
||||
}, [loadSummary]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const requestId = ++requestRef.current;
|
||||
setLoading(true);
|
||||
@ -73,5 +80,11 @@ export function useConsolidatedOsv(versionId: string) {
|
||||
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 };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user