Обходной лист (#12)
Tests / backend-test (push) Successful in 1m38s
Tests / frontend-test (push) Successful in 47s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
2026-08-17 05:43:52 +00:00
parent d076f4e98d
commit 48ede1420d
37 changed files with 1351 additions and 435 deletions
@@ -1,9 +1,16 @@
import { Employee } from '../models';
import { findEmployeeByEmail, toEmployeeOption } from './employee';
import { findEmployeeByEmail, matchEmployeesByPosition, toEmployeeOption } from './employee';
// Хелпер вместо повторения company/companies в каждом литерале — в тестах
// нет нужды в разных компаниях у одного сотрудника (это отдельно проверяется
// в matchEmployeesByPosition ниже).
function emp(fio: string, email: string, position: string, company = ''): Employee {
return { fio, email, position, company, companies: company ? [company] : [] };
}
const employees: Employee[] = [
{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle' },
{ fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела' }
emp('Горбенко Павел', 'gorbenko.pavel@tnsu.kz', 'Разработчик middle', 'ТОО TANSU Construction'),
emp('Соколов Давид', 'sokolov.david@tnsu.kz', 'Руководитель отдела', 'ТОО TANSU Construction')
];
describe('findEmployeeByEmail', () => {
@@ -26,11 +33,74 @@ describe('findEmployeeByEmail', () => {
});
describe('toEmployeeOption', () => {
it('строит опцию для SearchableSelect из сотрудника', () => {
it('строит опцию для SearchableSelect из сотрудника: должность и email в подписи', () => {
expect(toEmployeeOption(employees[0])).toEqual({
value: 'gorbenko.pavel@tnsu.kz',
label: 'Горбенко Павел',
sublabel: 'gorbenko.pavel@tnsu.kz'
sublabel: 'Разработчик middle · gorbenko.pavel@tnsu.kz'
});
});
// Должность в справочнике заполнена не у всех — тогда подпись не должна
// начинаться с висящего разделителя.
it('без должности в подписи остаётся только email', () => {
expect(toEmployeeOption(emp('Без Должности', 'nobody@tnsu.kz', ''))).toEqual({
value: 'nobody@tnsu.kz',
label: 'Без Должности',
sublabel: 'nobody@tnsu.kz'
});
});
});
describe('matchEmployeesByPosition', () => {
const staff: Employee[] = [
emp('Плотникова Лариса', 'p@tnsu.kz', 'Генеральный директор', 'Компания А'),
emp('Самаева Асель', 's@tnsu.kz', 'Директор', 'Компания А'),
emp('Ким Николай', 'k@tnsu.kz', 'Управляющий директор', 'Компания Б'),
emp('Иванов Иван', 'i1@tnsu.kz', 'Инженер ПТО', 'Компания А'),
emp('Петров Пётр', 'i2@tnsu.kz', 'Инженер ПТО', 'Компания Б'),
emp('Без Должности', 'n@tnsu.kz', '', 'Компания А')
];
it('пустой ввод — ничего не подбирает', () => {
expect(matchEmployeesByPosition(staff, ' ')).toEqual({ exact: [], similar: [] });
});
// Бэкенд ищет точное совпадение должности, поэтому подсказка обязана считать
// так же — иначе обещала бы исполнителя там, где его не найдут.
it('точное совпадение не зависит от регистра и краевых пробелов', () => {
const result = matchEmployeesByPosition(staff, ' генеральный ДИРЕКТОР ');
expect(result.exact.map(e => e.fio)).toEqual(['Плотникова Лариса']);
});
it('одну должность в разных компаниях занимают несколько человек — возвращаются все', () => {
const result = matchEmployeesByPosition(staff, 'Инженер ПТО');
expect(result.exact.map(e => e.company)).toEqual(['Компания А', 'Компания Б']);
});
// 'директор' — сам по себе точное совпадение (должность Самаевой), а
// подстрокой входит ещё в две других — те и предлагаются похожими.
it('точное совпадение и похожие должности возвращаются одновременно', () => {
const result = matchEmployeesByPosition(staff, 'директор');
expect(result.exact.map(e => e.fio)).toEqual(['Самаева Асель']);
expect(result.similar.map(s => s.position)).toEqual(['Генеральный директор', 'Управляющий директор']);
});
// Список нужен, чтобы подставить точное написание — одинаковые строки в нём бесполезны.
it('похожие схлопываются по написанию и считают людей', () => {
expect(matchEmployeesByPosition(staff, 'инженер').similar).toEqual([
{ position: 'Инженер ПТО', fio: 'Иванов Иван', count: 2 }
]);
});
it('точное совпадение не дублируется в похожих', () => {
const result = matchEmployeesByPosition(staff, 'Директор');
expect(result.exact.map(e => e.fio)).toEqual(['Самаева Асель']);
expect(result.similar.some(s => s.position === 'Директор')).toBe(false);
});
it('сотрудники без должности не попадают в подбор', () => {
expect(matchEmployeesByPosition(staff, '').exact).toEqual([]);
expect(matchEmployeesByPosition(staff, 'директор').similar.some(s => s.position === '')).toBe(false);
});
});
@@ -1,9 +1,17 @@
import { Employee } from '../models';
import { ISearchableOption } from '../components/ui';
/** Опция для SearchableSelect: ФИО — основная строка, email — маленькая подпись под ней. */
/**
* Опция для SearchableSelect: ФИО — основная строка, должность и email — подпись под ней.
*
* Должность здесь не только для узнавания однофамильцев: SearchableSelect ищет и по
* sublabel (см. filtered там), поэтому согласующего можно найти, набрав «начальник омтс»,
* не помня фамилии. Должность может быть пустой (в справочнике не у всех заполнена) —
* тогда в подписи остаётся только email.
*/
export function toEmployeeOption(employee: Employee): ISearchableOption {
return { value: employee.email, label: employee.fio, sublabel: employee.email };
const sublabel = [employee.position, employee.email].filter(Boolean).join(' · ');
return { value: employee.email, label: employee.fio, sublabel };
}
export function findEmployeeByEmail(employees: Employee[], email: string | undefined): Employee | undefined {
@@ -11,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<string, SimilarPosition>();
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) };
}
@@ -44,11 +44,28 @@ describe('detectExecutorKind', () => {
});
describe('describeStepExecutor', () => {
const employees: Employee[] = [{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик' }];
const employees: Employee[] = [
{
fio: 'Горбенко Павел',
email: 'gorbenko.pavel@tnsu.kz',
position: 'Разработчик',
company: 'ТОО TANSU Construction',
companies: ['ТОО TANSU Construction']
}
];
it('для email найденного сотрудника показывает ФИО и email отдельной строкой', () => {
it('для email найденного сотрудника показывает ФИО, а должность и email — отдельной строкой', () => {
const result = describeStepExecutor(step({ executorEmail: 'gorbenko.pavel@tnsu.kz' }), employees);
expect(result).toEqual({ primary: 'Горбенко Павел', secondary: 'gorbenko.pavel@tnsu.kz' });
expect(result).toEqual({ primary: 'Горбенко Павел', secondary: 'Разработчик · gorbenko.pavel@tnsu.kz' });
});
// Должность в справочнике заполнена не у всех — тогда подпись не должна
// начинаться с висящего разделителя.
it('для сотрудника без должности в подписи остаётся только email', () => {
const result = describeStepExecutor(step({ executorEmail: 'nopos@tnsu.kz' }), [
{ fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '', company: '', companies: [] }
]);
expect(result).toEqual({ primary: 'Без Должности', secondary: 'nopos@tnsu.kz' });
});
it('для email, которого нет в справочнике, показывает сам email без secondary', () => {
@@ -19,7 +19,7 @@ export function detectExecutorKind(step: {
export interface StepExecutorDisplay {
primary: string;
/** Email под ФИО — только когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */
/** Должность и email под ФИО — когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */
secondary?: string;
}
@@ -27,7 +27,10 @@ export interface StepExecutorDisplay {
export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay {
if (step.executorEmail) {
const employee = findEmployeeByEmail(employees, step.executorEmail ?? undefined);
return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail };
if (!employee) return { primary: step.executorEmail };
// Должность рядом с email: по одному ФИО не видно, тот ли это человек, что
// задумывался при настройке маршрута (в справочнике есть однофамильцы).
return { primary: employee.fio, secondary: [employee.position, employee.email].filter(Boolean).join(' · ') };
}
if (step.position) return { primary: step.position };
if (step.projectRole && step.projectRole !== ProjectRole.None) {