fix: в «Моих заявках» только заявки с сайта портала
Tests / frontend-test (push) Successful in 1m13s

This commit is contained in:
Тимур Городецкий
2026-09-28 11:27:30 +05:00
parent 36922af942
commit bfe0b95f26
7 changed files with 403 additions and 65 deletions
@@ -0,0 +1,105 @@
import { REQUEST_STATUS } from '../../shared/approval';
import { FORM_ID as HIRE_FORM_ID } from './model';
import { queryAllPartitions, statusFilter } from './partitionQuery';
/**
* Заявки на приём, связанные с заявкой на подбор, и занятость вакансий подбора.
*
* Одна заявка на приём = один принятый человек, поэтому подбор на N вакансий
* закрывается не больше чем N приёмами. Связь хранится в самом приёме
* (SelectionRequestId + SelectionRequestListId, см. selectionLookup.ts), так
* что считаем по списку приёмов — отдельных колонок в подборе нет.
*/
// Приём занимает вакансию, когда дошёл до исполнения (так решил заказчик,
// 2026-09-28). «Закрыта» идёт после «Выполнения» — закрытие приёма вакансию не
// освобождает. «Согласовано» — конечный статус приёма, если в маршруте нет шагов
// исполнения (при исполнителях заявка его минует и сразу уходит в «Выполнение»,
// см. approverDecision.service.ts) — без него такие приёмы не занимали бы
// вакансию никогда. Черновики, приёмы на согласовании, возвращённые и
// отклонённые вакансию не занимают.
const OCCUPYING_HIRE_STATUSES: string[] = [REQUEST_STATUS.approved, REQUEST_STATUS.execution, REQUEST_STATUS.closed];
export function isOccupyingHireStatus(status: string): boolean {
return OCCUPYING_HIRE_STATUSES.indexOf(status) !== -1;
}
/** Ссылка приёма на подбор: Id подбора + его партиция ('' — партиция неизвестна). */
export interface ISelectionRef {
selectionId: number;
selectionListId: string;
}
/**
* Относится ли ссылка приёма к подбору. Id повторяются в разных партициях
* серии, поэтому сравниваем и партицию. Но если она неизвестна хоть с одной
* стороны (реестр партиций не загрузился — при выборе подбора в приёме или
* сейчас), сопоставляем по одному Id: иначе такой приём не был бы виден в
* подборе и не занимал бы вакансию вовсе.
*/
function belongsToSelection(ref: ISelectionRef, selectionId: number, selectionListId: string): boolean {
if (ref.selectionId !== selectionId) return false;
if (!ref.selectionListId || !selectionListId) return true;
return ref.selectionListId.toLowerCase() === selectionListId.toLowerCase();
}
/** Ссылки на подбор всех приёмов, которые уже заняли вакансию. */
export async function getOccupyingHireRefs(): Promise<ISelectionRef[]> {
const rows = await queryAllPartitions(HIRE_FORM_ID, statusFilter(OCCUPYING_HIRE_STATUSES), [
'Id',
'SelectionRequestId',
'SelectionRequestListId'
]);
return (
rows
.map(({ row }) => ({
selectionId: Number(row.SelectionRequestId) || 0,
selectionListId: (row.SelectionRequestListId as string) || ''
}))
// Приём без связи с подбором (связь необязательна) вакансий не занимает.
.filter((ref) => ref.selectionId > 0)
);
}
/** Сколько приёмов из refs заняли вакансии конкретного подбора. */
export function countOccupied(refs: ISelectionRef[], selectionId: number, selectionListId: string): number {
return refs.filter((ref) => belongsToSelection(ref, selectionId, selectionListId)).length;
}
export interface ILinkedHire {
id: number;
/** Партиция, где лежит приём (→ rplist в ссылке). '' — партиция неизвестна. */
listId: string;
code: string;
candidateName: string;
status: string;
}
/**
* Все поданные приёмы по подбору, свежие сверху. Черновики не показываем — это
* ещё не поданные заявки инициатора, в подборе им не место.
*/
export async function getLinkedHires(selectionId: number, selectionListId: string): Promise<ILinkedHire[]> {
const rows = await queryAllPartitions(
HIRE_FORM_ID,
`SelectionRequestId eq ${selectionId} and RequestStatus ne '${REQUEST_STATUS.draft}'`,
['Id', 'RequestUniqueCode', 'HireCandidateName', 'RequestStatus', 'SelectionRequestListId']
);
return rows
.filter(({ row }) =>
belongsToSelection(
{ selectionId, selectionListId: (row.SelectionRequestListId as string) || '' },
selectionId,
selectionListId
)
)
.map(({ listId, row }) => ({
id: Number(row.Id),
listId,
code: (row.RequestUniqueCode as string) || '',
candidateName: (row.HireCandidateName as string) || '',
status: (row.RequestStatus as string) || ''
}));
}
@@ -0,0 +1,80 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { partitionsForPageUrl } from '../../shared/approval';
import { RequestTypeId } from '../../models/types';
/** Строка списка данных как её отдаёт SharePoint REST. */
export type ListRow = Record<string, unknown>;
/** Строка вместе с партицией, из которой она прочитана ('' — партиция неизвестна). */
export interface IPartitionRow {
listId: string;
row: ListRow;
}
// Предохранитель: партиция всё равно ограничена порогом элементов, но берём
// разумный потолок, чтобы одиночный запрос не тянул весь список целиком.
const MAX_ITEMS = 500;
interface IListLike {
items: {
filter: (f: string) => {
select: (...fields: string[]) => {
orderBy: (field: string, ascending: boolean) => {
top: (n: number) => () => Promise<ListRow[]>;
};
};
};
};
}
async function queryList(list: IListLike, filter: string, fields: string[]): Promise<ListRow[]> {
return list.items
.filter(filter)
.select(...fields)
.orderBy('Id', false)
.top(MAX_ITEMS)();
}
/**
* Прочитать все партиции серии формы (архив + текущая); строки помечаются
* listId своей партиции. Реестр недоступен (approval-route молчал при старте) →
* откатываемся на текущий список по имени, listId тогда ''. Сбой чтения одной
* партиции не роняет остальные.
*/
export async function queryAllPartitions(
formId: RequestTypeId,
filter: string,
fields: string[]
): Promise<IPartitionRow[]> {
const sp = getSP();
const partitions = partitionsForPageUrl(formId);
if (partitions.length === 0) {
const rows = await queryList(
sp.web.lists.getByTitle(dataListTitle(formId)) as unknown as IListLike,
filter,
fields
);
return rows.map((row) => ({ listId: '', row }));
}
const perPartition = await Promise.all(
partitions.map((p) =>
queryList(sp.web.getList(p.listId) as unknown as IListLike, filter, fields)
.then((rows) => rows.map((row) => ({ listId: p.listId, row })))
.catch((e) => {
console.error(`[${formId}] партиция «${p.title}» (${p.listId}) не прочитана:`, (e as Error).message);
return [] as IPartitionRow[];
})
)
);
// Партиции идут по возрастанию номера, внутри уже отсортировано по Id убыв.
return perPartition.reduce((acc, cur) => acc.concat(cur), []);
}
/** OData-условие «RequestStatus — один из». */
export function statusFilter(statuses: string[]): string {
return statuses.map((s) => `RequestStatus eq '${s}'`).join(' or ');
}
@@ -22,6 +22,11 @@ function optionLabel(o: ISelectionRequestOption): string {
return o.position ? `${head} — ${o.position}` : head;
}
/** «Занято 2 из 6» — только если в подборе указано количество вакансий. */
function vacanciesLabel(o: ISelectionRequestOption): string {
return o.vacancies > 0 ? `Занято ${o.hired} из ${o.vacancies}` : '';
}
/**
* Поле «Заявка на подбор» в форме приёма: поиск-выбор согласованной заявки на
* подбор + ссылка на неё (открывается в новой вкладке через RecruitingSelectionView).
@@ -206,11 +211,12 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
>
{optionLabel(o)}
{o.title && <span className={styles.meta}>{o.title}</span>}
{vacanciesLabel(o) && <span className={styles.meta}>{vacanciesLabel(o)}</span>}
</button>
))
) : (
<div className={styles.note}>
{loaded ? 'Нет утверждённых заявок на подбор.' : 'Загрузка…'}
{loaded ? 'Нет утверждённых заявок на подбор со свободными вакансиями.' : 'Загрузка…'}
</div>
)}
</div>
@@ -1,7 +1,7 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { REQUEST_STATUS, partitionsForPageUrl } from '../../shared/approval';
import { REQUEST_STATUS } from '../../shared/approval';
import { RequestTypeId } from '../../models/types';
import { queryAllPartitions, statusFilter } from './partitionQuery';
import { countOccupied, getOccupyingHireRefs } from './linkedHires';
/**
* Выборка «Заявок на подбор» для связывания из «Заявки на прием».
@@ -11,6 +11,9 @@ import { RequestTypeId } from '../../models/types';
* shared/approval/listPartitions.ts), а Lookup умеет ссылаться ровно на один
* список и через границу партиции ломается. Поэтому здесь мы читаем список(и)
* подбора «руками» и отдаём ровно то, что нужно для deep-link портала (rpid/rplist).
*
* Подборы, у которых все вакансии уже заняты приёмами (см. linkedHires.ts), в
* выборку не попадают.
*/
/** Слаг формы подбора — ключ реестра форм и pageUrl типа заявки в approval-route. */
@@ -26,79 +29,50 @@ export interface ISelectionRequestOption {
position: string;
/** Системный Title элемента — фолбэк подписи, если код/должность пусты. */
title: string;
/** Количество открытых вакансий из подбора. 0 — не указано (старые заявки), лимита нет. */
vacancies: number;
/** Сколько заявок на приём уже заняли вакансии этого подбора. */
hired: number;
}
// Только утверждённые заявки на подбор: приём оформляют по уже утверждённому подбору.
// После согласования подбор уходит исполнителю («Выполнение»), затем «Закрыта» —
// это тот же утверждённый подбор, поэтому берём все три статуса (как isApprovedStatus).
const APPROVED_STATUSES: string[] = [REQUEST_STATUS.approved, REQUEST_STATUS.execution, REQUEST_STATUS.closed];
const APPROVED_FILTER = APPROVED_STATUSES.map((s) => `RequestStatus eq '${s}'`).join(' or ');
// Предохранитель: партиция всё равно ограничена порогом элементов, но берём
// разумный потолок, чтобы одиночный запрос не тянул весь список целиком.
const MAX_ITEMS = 500;
interface IListLike {
items: {
filter: (f: string) => {
select: (...fields: string[]) => {
orderBy: (field: string, ascending: boolean) => {
top: (n: number) => () => Promise<Record<string, unknown>[]>;
};
};
};
};
}
async function queryPartition(list: IListLike, listId: string): Promise<ISelectionRequestOption[]> {
const rows = await list.items
.filter(APPROVED_FILTER)
.select('Id', 'Title', 'RequestUniqueCode', 'Position')
.orderBy('Id', false)
.top(MAX_ITEMS)();
return rows.map((row) => ({
id: Number(row.Id),
listId,
code: (row.RequestUniqueCode as string) || '',
position: (row.Position as string) || '',
title: (row.Title as string) || ''
}));
/** У подбора указано количество вакансий, и все они уже заняты приёмами. */
function isSelectionFilled(o: ISelectionRequestOption): boolean {
return o.vacancies > 0 && o.hired >= o.vacancies;
}
/**
* Все утверждённые (Согласовано / Выполнение / Закрыта) заявки на подбор из всех партиций серии (архив + текущая).
*
* Реестр партиций знает серию → читаем каждую партицию по её listId (url),
* заодно получая правильный rplist для ссылки. Реестр недоступен (approval-route
* молчал при старте) → откатываемся на текущий список по имени; ссылка тогда
* пойдёт без rplist и просмотр откроется в текущей партиции (см. routing.ts).
* Сбой чтения одной партиции не роняет остальные.
* Утверждённые (Согласовано / Выполнение / Закрыта) заявки на подбор из всех
* партиций серии, у которых остались свободные вакансии.
*/
export async function getApprovedSelectionRequests(): Promise<ISelectionRequestOption[]> {
const sp = getSP();
const partitions = partitionsForPageUrl(SELECTION_FORM_ID);
const [selections, hireRefs] = await Promise.all([
queryAllPartitions(SELECTION_FORM_ID, statusFilter(APPROVED_STATUSES), [
'Id',
'Title',
'RequestUniqueCode',
'Position',
'OpenVacanciesCount'
]),
getOccupyingHireRefs()
]);
if (partitions.length === 0) {
return queryPartition(
sp.web.lists.getByTitle(dataListTitle(SELECTION_FORM_ID)) as unknown as IListLike,
''
);
}
const options = selections.map(({ listId, row }) => {
const id = Number(row.Id);
return {
id,
listId,
code: (row.RequestUniqueCode as string) || '',
position: (row.Position as string) || '',
title: (row.Title as string) || '',
vacancies: Number(row.OpenVacanciesCount) || 0,
hired: countOccupied(hireRefs, id, listId)
};
});
const perPartition = await Promise.all(
partitions.map((p) =>
queryPartition(sp.web.getList(p.listId) as unknown as IListLike, p.listId).catch((e) => {
console.error(
`[Связь с подбором] партиция «${p.title}» (${p.listId}) не прочитана:`,
(e as Error).message
);
return [] as ISelectionRequestOption[];
})
)
);
// Новые заявки (больший Id) — выше; между партициями сортировать не нужно:
// партиции идут по возрастанию номера, внутри уже отсортировано по Id убыв.
return perPartition.reduce((acc, cur) => acc.concat(cur), []);
return options.filter((o) => !isSelectionFilled(o));
}
@@ -52,6 +52,8 @@ import { DepartmentSection } from './sections/DepartmentSection';
import { VacancySection } from './sections/VacancySection';
import { HealthTripsSection } from './sections/HealthTripsSection';
import { ConditionsSection } from './sections/ConditionsSection';
import { HiresProgressSection } from './sections/HiresProgressSection';
import { parseVacanciesCount } from './vacancyReasons';
import { useStableCallback } from '../../shared/useStableCallback';
interface IRecruitingSelectionViewProps {
@@ -287,6 +289,14 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
<FormModeProvider readOnly={!fullEditable}>
<ApplicationSection {...sectionProps} />
<DepartmentSection {...sectionProps} />
{/* Приём оформляют только по утверждённому подбору — до этого блоку показывать нечего. */}
{isApprovedStatus(status) && (
<HiresProgressSection
selectionId={itemId}
selectionListId={listId || ''}
vacancies={parseVacanciesCount(data.OpenVacanciesCount)}
/>
)}
<VacancySection {...sectionProps} />
<HealthTripsSection {...sectionProps} />
<ConditionsSection {...sectionProps} />
@@ -0,0 +1,53 @@
@import '../../../../../styles/design-tokens';
.value {
font-family: $font-sans;
font-size: 13.5px;
font-weight: 600;
color: $gray-900;
}
.note {
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-500;
}
.list {
display: flex;
flex-direction: column;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
// На узких экранах строка переносится: ссылка и статус остаются, ФИО уходит ниже.
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-2 $sp-3;
font-family: $font-sans;
font-size: $fs-sm;
}
.link {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 600;
color: $navy-700;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.name {
flex: 1 1 160px;
min-width: 0;
color: $gray-900;
overflow-wrap: anywhere;
}
@@ -0,0 +1,110 @@
import * as React from 'react';
import styles from './HiresProgressSection.module.scss';
import { Alert, CollapseSection, FormField } from '../../../shared/ui';
import { StatusBadge } from '../../../shared/approval';
import { routeUrl } from '../../../shared/routing';
import { friendlyErrorMessage } from '../../../shared/diagnostics';
import { FORM_ID as HIRE_FORM_ID } from '../../RecruitingHire/model';
import { getLinkedHires, ILinkedHire, isOccupyingHireStatus } from '../../RecruitingHire/linkedHires';
interface IHiresProgressSectionProps {
selectionId: number;
/** Партиция, где лежит заявка на подбор ('' — неизвестна). */
selectionListId: string;
/** Количество открытых вакансий из 2.2. 0 — не указано. */
vacancies: number;
}
const TEXT = {
title: 'Приём по заявке',
occupied: 'Занято вакансий',
hires: 'Заявки на приём',
loading: 'Загрузка…',
empty: 'Заявок на приём по этому подбору пока нет.',
loadError: 'Не удалось загрузить заявки на приём'
};
function occupiedLabel(occupied: number, vacancies: number): string {
return vacancies > 0 ? `${occupied} из ${vacancies}` : String(occupied);
}
function hireHref(hire: ILinkedHire): string {
return routeUrl({
mode: 'approve',
nav: 'new',
formKey: HIRE_FORM_ID,
itemId: hire.id,
listId: hire.listId || undefined
});
}
/**
* Просмотр подбора: сколько вакансий уже заняли приёмы и какие приёмы поданы.
* Данные живые (читаются из списков приёма), поэтому на печать не выводятся.
* Правило «приём занимает вакансию» — то же, что и в выборе подбора в форме
* приёма (см. RecruitingHire/linkedHires.ts).
*/
export const HiresProgressSection: React.FC<IHiresProgressSectionProps> = ({
selectionId,
selectionListId,
vacancies
}) => {
const [hires, setHires] = React.useState<ILinkedHire[]>([]);
const [isLoading, setIsLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string>('');
React.useEffect(() => {
let alive = true;
setIsLoading(true);
getLinkedHires(selectionId, selectionListId)
.then((rows) => {
if (!alive) return;
setHires(rows);
setError('');
})
.catch((e) => {
if (alive) setError(`${TEXT.loadError}: ${friendlyErrorMessage(e)}`);
})
.finally(() => {
if (alive) setIsLoading(false);
});
return () => {
alive = false;
};
}, [selectionId, selectionListId]);
const occupied = hires.filter((h) => isOccupyingHireStatus(h.status)).length;
const renderHires = (): React.ReactNode => {
if (isLoading) return <span className={styles.note}>{TEXT.loading}</span>;
if (error) return <Alert variant="err">{error}</Alert>;
if (hires.length === 0) return <span className={styles.note}>{TEXT.empty}</span>;
return (
<ul className={styles.list}>
{hires.map((h) => (
<li key={`${h.listId}:${h.id}`} className={styles.row}>
<a className={styles.link} href={hireHref(h)} target="_blank" rel="noopener noreferrer">
№ {h.code || h.id}
<span aria-hidden="true">↗</span>
</a>
<span className={styles.name}>{h.candidateName || '—'}</span>
{h.status && <StatusBadge status={h.status} />}
</li>
))}
</ul>
);
};
return (
<div className="rp-no-print">
<CollapseSection title={TEXT.title} icon="people">
<FormField label={TEXT.occupied}>
<span className={styles.value}>{isLoading || error ? '—' : occupiedLabel(occupied, vacancies)}</span>
</FormField>
<FormField label={TEXT.hires} full>
{renderHires()}
</FormField>
</CollapseSection>
</div>
);
};