diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteCopyModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteCopyModal.tsx new file mode 100644 index 0000000..6a8c002 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteCopyModal.tsx @@ -0,0 +1,229 @@ +import * as React from 'react'; +import { + Company, + CreateTemplateRouteStepInput, + Department, + ProjectRole, + TemplateRoute, + TemplateRouteStep +} from '../../models'; +import { texts } from '../../texts'; +import { shortGuid } from '../../utils/guid'; +import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; +import { useRouteSteps } from '../../hooks/useRouteSteps'; +import { ApprovalRouteApi } from '../../services/api'; +import { ApiClientError } from '../../services/httpClient'; +import { Alert, Checkbox, Field, FormGrid, FormModal, SearchableSelect } from '../ui'; + +export interface IRouteCopyModalProps { + api: ApprovalRouteApi; + /** Маршрут-источник: тип заявки у копии тот же, меняются только «координаты». */ + source: TemplateRoute; + requestTypeName: string; + companies: Company[]; + departments: Department[]; + dictionaryLoading: boolean; + onCopied: () => void; + onClose: () => void; +} + +interface IFormState { + companyId: string; + departmentId: string; + byProject: boolean; + requiresControl: boolean; +} + +/** Тело для создания шага-копии: всё, кроме id/routeId, переносится как есть. */ +function toStepInput(step: TemplateRouteStep): CreateTemplateRouteStepInput { + return { + orderNo: step.orderNo, + executorEmail: step.executorEmail, + position: step.position, + projectRole: step.projectRole, + headType: step.headType, + status: step.status, + allowReassignToOtherApprovers: step.allowReassignToOtherApprovers, + fieldName: step.fieldName, + operation: step.operation, + value: step.value, + slaHours: step.slaHours, + askOverdueReason: step.askOverdueReason + }; +} + +/** + * Копирование маршрута вместе с шагами в пределах ОДНОГО типа заявки. + * + * Маршрут уникален по (тип заявки, компания, отдел, «по проекту») — см. + * HasDuplicateAsync в TemplateRouteService. Тип заявки у копии тот же, поэтому + * копия обязана отличаться хотя бы одним из остальных трёх полей; проверяем это + * до запроса, чтобы вместо 409 от бэкенда показать понятную причину. + * + * Шаги переносятся по одному. Если хоть один не создался, СОЗДАННЫЙ МАРШРУТ + * УДАЛЯЕТСЯ: наполовину скопированный маршрут хуже, чем отсутствующий — он бы + * молча резолвился с недостающими согласующими. + */ +export const RouteCopyModal: React.FC = ({ + api, + source, + requestTypeName, + companies, + departments, + dictionaryLoading, + onCopied, + onClose +}) => { + const [form, setForm] = React.useState({ + companyId: source.companyId ?? '', + departmentId: source.departmentId ?? '', + byProject: source.byProject, + requiresControl: source.requiresControl + }); + const steps = useRouteSteps(api, source.id); + const submit = useAsyncSubmit(); + + const departmentsForSelectedCompany = React.useMemo( + () => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []), + [departments, form.companyId] + ); + + const sourceCompany = source.companyId + ? companies.find(c => c.id === source.companyId)?.name ?? shortGuid(source.companyId) + : texts.routeSteps.anyCompany; + const sourceDepartment = source.departmentId + ? departments.find(d => d.id === source.departmentId)?.name ?? shortGuid(source.departmentId) + : texts.routeSteps.anyDepartment; + + // Шаги с ролью в проекте бэкенд не примет в маршрут без признака «по проекту». + const projectRoleSteps = steps.items.filter(s => s.projectRole && s.projectRole !== ProjectRole.None).length; + const projectRoleBlocks = projectRoleSteps > 0 && !form.byProject; + + // Копия должна отличаться от источника: тип заявки у них общий, значит + // различие обязано быть в компании, отделе или признаке «по проекту». + const sameAsSource = + (form.companyId || '') === (source.companyId ?? '') && + (form.departmentId || '') === (source.departmentId ?? '') && + form.byProject === source.byProject; + + const submitForm = (): void => { + if (sameAsSource) { + submit.fail(texts.routes.validationCopySame); + return; + } + if (projectRoleBlocks) { + submit.fail(texts.routes.copyProjectRoleWarning(projectRoleSteps)); + return; + } + + const companyId = form.companyId.trim() ? form.companyId.trim() : null; + const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null; + + submit.run(async () => { + const created = await api.templateRoutes.create({ + requestTypeId: source.requestTypeId, + companyId, + departmentId, + byProject: form.byProject, + requiresControl: form.requiresControl + }); + + // Последовательно, а не Promise.all: при ошибке важно знать, на каком шаге + // сорвались, а параллельные запросы к тому же дают гонку по orderNo в логах. + try { + for (const step of [...steps.items].sort((a, b) => a.orderNo - b.orderNo)) { + await api.templateRouteSteps.create(created.id, toStepInput(step)).catch((e: ApiClientError) => { + throw new Error(texts.routes.copyStepFailed(step.orderNo, e.message)); + }); + } + } catch (stepError) { + // Откат: маршрут без части шагов резолвился бы молча и неверно. + try { + await api.templateRoutes.remove(created.id); + } catch { + throw new Error( + `${(stepError as Error).message} ${texts.routes.copyRollbackFailed(created.id)}` + ); + } + throw new Error(`${(stepError as Error).message} ${texts.routes.copyRolledBack}`); + } + + onCopied(); + onClose(); + }); + }; + + return ( + + {texts.routes.copySource(requestTypeName, sourceCompany, sourceDepartment)} + + {steps.error && {steps.error}} + {!steps.error && steps.isLoading && {texts.routes.copyStepsLoading}} + {!steps.error && !steps.isLoading && ( + 0 ? 'ok' : 'warn'}> + {steps.items.length > 0 ? texts.routes.copyStepsCount(steps.items.length) : texts.routes.copyStepsEmpty} + + )} + {projectRoleBlocks && {texts.routes.copyProjectRoleWarning(projectRoleSteps)}} + + + + ({ value: c.id, label: c.name }))} + value={form.companyId} + loading={dictionaryLoading} + allowClear + clearLabel={texts.routes.companyClear} + placeholder={texts.routes.companyPlaceholder} + searchPlaceholder={texts.routes.companySearchPlaceholder} + onChange={companyId => + // Как и в форме маршрута: смена компании обнуляет отдел — прежний + // принадлежал другой компании. + setForm({ ...form, companyId, departmentId: '' }) + } + /> + + + ({ value: d.id, label: d.name }))} + value={form.departmentId} + loading={dictionaryLoading} + disabled={!form.companyId} + allowClear + clearLabel={texts.routes.departmentPlaceholder} + placeholder={texts.routes.departmentPlaceholder} + searchPlaceholder={texts.routes.departmentSearchPlaceholder} + emptyMessage={texts.routes.departmentEmpty} + onChange={departmentId => setForm({ ...form, departmentId })} + /> + + + setForm({ ...form, byProject: v })} + /> + + + setForm({ ...form, requiresControl: v })} + /> + + + + ); +}; diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.module.scss new file mode 100644 index 0000000..12a5ea2 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.module.scss @@ -0,0 +1,72 @@ +@import '../../../../styles/design-tokens'; + +// Подсказка «кого найдёт эта должность» под полем «Должность». Живёт внутри +// Field, поэтому отступ сверху небольшой — визуально это продолжение поля, +// а не отдельный блок. +.positionPreview { + margin-top: 6px; + padding: 8px 10px; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $gray-50; + font-size: 12px; + line-height: 1.5; +} + +// Найдено — зелёная рамка: должность рабочая, шаг резолвится. +.previewOk { + border-color: $green-600; + background: rgba($green-600, .06); +} + +// Не найдено — предупреждение: шаг останется без исполнителя. +.previewMiss { + border-color: $gold-500; + background: $gold-50; +} + +.previewTitle { + color: $gray-700; +} + +.previewList { + margin: 6px 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 3px; +} + +.previewPerson { + color: $gray-900; +} + +.previewCompany { + color: $gray-500; +} + +// Похожая должность — кнопка: клик подставляет её точное написание в поле, +// чтобы не переписывать вручную и не ошибиться в регистре/пробелах. +.previewSuggest { + display: block; + width: 100%; + padding: 4px 6px; + border: 0; + border-radius: $r-sm; + background: transparent; + text-align: left; + font-family: $font-sans; + font-size: 12px; + color: $navy-700; + cursor: pointer; + + &:hover { + background: #fff; + color: $gold-600; + } +} + +.previewSuggestCount { + color: $gray-500; +} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx index c8787f2..9dc5c5c 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import styles from './RouteStepFormModal.module.scss'; import { ComparisonOperator, Employee, @@ -12,7 +13,7 @@ import { } from '../../models'; import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts'; import { detectExecutorKind } from '../../utils/stepExecutor'; -import { toEmployeeOption } from '../../utils/employee'; +import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useSharePointFields } from '../../hooks/useSharePointFields'; import { ApprovalRouteApi } from '../../services/api'; @@ -186,6 +187,75 @@ export const RouteStepFormModal: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isManagerField]); + // Подсказка «кого найдёт эта должность»: бэкенд на резолве ищет ТОЧНОЕ + // совпадение должности в справочнике (/Employee/ByPosition), и опечатка тихо + // оставляет шаг без исполнителя — до самого резолва об этом узнать негде. + // Считаем по уже загруженному справочнику сотрудников, без запросов на ввод. + const positionMatches = React.useMemo( + () => matchEmployeesByPosition(employees, form.position), + [employees, form.position] + ); + + const positionPreview = ((): React.ReactNode => { + if (!form.position.trim()) return undefined; + if (employeesLoading) { + return
{texts.routeSteps.positionPreviewLoading}
; + } + // Справочник не приехал (нет клиента/ошибка) — молчим о «не найдено»: это + // сказало бы неправду про корректную должность. + if (employees.length === 0) { + return
{texts.routeSteps.positionPreviewNoDictionary}
; + } + + if (positionMatches.exact.length > 0) { + return ( +
+ + {texts.routeSteps.positionPreviewFound(positionMatches.exact.length)} + +
    + {positionMatches.exact.map(e => ( +
  • + {e.fio} + {e.company && · {e.company}} +
  • + ))} +
+
+ ); + } + + return ( +
+ {texts.routeSteps.positionPreviewNone} + {positionMatches.similar.length > 0 && ( + <> +
{texts.routeSteps.positionPreviewSimilar}
+
    + {positionMatches.similar.map(s => ( +
  • + +
  • + ))} +
+ + )} +
+ ); + })(); + const submitForm = (): void => { const orderNo = Number(form.orderNo); if (!orderNo || orderNo < 1) { @@ -304,6 +374,7 @@ export const RouteStepFormModal: React.FC = ({ onChange={e => setForm({ ...form, position: e.target.value })} placeholder={texts.routeSteps.positionPlaceholder} /> + {positionPreview} )} {form.kind === 'projectRole' && ( diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx index 78c8a85..8f612f8 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import styles from './RoutesScreen.module.scss'; import { RouteFormModal } from './RouteFormModal'; +import { RouteCopyModal } from './RouteCopyModal'; import { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; import { TemplateRoute } from '../../models'; @@ -69,6 +70,7 @@ export const RoutesScreen: React.FC = ({ const [editing, setEditing] = React.useState(); const [showForm, setShowForm] = React.useState(false); + const [copying, setCopying] = React.useState(); const deleteTarget = useDeleteTarget(item => api.templateRoutes.remove(item.id), routes.reload); @@ -145,6 +147,7 @@ export const RoutesScreen: React.FC = ({ render: r => ( + setCopying(r)} /> openEdit(r)} /> deleteTarget.ask(r)} /> @@ -170,6 +173,19 @@ export const RoutesScreen: React.FC = ({ /> )} + {copying && ( + setCopying(undefined)} + /> + )} + {deleteTarget.target && ( void; onClose: () => void; children: React.ReactNode; @@ -18,7 +22,16 @@ export interface IFormModalProps { * Модальное окно формы: одинаковая обвязка «ошибка сверху, Отмена/Сохранить снизу» * для всех справочников, чтобы экраны описывали только поля. */ -export const FormModal: React.FC = ({ title, error, isSaving, onSubmit, onClose, children }) => ( +export const FormModal: React.FC = ({ + title, + error, + isSaving, + submitLabel = texts.common.save, + savingLabel = texts.common.saving, + onSubmit, + onClose, + children +}) => ( = ({ title, error, isSaving, o <> } diff --git a/frontend/src/webparts/approvalAdmin/components/ui/Icon.tsx b/frontend/src/webparts/approvalAdmin/components/ui/Icon.tsx index 9ceac07..458f129 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/Icon.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ui/Icon.tsx @@ -24,7 +24,8 @@ export type IconName = | 'briefcase' | 'flag' | 'crown' - | 'refresh'; + | 'refresh' + | 'copy'; let spriteInjected = false; @@ -51,6 +52,7 @@ const SPRITE_SVG = ` + `; function ensureSpriteInjected(): void { diff --git a/frontend/src/webparts/approvalAdmin/models/dictionaries.ts b/frontend/src/webparts/approvalAdmin/models/dictionaries.ts index d81fe99..8b99a3c 100644 --- a/frontend/src/webparts/approvalAdmin/models/dictionaries.ts +++ b/frontend/src/webparts/approvalAdmin/models/dictionaries.ts @@ -33,4 +33,11 @@ export interface Employee { * подсказка при выборе сотрудника (см. ManageRequestsApproverModal), не для * отображения в списке (сам справочник должностей не показывает). */ position: string; + /** + * Компания сотрудника — '' если не пришла из справочника. Нужна подсказке + * «кого найдёт эта должность» в форме шага: бэкенд ищет исполнителя по ПАРЕ + * (должность, компания инициатора), поэтому одну и ту же должность в разных + * юрлицах занимают разные люди, и без компании подсказка была бы неоднозначной. + */ + company: string; } diff --git a/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts index b0d8bb7..9f25130 100644 --- a/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts +++ b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts @@ -53,6 +53,7 @@ interface IPortalEmployee { fio: string; email: string | null; position: string | null; + company: string | null; } async function getJson(client: AadHttpClient, baseUrl: string, path: string): Promise { @@ -112,11 +113,17 @@ export class PortalDictionaryClient { if (!x.email) return; const existing = byEmail.get(x.email); if (!existing) { - byEmail.set(x.email, { fio: x.fio, email: x.email, position: x.position || '' }); - } else if (!existing.position && x.position) { - // Один сотрудник — строка на каждый его отдел; должность могла найтись - // не в первой из них. - existing.position = x.position; + byEmail.set(x.email, { + fio: x.fio, + email: x.email, + position: x.position || '', + company: x.company || '' + }); + } else { + // Один сотрудник — строка на каждый его отдел; должность и компания + // могли найтись не в первой из них. + if (!existing.position && x.position) existing.position = x.position; + if (!existing.company && x.company) existing.company = x.company; } }); return Array.from(byEmail.values()); diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index c60d1bd..1c53b59 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -187,6 +187,38 @@ export const texts = { controlLabel: 'Контроль', controlCheckbox: 'Требуется отдельный контроль исполнения', validationRequestType: 'Выберите тип заявки.', + + // ---- Копирование маршрута ---- + copy: 'Копировать', + copyTitle: (routeId: number): string => `Копия маршрута #${routeId}`, + copyAction: 'Создать копию', + copyInProgress: 'Копирование...', + copyHint: + 'Копия создаётся для того же типа заявки вместе со всеми шагами. Маршрут различается по компании, ' + + 'отделу и признаку «по проекту» — измените хотя бы одно из них, иначе копия совпадёт с исходным маршрутом.', + copySource: (requestTypeName: string, company: string, department: string): string => + `Копируем: ${requestTypeName} · ${company} · ${department}`, + copyStepsCount: (count: number): string => + `Шагов будет скопировано: ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])}`, + copyStepsLoading: 'Загрузка шагов исходного маршрута...', + copyStepsEmpty: 'В исходном маршруте нет шагов — копия будет пустой.', + validationCopySame: + 'Копия совпадает с исходным маршрутом. Измените компанию, отдел или признак «по проекту» — ' + + 'иначе такой маршрут уже существует.', + // Роль в проекте допустима только у маршрута с признаком «по проекту» (проверка + // на бэкенде, TemplateRouteStepService) — предупреждаем ДО копирования, иначе + // копия сорвётся на середине переноса шагов. + copyProjectRoleWarning: (count: number): string => + `В исходном маршруте ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])} с ролью в проекте. ` + + 'Без признака «по проекту» такие шаги скопировать нельзя — включите его или удалите эти шаги в исходном маршруте.', + copyStepFailed: (orderNo: number, message: string): string => + `Не удалось скопировать шаг ${orderNo}: ${message}`, + // Частично скопированный маршрут опаснее, чем несозданный: он бы резолвился + // с недостающими согласующими. Поэтому при ошибке копию удаляем целиком. + copyRolledBack: 'Копия не создана — маршрут удалён, чтобы не остался наполовину скопированным.', + copyRollbackFailed: (routeId: number): string => + `ВНИМАНИЕ: маршрут #${routeId} создан, но шаги скопированы не полностью, и удалить его автоматически не вышло. Удалите маршрут вручную.`, + deleteTitle: 'Удалить маршрут?', deleteText: (routeId: number): string => `Маршрут #${routeId} и все его шаги будут удалены безвозвратно.` @@ -232,6 +264,20 @@ export const texts = { executorPlaceholder: 'Выберите сотрудника', positionLabel: 'Должность', positionHint: 'Ищется в справочнике api.tnsu.kz по компании инициатора.', + // Подсказка «кого найдёт эта должность» — считается по загруженному справочнику. + positionPreviewLoading: 'Проверяем по справочнику сотрудников...', + positionPreviewFound: (count: number): string => + count === 1 + ? 'Сейчас эту должность занимает:' + : `Эту должность занимают ${count} ${pluralRu(count, ['сотрудник', 'сотрудника', 'сотрудников'])} — ` + + 'на согласование пойдёт тот, чья компания совпадёт с компанией инициатора:', + positionPreviewNone: + 'Точно такой должности в справочнике нет — на резолве шаг останется без исполнителя. Проверьте написание.', + positionPreviewSimilar: 'Похожие должности в справочнике (нажмите, чтобы подставить точное написание):', + /** Сколько ещё человек занимают ту же должность, кроме показанного. */ + positionPreviewMore: (count: number): string => + `и ещё ${count} ${pluralRu(count, ['человек', 'человека', 'человек'])}`, + positionPreviewNoDictionary: 'Справочник сотрудников недоступен — проверить должность не удалось.', positionPlaceholder: 'Главный инженер', projectRoleLabel: 'Роль в проекте', headTypeLabel: 'Тип руководителя', @@ -385,7 +431,10 @@ export const texts = { missingInDictionary: 'Нет в справочнике', loadingDictionary: 'Загрузка справочника...', searching: 'Поиск...', - employeeSearchPlaceholder: 'Поиск по ФИО или email...', + // Не перечисляем поля: искать можно и по ФИО, и по email, и по должности + // (см. toEmployeeOption — должность попадает в sublabel, а SearchableSelect + // ищет и по нему). Перечисление всё равно устарело бы при следующем поле. + employeeSearchPlaceholder: 'Поиск...', employeesEmpty: 'Сотрудники не найдены в справочнике' } }; diff --git a/frontend/src/webparts/approvalAdmin/utils/employee.ts b/frontend/src/webparts/approvalAdmin/utils/employee.ts index ee2fead..f5ca577 100644 --- a/frontend/src/webparts/approvalAdmin/utils/employee.ts +++ b/frontend/src/webparts/approvalAdmin/utils/employee.ts @@ -19,3 +19,67 @@ export function findEmployeeByEmail(employees: Employee[], email: string | undef const target = email.toLowerCase(); return employees.find(e => e.email.toLowerCase() === target); } + +export interface SimilarPosition { + /** Написание должности как в справочнике — его и подставляем по клику. */ + position: string; + /** + * ФИО первого найденного сотрудника с этой должностью — по одному названию + * не всегда понятно, тот ли это человек, что нужен; имя снимает вопрос сразу, + * без подстановки и повторной проверки. + */ + fio: string; + /** Сколько сотрудников её занимают (в разных компаниях/отделах). */ + count: number; +} + +export interface PositionMatches { + /** + * Кого бэкенд реально подставит: он ищет ТОЧНОЕ совпадение должности + * (/Employee/ByPosition), поэтому здесь тоже точное — с точностью до регистра + * и краевых пробелов. Несколько человек — норма: должность одна, а компании + * разные, и на resolve выберется тот, чья компания совпала с компанией инициатора. + */ + exact: Employee[]; + /** + * Похожие должности — на случай опечатки или неполного ввода. Уникальные + * по написанию, а не по сотруднику: список нужен, чтобы подставить в поле + * точный текст должности, и пять одинаковых строк там бесполезны. + * Точное совпадение сюда не попадает — оно уже показано в exact. + */ + similar: SimilarPosition[]; +} + +/** Максимум похожих в подсказке — длинный список её только зашумит. */ +const SIMILAR_LIMIT = 6; + +/** + * Кого найдёт шаг «по должности в компании инициатора» с таким текстом должности. + * Считается по уже загруженному справочнику (без запроса на каждый символ). + */ +export function matchEmployeesByPosition(employees: Employee[], position: string): PositionMatches { + const query = position.trim().toLowerCase(); + if (!query) return { exact: [], similar: [] }; + + const exact: Employee[] = []; + // Ключ — нормализованное написание, значение — как показать и сколько человек. + const similarByKey = new Map(); + + employees.forEach(e => { + const raw = e.position.trim(); + const candidate = raw.toLowerCase(); + if (!candidate) return; + + if (candidate === query) { + exact.push(e); + return; + } + if (candidate.indexOf(query) === -1) return; + + const existing = similarByKey.get(candidate); + if (existing) existing.count += 1; + else similarByKey.set(candidate, { position: raw, fio: e.fio, count: 1 }); + }); + + return { exact, similar: Array.from(similarByKey.values()).slice(0, SIMILAR_LIMIT) }; +}