arm-core/web/src/widgets/ConsolidatedOsv/counterpartyMapping.ts
2026-10-08 18:09:07 +03:00

77 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(' ');
}