feat: несколько причин и сотрудников в заявке на подбор при 2+ вакансиях
Tests / frontend-test (push) Successful in 1m14s

This commit is contained in:
Тимур Городецкий
2026-09-25 10:33:05 +05:00
parent 1b36813a07
commit 7959d91025
15 changed files with 529 additions and 62 deletions
@@ -105,7 +105,13 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
set('ProbationPeriodOther', selection.SelectionProbationPeriodOther);
set('SalaryProbation', selection.RateProbation);
set('SalaryAfterProbation', selection.RateAfterProbation);
const hireTypePrefill = hireTypeFromSelection(selection.VacancyOpenReason, selection.VacancyOpenReasonDetails);
// Тип приёма однозначен, только если в подборе ОДНА причина; ФИО «взамен
// кого» — только если под ней ОДИН сотрудник. Иначе инициатор выбирает сам.
const reasons = selection.VacancyReasons;
const single = reasons.length === 1 ? reasons[0] : undefined;
const hireTypePrefill = single
? hireTypeFromSelection(single.reason, single.employees.length === 1 ? single.employees[0] : '')
: undefined;
if (hireTypePrefill) {
set('HireType', hireTypePrefill.hireType);
set('HireReplacedEmployee', hireTypePrefill.replacedEmployee);
@@ -52,7 +52,7 @@ import {
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, todayIso } from './model';
import { missingRequiredFields } from './validation';
import { submitBlocker } from './validation';
import { saveSelectionRequest, selectionApprovalService } from './submit';
import { loadSelectionRequest } from './load';
import { ApplicationSection } from './sections/ApplicationSection';
@@ -267,8 +267,8 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
// Остальные поля формы обязательны только для ОТПРАВКИ на согласование —
// черновик по-прежнему можно сохранить не заполнив их (см. validation.ts).
const missingFields = missingRequiredFields(data);
const isSubmitValid = isValid && missingFields.length === 0;
const blocker = submitBlocker(data);
const isSubmitValid = isValid && !blocker;
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({
@@ -348,9 +348,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
if (missingFields.length > 0) {
return `Заполните обязательные поля: ${missingFields.join(', ')}.`;
}
if (blocker) return blocker;
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
}
@@ -46,7 +46,7 @@ import {
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadSelectionRequest } from './load';
import { selectionApprovalService, updateAllSelectionFields } from './submit';
import { missingRequiredFields } from './validation';
import { submitBlocker } from './validation';
import { ApplicationSection } from './sections/ApplicationSection';
import { DepartmentSection } from './sections/DepartmentSection';
import { VacancySection } from './sections/VacancySection';
@@ -184,8 +184,8 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
// отклонить — можно (бросок отменяет решение, текст показывает DecisionPanel).
const handleBeforeDecide = async (decision: ApproverDecision): Promise<void> => {
if (decision === 'approve') {
const missing = missingRequiredFields(data);
if (missing.length > 0) throw new Error(`заполните обязательные поля: ${missing.join(', ')}.`);
const blocker = submitBlocker(data);
if (blocker) throw new Error(blocker);
}
await saveAllFields();
};
@@ -1,5 +1,6 @@
import { IChangeTrackingConfig } from '../../shared/fieldChanges/fieldChanges';
import { IFormData } from './model';
import { IChangeTrackingConfig, EMPTY_VALUE } from '../../shared/fieldChanges/fieldChanges';
import { IFormData, IVacancyReason } from './model';
import { summarizeVacancyReasons } from './vacancyReasons';
/** Колонка журнала правок согласующего (Note, JSON) — см. provisioning.json. */
export const APPROVER_CHANGES_FIELD = 'SelectionApproverChangesJson';
@@ -10,5 +11,8 @@ export const APPROVER_CHANGES_FIELD = 'SelectionApproverChangesJson';
* «Объект/Проект» и «Подразделение».
*/
export const SELECTION_CHANGE_TRACKING: IChangeTrackingConfig<IFormData> = {
ignore: ['ObjectProjectId', 'ObjectProjectCode', 'DepartmentId']
ignore: ['ObjectProjectId', 'ObjectProjectCode', 'DepartmentId'],
formatters: {
VacancyReasons: (value) => summarizeVacancyReasons((value as IVacancyReason[]) || []) || EMPTY_VALUE
}
};
@@ -4,6 +4,7 @@ import { dataListTitle } from '../../shared/listTitles';
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
import { FORM_ID, IFormData, EMPTY_DATA, ILanguageLevel } from './model';
import { APPROVER_CHANGES_FIELD } from './changeTracking';
import { parseVacancyReasons } from './vacancyReasons';
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
function parseJsonArray<T>(raw: unknown): T[] {
@@ -53,7 +54,8 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro
// Скалярные поля — по имени; поля-люди — вложенный EMail (+ expand);
// Companies/Languages/Programs хранятся в Note-колонках *Json (не одноимёнными колонками).
const jsonKeys: (keyof IFormData)[] = ['Companies', 'Languages', 'Programs', 'WorkplaceProvision'];
// VacancyReasons — тоже JSON (VacancyReasonsJson) с откатом на старые колонки причины.
const jsonKeys: (keyof IFormData)[] = ['Companies', 'Languages', 'Programs', 'WorkplaceProvision', 'VacancyReasons'];
const scalarSelects = keys.filter((k) => !isPerson(k) && jsonKeys.indexOf(k) === -1);
const personSelects = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS].map((k) => `${k}/EMail`);
const expands = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS];
@@ -67,6 +69,9 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro
'LanguagesJson',
'ProgramsJson',
'WorkplaceProvisionJson',
'VacancyReasonsJson',
'VacancyOpenReason',
'VacancyOpenReasonDetails',
APPROVER_CHANGES_FIELD,
'RequestStatus',
'RequestUniqueCode',
@@ -80,6 +85,7 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro
writable.Languages = parseJsonArray<ILanguageLevel>(item.LanguagesJson);
writable.Programs = parseJsonArray<string>(item.ProgramsJson);
writable.WorkplaceProvision = parseJsonArray<string>(item.WorkplaceProvisionJson);
writable.VacancyReasons = parseVacancyReasons(item.VacancyReasonsJson, item.VacancyOpenReason, item.VacancyOpenReasonDetails);
keys.forEach((key) => {
if (jsonKeys.indexOf(key) !== -1) {
@@ -20,14 +20,13 @@ export interface IFormData {
SubmitDate: string;
ClosingDeadline: string;
/**
* Префикс Vacancy — не для читаемости, а обход коллизии managed properties:
* колонка называлась просто OpenReason, и Search индексировал её только у
* ОДНОГО из списков сайта с таким internal name (см. sp-write props/field в
* tools). Переименовали и перенесли данные скриптом (2026-08-29) — трогать
* имя обратно нельзя, коллизия вернётся.
* 1.5 Причины открытия вакансии. Одна причина — при одной вакансии; при 2.2 > 1
* можно несколько, и у причин с ФИО — несколько сотрудников. Хранится JSON в
* колонке VacancyReasonsJson; старые колонки VacancyOpenReason (первая
* причина) и VacancyOpenReasonDetails (сводка текстом) заполняются для
* совместимости — см. vacancyReasons.ts.
*/
VacancyOpenReason: string;
VacancyOpenReasonDetails: string;
VacancyReasons: IVacancyReason[];
Companies: ICompany[];
ObjectProject: string;
ObjectProjectId: string;
@@ -84,6 +83,16 @@ export interface IFormData {
HiringDecisionMaker: string[];
}
/** Одна причина открытия вакансии (п. 1.5). */
export interface IVacancyReason {
/** Значение из OPEN_REASON_OPTIONS. */
reason: string;
/** ФИО сотрудников — для причин с ФИО (замена, перевод, декрет); иначе []. */
employees: string[];
/** Уточнение — для «Другое»; иначе ''. */
details: string;
}
/** Обновление одного поля формы с сохранением типа значения. */
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
@@ -104,8 +113,7 @@ export const EMPTY_DATA: IFormData = {
Responsible: '',
SubmitDate: '',
ClosingDeadline: '',
VacancyOpenReason: '',
VacancyOpenReasonDetails: '',
VacancyReasons: [],
Companies: [],
ObjectProject: '',
ObjectProjectId: '',
@@ -7,6 +7,7 @@
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
{ "internalName": "VacancyOpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Декрет", "Другое"] },
{ "internalName": "VacancyOpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" },
{ "internalName": "VacancyReasonsJson", "title": "Причины открытия вакансии (JSON)", "type": "Note" },
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
{ "internalName": "SelectionApproverChangesJson", "title": "Изменения согласующего — подбор (JSON)", "type": "Note" },
{ "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" },
@@ -1,26 +1,12 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, Select, PeopleField } from '../../../shared/ui';
import { CollapseSection, FormField, TextInput, PeopleField } from '../../../shared/ui';
import { CompanyPickerMulti, ProjectPicker } from '../../../shared/dictionary';
import {
ISectionProps,
PERSON_PLACEHOLDER,
OPEN_REASON_OPTIONS,
OPEN_REASON_REPLACE,
OPEN_REASON_TRANSFER,
OPEN_REASON_NEW,
OPEN_REASON_MATERNITY
} from '../model';
/** Причины, где вакансия открывается под конкретного сотрудника — спрашиваем его ФИО. */
const REASONS_WITH_EMPLOYEE_NAME = [OPEN_REASON_REPLACE, OPEN_REASON_TRANSFER, OPEN_REASON_MATERNITY];
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
import { parseVacanciesCount } from '../vacancyReasons';
import { VacancyReasonsField } from './VacancyReasonsField';
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context, onResponsibleChange }) => {
const reason = data.VacancyOpenReason;
const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW;
const reasonDetailsLabel =
REASONS_WITH_EMPLOYEE_NAME.indexOf(reason) !== -1
? 'ФИО сотрудника (Полностью, пример: Иванов Иван Иванович)'
: 'Уточните причину';
const vacanciesCount = parseVacanciesCount(data.OpenVacanciesCount);
return (
<CollapseSection title="1. Заявка" icon="clipboard">
@@ -52,27 +38,18 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
min={data.SubmitDate || undefined}
/>
</FormField>
<FormField label="1.5 Причина открытия вакансии" htmlFor="VacancyOpenReason" required>
<Select
id="VacancyOpenReason"
value={data.VacancyOpenReason}
onChange={(v) => {
set('VacancyOpenReason', v);
if (v === '' || v === OPEN_REASON_NEW) set('VacancyOpenReasonDetails', '');
}}
options={OPEN_REASON_OPTIONS}
placeholder="— выберите —"
<FormField
label={vacanciesCount > 1 ? '1.5 Причины открытия вакансий' : '1.5 Причина открытия вакансии'}
fieldKey="VacancyReasons"
required
full
>
<VacancyReasonsField
value={data.VacancyReasons}
onChange={(v) => set('VacancyReasons', v)}
vacanciesCount={vacanciesCount}
/>
</FormField>
{showReasonDetails && (
<FormField label={reasonDetailsLabel} htmlFor="VacancyOpenReasonDetails">
<TextInput
id="VacancyOpenReasonDetails"
value={data.VacancyOpenReasonDetails}
onChange={(v) => set('VacancyOpenReasonDetails', v)}
/>
</FormField>
)}
<FormField label="1.6 Организация" htmlFor="Companies" required full>
<CompanyPickerMulti
context={context}
@@ -0,0 +1,85 @@
import * as React from 'react';
import { IconButton } from '@tnsu/ui-kit';
import styles from './VacancyReasons.module.scss';
import { Button, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
import { IVacancyReason } from '../model';
import { needsDetails, needsEmployeeName } from '../vacancyReasons';
interface IVacancyReasonDetailsProps {
entry: IVacancyReason;
onChange: (entry: IVacancyReason) => void;
/** Несколько сотрудников на причину — только при 2.2 > 1. */
allowMultipleEmployees: boolean;
/** Префикс id полей — уникален в пределах формы. */
idPrefix: string;
}
const EMPLOYEE_PLACEHOLDER = 'ФИО полностью, пример: Иванов Иван Иванович';
/**
* Что уточняется под причиной: ФИО сотрудников (замена/перевод/декрет) или
* текст «Уточните причину» («Другое»). У «Новой штатной единицы» — ничего.
*/
export const VacancyReasonDetails: React.FC<IVacancyReasonDetailsProps> = ({
entry,
onChange,
allowMultipleEmployees,
idPrefix
}) => {
const readOnly = useFormReadOnly();
if (needsDetails(entry.reason)) {
return (
<div className={styles.details}>
<label className={styles.detailsLabel} htmlFor={`${idPrefix}-details`}>
Уточните причину
</label>
<TextInput
id={`${idPrefix}-details`}
value={entry.details}
onChange={(v) => onChange({ ...entry, details: v })}
/>
</div>
);
}
if (!needsEmployeeName(entry.reason)) return null;
// Хотя бы одна строка ввода — даже если сотрудников ещё нет.
const rows = entry.employees.length > 0 ? entry.employees : [''];
const setRow = (index: number, value: string): void =>
onChange({ ...entry, employees: rows.map((e, i) => (i === index ? value : e)) });
const removeRow = (index: number): void =>
onChange({ ...entry, employees: rows.filter((_, i) => i !== index) });
const addRow = (): void => onChange({ ...entry, employees: [...rows, ''] });
// Убрать лишнего можно всегда (вакансий могли уменьшить), добавить — только при 2.2 > 1.
const canRemove = !readOnly && rows.length > 1;
return (
<div className={styles.details}>
<span className={styles.detailsLabel}>
{rows.length > 1 || allowMultipleEmployees ? 'ФИО сотрудников' : 'ФИО сотрудника'}
</span>
{rows.map((employee, i) => (
<div key={i} className={styles.employeeRow}>
<TextInput
id={`${idPrefix}-employee-${i}`}
value={employee}
onChange={(v) => setRow(i, v)}
placeholder={EMPLOYEE_PLACEHOLDER}
ariaLabel={`ФИО сотрудника ${i + 1}`}
/>
{canRemove && (
<IconButton icon="trash" title="Убрать сотрудника" danger size="sm" onClick={() => removeRow(i)} />
)}
</div>
))}
{allowMultipleEmployees && !readOnly && (
<Button variant="secondary" size="sm" className={styles.addButton} onClick={addRow}>
<Icon name="plus" size="sm" />
Добавить сотрудника
</Button>
)}
</div>
);
};
@@ -0,0 +1,59 @@
@import '../../../../../styles/design-tokens';
.stack {
display: flex;
flex-direction: column;
gap: $sp-3;
width: 100%;
}
.reason {
display: flex;
flex-direction: column;
gap: $sp-2;
}
// Сотрудники/уточнение — с отступом под своей причиной, чтобы было видно, к какой относятся.
.details {
display: flex;
flex-direction: column;
gap: $sp-2;
padding-left: $sp-6;
}
.detailsLabel {
font-size: $fs-label;
color: $gray-700;
}
.employeeRow {
display: flex;
align-items: center;
gap: $sp-2;
> :first-child {
flex: 1;
min-width: 0;
}
}
.addButton {
align-self: flex-start;
}
.error {
font-size: $fs-sm;
color: $red-500;
}
.warning {
font-size: $fs-sm;
color: $gold-600;
}
// На узком экране отступ вложенного блока съедает ширину — уменьшаем.
@media (max-width: 480px) {
.details {
padding-left: $sp-3;
}
}
@@ -0,0 +1,102 @@
import * as React from 'react';
import styles from './VacancyReasons.module.scss';
import { Checkbox, Select, useFormReadOnly } from '../../../shared/ui';
import { IVacancyReason, OPEN_REASON_OPTIONS } from '../model';
import { allowsMultipleReasons, emptyReason, vacancyReasonsError, vacancyReasonsWarning } from '../vacancyReasons';
import { VacancyReasonDetails } from './VacancyReasonDetails';
interface IVacancyReasonsFieldProps {
value: IVacancyReason[];
onChange: (reasons: IVacancyReason[]) => void;
/** Число из п. 2.2 — от него зависит, можно ли выбрать несколько причин. */
vacanciesCount: number;
}
/**
* 1.5 Причины открытия вакансии.
*
* Одна вакансия — выпадающий список, одна причина, один сотрудник. Больше одной
* — флажки: несколько причин, у причин с ФИО — несколько сотрудников. Если
* вакансий стало меньше, а причин выбрано несколько, остаются флажки с
* подсказкой (ничего не стираем молча).
*/
export const VacancyReasonsField: React.FC<IVacancyReasonsFieldProps> = ({ value, onChange, vacanciesCount }) => {
const readOnly = useFormReadOnly();
const multiple = allowsMultipleReasons(vacanciesCount);
const showCheckboxes = multiple || value.length > 1;
const entryOf = (reason: string): IVacancyReason | undefined => value.find((r) => r.reason === reason);
const replaceEntry = (entry: IVacancyReason): void =>
onChange(value.map((r) => (r.reason === entry.reason ? entry : r)));
// Порядок причин — как в списке вариантов, а не в порядке кликов.
const toggle = (reason: string, checked: boolean): void => {
const selected = OPEN_REASON_OPTIONS.map((o) => o.value).filter((v) =>
v === reason ? checked : !!entryOf(v)
);
onChange(selected.map((v) => entryOf(v) ?? emptyReason(v)));
};
const error = vacancyReasonsError(value, vacanciesCount);
const warning = vacancyReasonsWarning(value, vacanciesCount);
const messages = (
<>
{error && <div className={styles.error}>{error}</div>}
{!error && warning && <div className={styles.warning}>{warning}</div>}
</>
);
if (!showCheckboxes) {
const current = value[0];
return (
<div className={styles.stack}>
<Select
id="VacancyOpenReason"
value={current?.reason ?? ''}
onChange={(v) => onChange(v ? [entryOf(v) ?? emptyReason(v)] : [])}
options={OPEN_REASON_OPTIONS}
placeholder="— выберите —"
/>
{current && (
<VacancyReasonDetails
entry={current}
onChange={replaceEntry}
allowMultipleEmployees={false}
idPrefix="VacancyReason-0"
/>
)}
{messages}
</div>
);
}
// В режиме чтения показываем только выбранные причины — список всех флажков там лишний.
const options = readOnly ? OPEN_REASON_OPTIONS.filter((o) => !!entryOf(o.value)) : OPEN_REASON_OPTIONS;
return (
<div className={styles.stack}>
{options.map((option, i) => {
const entry = entryOf(option.value);
return (
<div key={option.value} className={styles.reason}>
<Checkbox
id={`VacancyReason-${i}`}
checked={!!entry}
onChange={(checked) => toggle(option.value, checked)}
label={option.label}
/>
{entry && (
<VacancyReasonDetails
entry={entry}
onChange={replaceEntry}
allowMultipleEmployees={multiple}
idPrefix={`VacancyReason-${i}`}
/>
)}
</div>
);
})}
{messages}
</div>
);
};
@@ -5,6 +5,7 @@ import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/f
import { buildRequestTitle } from '../../shared/requestTitle';
import { FORM_ID, IFormData } from './model';
import { APPROVER_CHANGES_FIELD } from './changeTracking';
import { cleanVacancyReasons, legacyDetailsText } from './vacancyReasons';
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager'];
@@ -55,6 +56,14 @@ function buildScalarBody(data: IFormData): Record<string, unknown> {
item.ProgramsJson = data.Programs.length > 0 ? JSON.stringify(data.Programs) : null;
return;
}
if (key === 'VacancyReasons') {
// Полная структура — JSON; старые колонки — для совместимости (см. vacancyReasons.ts).
const reasons = cleanVacancyReasons(data.VacancyReasons);
item.VacancyReasonsJson = reasons.length > 0 ? JSON.stringify(reasons) : null;
item.VacancyOpenReason = reasons.length > 0 ? reasons[0].reason : null;
item.VacancyOpenReasonDetails = legacyDetailsText(reasons) || null;
return;
}
if (key === 'WorkplaceProvision') {
// Мультивыбор категорий обеспечения — сериализуем в Note.
item.WorkplaceProvisionJson = data.WorkplaceProvision.length > 0 ? JSON.stringify(data.WorkplaceProvision) : null;
@@ -0,0 +1,72 @@
import {
parseVacancyReasons,
summarizeVacancyReasons,
legacyDetailsText,
vacancyReasonsError,
vacancyReasonsWarning,
parseVacanciesCount,
cleanVacancyReasons
} from './vacancyReasons';
const replace = { reason: 'Замена уволенного сотрудника', employees: ['Иванов И. И.', 'Петров П. П.'], details: '' };
const newUnit = { reason: 'Новая штатная единица', employees: [], details: '' };
describe('parseVacancyReasons', () => {
it('читает JSON новой колонки', () => {
expect(parseVacancyReasons(JSON.stringify([replace, newUnit]), '', '')).toEqual([replace, newUnit]);
});
it('старая заявка: причина с ФИО → один сотрудник', () => {
expect(parseVacancyReasons(null, 'Декрет', 'Иванова А. И.')).toEqual([
{ reason: 'Декрет', employees: ['Иванова А. И.'], details: '' }
]);
});
it('старая заявка: «Другое» → уточнение', () => {
expect(parseVacancyReasons('', 'Другое', 'расширение штата')).toEqual([
{ reason: 'Другое', employees: [], details: 'расширение штата' }
]);
});
it('нет ни JSON, ни старой причины — пусто', () => {
expect(parseVacancyReasons(null, null, null)).toEqual([]);
});
});
describe('сводка для старых колонок', () => {
it('склеивает причины и людей', () => {
expect(summarizeVacancyReasons([replace, newUnit])).toBe(
'Замена уволенного сотрудника: Иванов И. И., Петров П. П.; Новая штатная единица'
);
});
it('обрезает под лимит текстовой колонки SP', () => {
const many = { ...replace, employees: Array.from({ length: 40 }, (_, i) => `Сотрудник ${i}`) };
const text = legacyDetailsText([many]);
expect(text.length).toBe(255);
expect(text.endsWith('…')).toBe(true);
});
it('чистит пустые ФИО и чужие поля причины', () => {
expect(cleanVacancyReasons([{ reason: 'Новая штатная единица', employees: ['x'], details: 'y' }])).toEqual([newUnit]);
expect(cleanVacancyReasons([{ ...replace, employees: [' Иванов ', ''] }])[0].employees).toEqual(['Иванов']);
});
});
describe('проверки', () => {
it('несколько причин при одной вакансии — ошибка', () => {
expect(vacancyReasonsError([replace, newUnit], 1)).toBeDefined();
expect(vacancyReasonsError([replace, newUnit], 3)).toBeUndefined();
});
it('сотрудников больше, чем вакансий — предупреждение', () => {
expect(vacancyReasonsWarning([replace], 1)).toBeDefined();
expect(vacancyReasonsWarning([replace], 2)).toBeUndefined();
});
it('разбирает количество вакансий', () => {
expect(parseVacanciesCount('3')).toBe(3);
expect(parseVacanciesCount('')).toBe(0);
expect(parseVacanciesCount('-2')).toBe(0);
});
});
@@ -0,0 +1,128 @@
import {
IVacancyReason,
OPEN_REASON_MATERNITY,
OPEN_REASON_REPLACE,
OPEN_REASON_TRANSFER,
OTHER_VALUE
} from './model';
/**
* Причины открытия вакансии (п. 1.5): несколько причин при 2.2 > 1 и несколько
* сотрудников на причину. Логика без UI — разбор/сводка/проверки.
*
* Хранение: полная структура — JSON в VacancyReasonsJson (из неё читает
* портал). Для совместимости (список SP напрямую, старые отчёты) пишутся и
* старые колонки: VacancyOpenReason — ПЕРВАЯ причина (Choice, одно значение),
* VacancyOpenReasonDetails — всё целиком текстом (см. summarizeVacancyReasons).
* Имена старых колонок не менять: префикс Vacancy — обход коллизии managed
* properties Search (переименовали и перенесли данные 2026-08-29).
*/
/** Причины, где вакансия открывается под конкретных сотрудников — спрашиваем их ФИО. */
const REASONS_WITH_EMPLOYEE_NAME: ReadonlyArray<string> = [
OPEN_REASON_REPLACE,
OPEN_REASON_TRANSFER,
OPEN_REASON_MATERNITY
];
/** Лимит колонки «Однострочный текст» SharePoint — длиннее запись упадёт. */
const LEGACY_TEXT_MAX = 255;
export function needsEmployeeName(reason: string): boolean {
return REASONS_WITH_EMPLOYEE_NAME.indexOf(reason) !== -1;
}
export function needsDetails(reason: string): boolean {
return reason === OTHER_VALUE;
}
/** Новая запись причины — без людей и уточнения. */
export function emptyReason(reason: string): IVacancyReason {
return { reason, employees: [], details: '' };
}
function isVacancyReason(item: unknown): item is IVacancyReason {
const record = item as Record<string, unknown> | null;
return !!record && typeof record.reason === 'string' && Array.isArray(record.employees);
}
/**
* Причины из элемента списка: JSON новой колонки, а если его нет (заявки до
* мультипричин) — из старых колонок «причина» + «ФИО / уточнение».
*/
export function parseVacancyReasons(json: unknown, legacyReason: unknown, legacyDetails: unknown): IVacancyReason[] {
if (typeof json === 'string' && json.trim() !== '') {
try {
const parsed: unknown = JSON.parse(json);
if (Array.isArray(parsed)) {
return parsed.filter(isVacancyReason).map((r) => ({
reason: r.reason,
employees: r.employees.filter((e): e is string => typeof e === 'string'),
details: typeof r.details === 'string' ? r.details : ''
}));
}
} catch {
// битый JSON — падаем на старые колонки ниже
}
}
if (typeof legacyReason !== 'string' || legacyReason === '') return [];
const details = typeof legacyDetails === 'string' ? legacyDetails.trim() : '';
if (needsEmployeeName(legacyReason)) {
return [{ reason: legacyReason, employees: details ? [details] : [], details: '' }];
}
return [{ reason: legacyReason, employees: [], details: needsDetails(legacyReason) ? details : '' }];
}
/** Перед записью: без пустых ФИО и лишних пробелов, у каждой причины — только её поля. */
export function cleanVacancyReasons(reasons: IVacancyReason[]): IVacancyReason[] {
return reasons.map((r) => ({
reason: r.reason,
employees: needsEmployeeName(r.reason) ? r.employees.map((e) => e.trim()).filter(Boolean) : [],
details: needsDetails(r.reason) ? r.details.trim() : ''
}));
}
/** Все причины одной строкой: «Замена уволенного сотрудника: Иванов, Петров; Новая штатная единица». */
export function summarizeVacancyReasons(reasons: IVacancyReason[]): string {
return cleanVacancyReasons(reasons)
.map((r) => {
const extra = r.employees.length > 0 ? r.employees.join(', ') : r.details;
return extra ? `${r.reason}: ${extra}` : r.reason;
})
.join('; ');
}
/** Сводка для старой текстовой колонки — обрезана под её лимит. */
export function legacyDetailsText(reasons: IVacancyReason[]): string {
const summary = summarizeVacancyReasons(reasons);
return summary.length > LEGACY_TEXT_MAX ? `${summary.slice(0, LEGACY_TEXT_MAX - 1)}…` : summary;
}
/** Число из поля 2.2; пусто/мусор — 0. */
export function parseVacanciesCount(raw: string): number {
const n = Number(raw);
return isFinite(n) && n > 0 ? Math.floor(n) : 0;
}
/** Можно ли выбирать несколько причин и сотрудников (п. 2.2 больше одной). */
export function allowsMultipleReasons(vacanciesCount: number): boolean {
return vacanciesCount > 1;
}
/** Блокирующая проблема для отправки: несколько причин при одной вакансии. */
export function vacancyReasonsError(reasons: IVacancyReason[], vacanciesCount: number): string | undefined {
if (!allowsMultipleReasons(vacanciesCount) && reasons.length > 1) {
return 'При одной вакансии оставьте одну причину открытия (п. 1.5) или увеличьте количество вакансий (п. 2.2).';
}
return undefined;
}
/** Предупреждение (отправку не блокирует): сотрудников указано больше, чем вакансий. */
export function vacancyReasonsWarning(reasons: IVacancyReason[], vacanciesCount: number): string | undefined {
const employees = cleanVacancyReasons(reasons).reduce((sum, r) => sum + r.employees.length, 0);
if (vacanciesCount > 0 && employees > vacanciesCount) {
return `Указано сотрудников: ${employees}, а вакансий: ${vacanciesCount}. Проверьте п. 1.5 и 2.2.`;
}
return undefined;
}
@@ -1,4 +1,5 @@
import { IFormData } from './model';
import { parseVacanciesCount, vacancyReasonsError } from './vacancyReasons';
/**
* Поля формы, обязательные для ОТПРАВКИ на согласование (черновик по-прежнему
@@ -16,7 +17,7 @@ import { IFormData } from './model';
const REQUIRED_FIELDS: ReadonlyArray<{ label: string; ok: (d: IFormData) => boolean }> = [
{ label: '1.3 дата подачи заявки', ok: (d) => !!d.SubmitDate },
{ label: '1.4 крайний срок закрытия', ok: (d) => !!d.ClosingDeadline },
{ label: '1.5 причина открытия вакансии', ok: (d) => !!d.VacancyOpenReason },
{ label: '1.5 причина открытия вакансии', ok: (d) => d.VacancyReasons.length > 0 },
{ label: '1.6 организация', ok: (d) => d.Companies.length > 0 },
{ label: '2.1 подразделение', ok: (d) => !!d.Department.trim() },
{ label: '2.2 количество открытых вакансий', ok: (d) => !!d.OpenVacanciesCount.trim() },
@@ -40,3 +41,14 @@ const REQUIRED_FIELDS: ReadonlyArray<{ label: string; ok: (d: IFormData) => bool
export function missingRequiredFields(data: IFormData): string[] {
return REQUIRED_FIELDS.filter((f) => !f.ok(data)).map((f) => f.label);
}
/**
* Всё, что мешает отправке/согласованию, одной строкой: сначала незаполненные
* обязательные поля, затем согласованность причин с количеством вакансий.
* undefined — отправлять можно.
*/
export function submitBlocker(data: IFormData): string | undefined {
const missing = missingRequiredFields(data);
if (missing.length > 0) return `Заполните обязательные поля: ${missing.join(', ')}.`;
return vacancyReasonsError(data.VacancyReasons, parseVacanciesCount(data.OpenVacanciesCount));
}