Обходной лист (#12)
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user