From 1249e2ff3bc26e08240dce82762a0c518dde4e9f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 17:39:23 +0400 Subject: [PATCH] =?UTF-8?q?feat(osv):=20=D1=81=D0=BE=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B2?= =?UTF-8?q?=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9=20=D0=9E=D0=A1?= =?UTF-8?q?=D0=92=20=E2=80=94=20=C2=AB=D0=A3=D0=BA=D0=B0=D0=B7=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=C2=BB=20=E2=86=92=20=C2=AB=D0=A1=D0=B2=D1=8F=D0=B7=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=C2=BB=20=D0=BF=D0=BE=20=D0=BC=D0=B0=D0=BA=D0=B5?= =?UTF-8?q?=D1=82=D1=83=20[32]=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ИНН-ячейка строк без сопоставления подсвечена, ссылка «Указать» рядом с ИНН из файла [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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/specs/consolidated-osv-ui/spec.md | 30 ++- src/layouts/ConsolidatedOsvPage.tsx | 15 ++ src/services/api/consolidations.ts | 3 + src/services/api/osv.ts | 16 +- src/types/api.ts | 27 ++ .../ConsolidatedOsv/CounterpartyMapModal.tsx | 251 ++++++++++++++++++ src/widgets/ConsolidatedOsv/OsvTable.tsx | 32 ++- .../counterpartyMapping.test.ts | 148 +++++++++++ .../ConsolidatedOsv/counterpartyMapping.ts | 76 ++++++ .../ConsolidatedOsv/useConsolidatedOsv.ts | 39 ++- 10 files changed, 618 insertions(+), 19 deletions(-) create mode 100644 src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx create mode 100644 src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts create mode 100644 src/widgets/ConsolidatedOsv/counterpartyMapping.ts diff --git a/openspec/specs/consolidated-osv-ui/spec.md b/openspec/specs/consolidated-osv-ui/spec.md index 770ef38..16c7197 100644 --- a/openspec/specs/consolidated-osv-ui/spec.md +++ b/openspec/specs/consolidated-osv-ui/spec.md @@ -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[]` сводки списком «ЮЛ — счёт — недостающие кварталы» с пояснением, что строки этих пар в своде отсутствуют. diff --git a/src/layouts/ConsolidatedOsvPage.tsx b/src/layouts/ConsolidatedOsvPage.tsx index 895d97c..f34e933 100644 --- a/src/layouts/ConsolidatedOsvPage.tsx +++ b/src/layouts/ConsolidatedOsvPage.tsx @@ -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(null); const [exporting, setExporting] = useState(false); + // строка, для которой открыт выбор организации («Указать») + const [mapping, setMapping] = useState(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} /> + {mapping && ( + setMapping(null)} + onMapped={osv.refresh} + /> + )} ); } diff --git a/src/services/api/consolidations.ts b/src/services/api/consolidations.ts index 2c328c7..e794227 100644 --- a/src/services/api/consolidations.ts +++ b/src/services/api/consolidations.ts @@ -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( export const getVersionCard = (versionId: string) => api.get(`/consolidations/versions/${versionId}`); export const createConsolidation = (body: ConsolidationCreate) => api.post('/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(`/consolidations/versions/${versionId}/lock`); diff --git a/src/services/api/osv.ts b/src/services/api/osv.ts index 6ec3674..8344613 100644 --- a/src/services/api/osv.ts +++ b/src/services/api/osv.ts @@ -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>(`${base(versionId)}${buildOsvQuery(filters, { limit, offset })}`); export const getOsvSummary = (versionId: string) => api.get(`${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(`/consolidations/versions/${versionId}/counterparty-candidates${query}`); +export const mapCounterparty = (versionId: string, entryId: string, body: MapCounterpartyIn) => + api.post(`${base(versionId)}/entries/${entryId}/counterparty`, body); diff --git a/src/types/api.ts b/src/types/api.ts index 922088f..91f6943 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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; diff --git a/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx b/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx new file mode 100644 index 0000000..ce88b34 --- /dev/null +++ b/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx @@ -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 ( + + {value.name} + + ); +} + +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('group'); + const [candidates, setCandidates] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [submittingId, setSubmittingId] = useState(null); + // confirm через хук, а не статический Modal.confirm: в приложении нет antd (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 => { + 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 => + 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 = [ + { 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) => {c.name}) }, + { 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) => ), + }, + { + key: 'gsz', + title: 'ГСЗ', + width: 170, + onCell: (r) => sectionSpan(r), + render: cell((c) => ), + }, + { + key: 'action', + width: 110, + align: 'center', + onCell: (r) => sectionSpan(r), + render: cell((c) => ( + + )), + }, + ]; + + const tooShort = scope === 'all' && query === null; + const emptyText = tooShort + ? `Для поиска по всему справочнику введите не меньше ${ALL_SCOPE_MIN_QUERY} символов` + : 'Организации не найдены'; + return ( + + Выбор организации для связи с {sourceName} + + }> + {contextHolder} +
+ } + placeholder='Поиск' + maxLength={500} + value={q} + onChange={(e) => setQ(e.target.value)} + /> + setScope(e.target.checked ? 'all' : 'group')}> + Искать во всём справочнике (не только в группе) + + {error &&
{error}
} + + size='small' + rowKey='key' + loading={loading} + columns={columns} + dataSource={candidateRows(candidates)} + pagination={false} + scroll={{ y: 420 }} + locale={{ emptyText }} + /> +
+
+ ); +} diff --git a/src/widgets/ConsolidatedOsv/OsvTable.tsx b/src/widgets/ConsolidatedOsv/OsvTable.tsx index 5c93fdf..f96ccab 100644 --- a/src/widgets/ConsolidatedOsv/OsvTable.tsx +++ b/src/widgets/ConsolidatedOsv/OsvTable.tsx @@ -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 ( + + {row.counterparty_inn} + + + ); +} + +export function OsvTable({ rows, total, page, pageSize, loading, filters, members, onPage, onFilter, onMap }: Props) { const filtered = Object.keys(filters).length > 0; const columns: TableColumnsType = [ { @@ -214,7 +233,14 @@ export function OsvTable({ rows, total, page, pageSize, loading, filters, member filterIcon: filterIcon(!!filters.q), filterDropdown: ({ 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) => , + }, 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}` }} /> ); diff --git a/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts b/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts new file mode 100644 index 0000000..8614a07 --- /dev/null +++ b/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts @@ -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 => ({ + 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 = {}) => + 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.', + ); + }); +}); diff --git a/src/widgets/ConsolidatedOsv/counterpartyMapping.ts b/src/widgets/ConsolidatedOsv/counterpartyMapping.ts new file mode 100644 index 0000000..5149047 --- /dev/null +++ b/src/widgets/ConsolidatedOsv/counterpartyMapping.ts @@ -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 => 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(' '); +} diff --git a/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts b/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts index 2777ea3..e9a2a7b 100644 --- a/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts +++ b/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts @@ -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 }; }