Заявление реферальная программа (#13)
Tests / frontend-test (push) Successful in 1m20s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
2026-08-21 12:27:34 +00:00
parent 131e302f67
commit fd19ddc41a
27 changed files with 1306 additions and 13 deletions
@@ -3,7 +3,7 @@ import { resolveRoute } from './approvalRoute.service';
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
import { IMatrixRow } from './models/IMatrixRow';
import { ApprovalStatus, IResolveFieldValue } from './models/IApprovalRouteApi';
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
import { APPROVAL_PHASE, REQUEST_STATUS, isSubmittedStatus } from './models/statuses';
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
@@ -82,7 +82,11 @@ export async function writeRoute(
// При ОТПРАВКЕ все шаги пишем «Ждёт очереди», затем активируем первый —
// так активен ровно текущий шаг, остальные ждут. В черновике — статус заявки
// (Черновик), без активации.
const submitted = status === REQUEST_STATUS.onApproval;
//
// Именно предикат, а не `status === onApproval`: отправка маршрута без
// согласующих даёт «Выполнение» (см. initialSubmitStatus), и при строгом
// сравнении такая заявка не активировала ни одного шага исполнения.
const submitted = isSubmittedStatus(status);
const rowStatus = submitted ? REQUEST_STATUS.queued : status;
// Смещение фазы исполнения — по МАКСИМАЛЬНОМУ номеру шага согласующих, а не по
@@ -7,6 +7,8 @@ export {
APPROVAL_PHASE_CHOICES,
EDITABLE_STATUSES,
isEditableStatus,
initialSubmitStatus,
isSubmittedStatus,
} from './models/statuses';
export type { ISiteUser } from './models/ISiteUser';
export type { IMatrixRow } from './models/IMatrixRow';
@@ -5,7 +5,9 @@ import {
EDITABLE_STATUSES,
REQUEST_STATUS,
REQUEST_STATUS_CHOICES,
initialSubmitStatus,
isEditableStatus,
isSubmittedStatus,
} from './statuses';
describe('isEditableStatus', () => {
@@ -32,6 +34,43 @@ describe('isEditableStatus', () => {
});
});
describe('initialSubmitStatus', () => {
it('есть согласующие — заявка идёт на согласование', () => {
expect(initialSubmitStatus(2, 0)).toBe(REQUEST_STATUS.onApproval);
expect(initialSubmitStatus(2, 3)).toBe(REQUEST_STATUS.onApproval);
});
it('согласующих нет, но есть исполнители — сразу в исполнение', () => {
expect(initialSubmitStatus(0, 1)).toBe(REQUEST_STATUS.execution);
});
it('маршрута нет вовсе — заявка сразу согласована', () => {
expect(initialSubmitStatus(0, 0)).toBe(REQUEST_STATUS.approved);
});
});
// writeRoute по этому предикату решает, писать строки как «Ждёт очереди» и
// активировать первую, или оставить как есть (черновик). Каждый статус, который
// возвращает initialSubmitStatus, ОБЯЗАН считаться отправкой: иначе строки
// маршрута получают недопустимый статус, а активации не происходит вовсе —
// именно так заявка без согласующих перестала попадать в очередь исполнения.
describe('isSubmittedStatus', () => {
it('любой результат initialSubmitStatus считается отправкой', () => {
[initialSubmitStatus(1, 0), initialSubmitStatus(0, 1), initialSubmitStatus(0, 0)].forEach((s) =>
expect(isSubmittedStatus(s)).toBe(true)
);
});
it('черновик отправкой не считается', () => {
expect(isSubmittedStatus(REQUEST_STATUS.draft)).toBe(false);
});
it('возврат и отклонение отправкой не считаются (маршрут не переактивируется)', () => {
expect(isSubmittedStatus(REQUEST_STATUS.returned)).toBe(false);
expect(isSubmittedStatus(REQUEST_STATUS.rejected)).toBe(false);
});
});
// Наборы Choice-значений держатся в синхроне с provisioning.json списков —
// расхождение ломает запись (SharePoint отвергает значение вне Choice).
describe('наборы Choice-значений', () => {
@@ -43,6 +43,45 @@ export function isEditableStatus(status: string | undefined): boolean {
return !status || EDITABLE_STATUSES.indexOf(status) !== -1;
}
/**
* Статус ЗАЯВКИ в момент ОТПРАВКИ — до записи строк маршрута (см. writeRoute),
* когда реальных строк ещё нет и решать по ним, как computeRequestStatus в
* approverDecision.service.ts, нельзя. Формы раньше писали «На согласовании»
* безусловно; это неверно для маршрута без согласующих (только исполнение,
* см. форму «Реферальная рекомендация») — там заявка должна сразу войти в
* фазу исполнения, минуя несуществующую фазу согласования.
*
* Порядок фаз — тот же, что у computeRequestStatus (согласование → исполнение
* → закрыта), только по КОЛИЧЕСТВУ шагов, а не по фактическим статусам строк
* (при отправке все строки ещё «Ждёт очереди»/не существуют).
*/
export function initialSubmitStatus(approversCount: number, executorsCount: number): string {
if (approversCount > 0) return REQUEST_STATUS.onApproval;
if (executorsCount > 0) return REQUEST_STATUS.execution;
return REQUEST_STATUS.approved;
}
/**
* Статус заявки означает «отправлена» (в отличие от черновика)?
*
* Нужен writeRoute: он по этому признаку решает, писать строки маршрута как
* «Ждёт очереди» + активировать первую, или сохранить их «как есть» (черновик).
* Раньше там стояло строгое `status === onApproval` — единственный статус,
* которым формы отправляли заявку. С появлением initialSubmitStatus отправка
* может дать «Выполнение» (маршрут без согласующих), и строгое сравнение молча
* ломалось: строки получали ApprovalStatus «Выполнение» (недопустимое значение
* для строки, см. APPROVAL_ROW_STATUS_CHOICES), а activateNextQueued не
* вызывался — ни одна строка не становилась «На исполнении», и заявка не
* попадала в очередь исполнителя.
*/
export function isSubmittedStatus(status: string): boolean {
return (
status === REQUEST_STATUS.onApproval ||
status === REQUEST_STATUS.execution ||
status === REQUEST_STATUS.approved
);
}
// Наборов значений ДВА, и они не совпадают — это разные сущности:
//
// 1) статус ЗАЯВКИ — колонка RequestStatus в списке «… — данные». Пишется
@@ -42,6 +42,11 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
}) => {
const { rows, loading, error, editable, blocks } = editor;
// Подпись «доп. согласующий»/«доп. исполнитель» — из roleLabel, а не
// хардкод: иначе блок «Исполнение» (roleLabel="Исполнитель") показывал бы
// добавленному через переназначение исполнителю подпись «Доп. согласующий».
const addedByLabel = `Доп. ${roleLabel.charAt(0).toLowerCase()}${roleLabel.slice(1)}, добавил:`;
// Ключи строк и анимация их перестановки. Ключи обязаны быть устойчивыми
// (не индекс) — иначе React переписывает строки на месте, и «переезжать»
// при перестановке шага будет нечему (см. matrixRowKeys / useFlipRows).
@@ -155,13 +160,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
{!r.who && r.warning && (
<span className={styles.warningInline}>{r.warning}</span>
)}
{/* Доп. согласующий, добавленный другим согласующим (с правом
переназначения) — помечаем, кто его добавил. Пусто у обычных
строк маршрута. Как и комментарий — inline под именем, чтобы
не ломать rowSpan колонки «Шаг» у параллельных шагов. */}
{/* Доп. участник шага, добавленный переназначением — помечаем,
кто его добавил. Пусто у обычных строк маршрута. Как и
комментарий — inline под именем, чтобы не ломать rowSpan
колонки «Шаг» у параллельных шагов. */}
{r.addedBy && (
<span className={styles.addedByInline}>
<span className={styles.addedByLabel}>Доп. согласующий, добавил:</span>{' '}
<span className={styles.addedByLabel}>{addedByLabel}</span>{' '}
{r.addedBy}
</span>
)}
@@ -142,12 +142,18 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
}
if (delegated > 0) {
// Склоняем по фазе шага: у исполнения нет «согласующих» — тот, кого
// добавили, реально выполняет заявку, а не рассматривает её.
const who = isExecution ? 'Исполнитель' : 'Согласующий';
const whoMany = isExecution ? 'Исполнителей' : 'Согласующих';
const verb = isExecution ? 'выполнит' : 'рассмотрит';
const verbMany = isExecution ? 'выполнят' : 'рассмотрят';
return (
<div className={styles.panel}>
<Alert variant="ok">
{delegated === 1
? 'Согласующий добавлен перед вами — он рассмотрит заявку раньше вас. Ваш шаг остаётся в маршруте.'
: `Согласующих добавлено: ${delegated}. Они рассмотрят заявку раньше вас, в порядке добавления. ` +
? `${who} добавлен перед вами — он ${verb} заявку раньше вас. Ваш шаг остаётся в маршруте.`
: `${whoMany} добавлено: ${delegated}. Они ${verbMany} заявку раньше вас, в порядке добавления. ` +
'Ваш шаг остаётся в маршруте.'}
</Alert>
</div>
@@ -234,9 +240,11 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
<div className={styles.panel}>
<div className={styles.title}>{isExecution ? 'Исполнение' : 'Ваше решение'}</div>
<Alert variant="warn">
Заявку сейчас рассматривает сотрудник, которого вы добавили перед собой. Ваш шаг никуда
не делся — он вернётся к вам, когда очередь дойдёт. Если добавленный сотрудник
недоступен, можно добавить ещё.
{isExecution
? 'Заявку сейчас выполняет сотрудник, которого вы добавили перед собой.'
: 'Заявку сейчас рассматривает сотрудник, которого вы добавили перед собой.'}{' '}
Ваш шаг никуда не делся — он вернётся к вам, когда очередь дойдёт. Если добавленный
сотрудник недоступен, можно добавить ещё.
</Alert>
{error && <Alert variant="err">{error}</Alert>}
{delegateBlock}
@@ -32,6 +32,7 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
'it-1c-ticket': 'Заявка в отдел 1С',
'it-workplace-setup': 'Организация рабочего места',
'recruiting-hire': 'Прием на работу',
'recruiting-referral': 'Реферальная рекомендация',
'recruiting-selection': 'Заявка на подбор',
'wh-tmc-transfer': 'СЗ на перемещение ТМЦ'
};