77 lines
4.8 KiB
TypeScript
77 lines
4.8 KiB
TypeScript
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(' ');
|
||
}
|