feat(ct): кнопка «На доработку» у согласующего; после доработки маршрут идёт с начала

Вернуть СТ инициатору на исправление было нельзя — только «Отклонить». Теперь у согласующего
(шаг 4 и шаг 3 по ссылке из Teams) есть «На доработку» с обязательным комментарием. Строка и СТ
получают статус «На доработке», СТ сразу открыта инициатору для правки, на шаге 4 — плашка, кто
вернул и что исправить. Повторная отправка переписывает маршрут целиком — согласование с начала.
«Отклонить» с перезапуском работает как раньше.

Решение rework добавлено в общий ApproverService/DecisionPanel, но кнопка включена только в СТ ОМТС
(allowRework); в ОИД/КПС статус недостижим. В SP в Choice status «Сравнительная таблица» и
«Согласование СТ» добавлено «На доработке» (и «Отклонено» в статус СТ, которое форма уже писала).
Уведомление инициатору при доработке — в потоке Power Automate (вне репо).
This commit is contained in:
Тимур Городецкий
2026-10-01 17:26:29 +05:00
parent 04cd6fe690
commit 2593021a70
16 changed files with 164 additions and 23 deletions
@@ -4,14 +4,17 @@ const STATUS_LABEL: Record<string, string> = {
'Черновик': 'Сформирована',
'На согласовании': 'На согласовании',
'Согласовано': 'Согласована',
'Отклонено': 'Отклонена'
'Отклонено': 'Отклонена',
'На доработке': 'На доработке'
};
const STATUS_VARIANT: Record<string, DashboardRow['statusVariant']> = {
'Черновик': 'neutral',
'На согласовании': 'info',
'Согласовано': 'success',
'Отклонено': 'error'
'Отклонено': 'error',
// В реестре нет жёлтого варианта — как «На согласовании»: процесс идёт, ждём действий инициатора.
'На доработке': 'info'
};
export function createCtDocumentConfig(): DocumentTypeConfig {
@@ -21,9 +21,10 @@ const STATUS_LABELS: Record<CTStatus, string> = {
on_approval: 'На согласовании',
approved: 'Согласована',
rejected: 'Отклонена',
rework: 'На доработке',
};
const STATUS_VARIANT: Record<CTStatus, 'neutral' | 'info' | 'success' | 'danger'> = {
const STATUS_VARIANT: Record<CTStatus, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
draft: 'neutral',
kp_loading: 'neutral',
kp_loaded: 'neutral',
@@ -31,6 +32,7 @@ const STATUS_VARIANT: Record<CTStatus, 'neutral' | 'info' | 'success' | 'danger'
on_approval: 'info',
approved: 'success',
rejected: 'danger',
rework: 'warning',
};
// Адаптер над Stepper/FormHeader из @tnsu/ui-kit (по образцу KPS Breadcrumbs): контракт props формы
@@ -17,6 +17,7 @@ const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
Отклонено: 'rejected',
Согласовано: 'approved',
'На согласовании': 'on_approval',
'На доработке': 'rework',
};
// Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ по Teams-ссылке (?ctid=),
@@ -56,6 +57,7 @@ export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave
onDecided={handleDecided}
search={(q) => service.searchUsers(q)}
onDelegated={() => setDecisionSeq((n) => n + 1)}
allowRework
/>
)}
</>
@@ -1,10 +1,11 @@
import * as React from "react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button, Alert, Spinner, Dialog, Icon } from "@tnsu/ui-kit";
import { CTStatus, FormStep } from "../types/domain";
import { CtService } from "../../services/CtService";
import { CTFormData } from "../hooks/useCtForm";
import { useIncore } from "./useIncore";
import { findReworkRow } from "../utils/approvalAccess";
import {
ApprovalMatrix,
DecisionPanel,
@@ -30,6 +31,7 @@ const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
Отклонено: "rejected",
Согласовано: "approved",
"На согласовании": "on_approval",
"На доработке": "rework",
};
const INCORE_STEP_LABELS: Record<string, string> = {
@@ -57,6 +59,20 @@ export const Step4Approval: React.FC<Props> = ({ service, formData, onPrev, onSa
const incore = useIncore({ service, formData, isInitiator });
// Возврат на доработку: кто вернул и что исправить — из записанного маршрута. Сам шаг сейчас показывает
// новый планируемый маршрут для повторной отправки (идёт с начала), старых строк в нём не видно.
const [reworkRow, setReworkRow] = useState<IMatrixRow | undefined>(undefined);
useEffect(() => {
if (status !== "rework" || !ctId) {
setReworkRow(undefined);
return;
}
service
.getApprovalMatrixForRequest(ctId)
.then((rows) => setReworkRow(findReworkRow(rows)))
.catch((err) => console.warn("[Шаг 4] не удалось загрузить комментарий возврата на доработку:", err));
}, [service, ctId, status]);
const loadMatrix = useMemo(
() => (): Promise<IMatrixRow[]> =>
isSubmitted && ctId
@@ -134,6 +150,13 @@ export const Step4Approval: React.FC<Props> = ({ service, formData, onPrev, onSa
{status === "approved" && <Alert variant="ok">СТ согласована всеми участниками.</Alert>}
{status === "rejected" && <Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert>}
{status === "on_approval" && <Alert variant="info">СТ находится на согласовании.</Alert>}
{status === "rework" && (
<Alert variant="warn">
СТ возвращена на доработку{reworkRow?.who ? ` (${reworkRow.who})` : ""}.
{reworkRow?.comment ? ` Что исправить: ${reworkRow.comment}.` : ""} Внесите правки и отправьте на согласование
заново — маршрут пойдёт с начала.
</Alert>
)}
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
@@ -201,6 +224,7 @@ export const Step4Approval: React.FC<Props> = ({ service, formData, onPrev, onSa
onDecided={handleDecided}
search={(q) => service.searchUsers(q)}
onDelegated={() => setDecisionSeq((n) => n + 1)}
allowRework
/>
)}
@@ -54,7 +54,8 @@ export type CTStatus =
| "formed"
| "on_approval"
| "approved"
| "rejected";
| "rejected"
| "rework"; // возвращена согласующим на доработку — инициатор правит и отправляет заново
export interface Supplier {
id: string; // ключ колонки Шага 3: `${spId}_${kpFileSpId}` (см. utils/supplierColumns), у старых данных — String(spId)
@@ -1,4 +1,4 @@
import { isUserInApprovalChain } from './approvalAccess';
import { findReworkRow, isUserInApprovalChain } from './approvalAccess';
import { IMatrixRow } from '../../../../shared/approval';
function makeRow(overrides: Partial<IMatrixRow>): IMatrixRow {
@@ -33,3 +33,18 @@ describe('isUserInApprovalChain (кому видны цены и КП при с
expect(isUserInApprovalChain([], { email: 'ivanov@tnsu.kz', displayName: 'Иванов Иван' })).toBe(false);
});
});
describe('findReworkRow (кто вернул СТ на доработку и что исправить)', () => {
it('находит строку со статусом «На доработке»', () => {
const matrix = [
makeRow({ order: 1, status: 'Согласовано' }),
makeRow({ order: 2, who: 'Петров Пётр', status: 'На доработке', comment: 'Уточните цены поставщика' }),
];
expect(findReworkRow(matrix)).toMatchObject({ who: 'Петров Пётр', comment: 'Уточните цены поставщика' });
});
it('нет возврата — undefined', () => {
expect(findReworkRow([makeRow({ status: 'Согласовано' })])).toBeUndefined();
});
});
@@ -1,4 +1,5 @@
import { IMatrixRow } from '../../../../shared/approval';
import { REQUEST_STATUS } from '../../../../shared/approval/models/statuses';
export interface CurrentUser {
email: string;
@@ -16,3 +17,8 @@ export function isUserInApprovalChain(matrix: IMatrixRow[], user: CurrentUser):
return row.who === user.displayName;
});
}
// Строка маршрута, которой СТ вернули на доработку: инициатору показываем, кто вернул и что исправить.
export function findReworkRow(matrix: IMatrixRow[]): IMatrixRow | undefined {
return matrix.find(row => row.status === REQUEST_STATUS.rework);
}
@@ -14,6 +14,7 @@ import {
} from "../components/types/domain";
import {
ApprovalListService,
ApprovalStatusKey,
ApproverService,
IApprovalListFields,
IApproverToWrite,
@@ -89,10 +90,11 @@ const APPROVAL_FIELDS: IApprovalListFields = {
};
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ.
const APPROVAL_STATUS_TO_CT_STATUS: Record<"onApproval" | "approved" | "rejected", CTStatus> = {
const APPROVAL_STATUS_TO_CT_STATUS: Record<ApprovalStatusKey, CTStatus> = {
onApproval: "on_approval",
approved: "approved",
rejected: "rejected",
rework: "rework",
};
// Обратный маппинг для чтения статуса шапки СТ (loadCTById) — раньше status не читался вовсе,
@@ -103,6 +105,7 @@ const CHOICE_TO_CT_STATUS: Record<string, CTStatus> = {
"На согласовании": "on_approval",
Согласовано: "approved",
Отклонено: "rejected",
"На доработке": "rework",
};
// Контактные поля поставщика (из «База данных поставщиков») для saveSupplierContacts.
@@ -299,6 +302,7 @@ export class CtService {
approved: "Согласовано",
formed: "Черновик",
rejected: "Отклонено",
rework: "На доработке",
};
const spStatus = statusMap[status];
if (!spStatus) return;
@@ -1,6 +1,7 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions, AadHttpClient } from '@microsoft/sp-http';
import {
ApprovalStatusKey,
ApprovalListService,
ApproverService,
REQUEST_STATUS,
@@ -164,10 +165,12 @@ const CRITERIA_FIELDS = {
const KPS_REQUEST_TYPE_PAGE_URL = 'kps';
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ.
const APPROVAL_STATUS_TO_CT_STATUS: Record<'onApproval' | 'approved' | 'rejected', CtStatus> = {
const APPROVAL_STATUS_TO_CT_STATUS: Record<ApprovalStatusKey, CtStatus> = {
onApproval: 'on_approval',
approved: 'approved',
rejected: 'rejected'
rejected: 'rejected',
// Кнопки «На доработку» в КПС нет (пока только СТ ОМТС) — статус недостижим; на всякий случай как отклонение.
rework: 'rejected'
};
// Округление денег до 2 знаков (срезаем хвост float перед записью в SP).
@@ -16,6 +16,7 @@ import type {
} from "../components/types/domain";
import {
ApprovalListService,
ApprovalStatusKey,
ApproverService,
IApprovalListFields,
IApproverToWrite,
@@ -68,10 +69,12 @@ const APPROVAL_FIELDS: IApprovalListFields = {
};
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ ОИД.
const APPROVAL_STATUS_TO_OID_STATUS: Record<"onApproval" | "approved" | "rejected", OIDStatus> = {
const APPROVAL_STATUS_TO_OID_STATUS: Record<ApprovalStatusKey, OIDStatus> = {
onApproval: "on_approval",
approved: "approved",
rejected: "rejected",
// Кнопки «На доработку» в ОИД нет (пока только СТ ОМТС) — статус недостижим; на всякий случай как отклонение.
rework: "rejected",
};
const EXT_API_RESOURCE = "9232d9e2-9807-4753-808f-07250e5474b0";
@@ -0,0 +1,50 @@
import { ApproverService } from './approverDecision.service';
import { ApprovalListService } from './approvalList.service';
import { IApprovalRow } from './models/IApprovalRow';
import { REQUEST_STATUS } from './models/statuses';
function makeRow(id: number, status: string): IApprovalRow {
return { id, order: id, status } as unknown as IApprovalRow;
}
// Поддельный список «Согласование …»: хранит строки в памяти.
function makeApproval(rows: IApprovalRow[]): { approval: ApprovalListService; activated: number[] } {
const activated: number[] = [];
const fake = {
setDecision: async (rowId: number, status: string) => {
const row = rows.find(r => r.id === rowId);
if (row) row.status = status;
},
activateNextQueued: async (requestId: number) => { activated.push(requestId); },
getForRequest: async () => rows,
};
return { approval: fake as unknown as ApprovalListService, activated };
}
describe('ApproverService.decide — «На доработку»', () => {
// Раньше у согласующего были только «Согласовать» и «Отклонить»: вернуть СТ на исправление было
// нельзя, приходилось отклонять.
it('строка получает «На доработке», СТ — статус доработки, очередь дальше не идёт', async () => {
const rows = [makeRow(1, REQUEST_STATUS.approved), makeRow(2, REQUEST_STATUS.onApproval), makeRow(3, REQUEST_STATUS.queued)];
const { approval, activated } = makeApproval(rows);
const computed: string[] = [];
const service = new ApproverService(approval, async status => { computed.push(status); });
const label = await service.decide(360, 2, 'rework', 'Уточните цены поставщика');
expect(rows[1].status).toBe(REQUEST_STATUS.rework);
expect(computed).toEqual(['rework']);
expect(label).toBe(REQUEST_STATUS.rework);
expect(activated).toEqual([]);
});
it('«Согласовать» по-прежнему двигает очередь', async () => {
const rows = [makeRow(1, REQUEST_STATUS.onApproval), makeRow(2, REQUEST_STATUS.queued)];
const { approval, activated } = makeApproval(rows);
const service = new ApproverService(approval, async () => undefined);
await service.decide(360, 1, 'approve', '');
expect(activated).toEqual([360]);
});
});
@@ -3,9 +3,12 @@ import { REQUEST_STATUS } from './models/statuses';
import { IApprovalRow } from './models/IApprovalRow';
import { ISiteUser } from './models/ISiteUser';
// approve — согласовать; reject — отклонить (нет фазы исполнения и статуса
// «возврат на доработку» — как в прежнем KPS-механизме).
export type ApproverDecision = 'approve' | 'reject';
// approve — согласовать; reject — отклонить; rework — вернуть инициатору на доработку
// (после исправлений он отправляет заново, маршрут идёт с начала). Нет фазы исполнения.
export type ApproverDecision = 'approve' | 'reject' | 'rework';
// Статус заявки, вычисленный из строк маршрута, — форма пишет его в своё поле статуса.
export type ApprovalStatusKey = 'onApproval' | 'approved' | 'rejected' | 'rework';
// Активная строка текущего пользователя: либо он сам назначен на неё
// (isDelegator=false), либо это его делегат недоступен и он, как делегировавший,
@@ -16,7 +19,8 @@ export interface IPendingRow extends IApprovalRow {
const ROW_STATUS: Record<ApproverDecision, string> = {
approve: REQUEST_STATUS.approved,
reject: REQUEST_STATUS.rejected
reject: REQUEST_STATUS.rejected,
rework: REQUEST_STATUS.rework
};
// Действия согласующего: определение его строки, применение решения,
@@ -30,7 +34,7 @@ export class ApproverService {
*/
constructor(
private readonly approval: ApprovalListService,
private readonly onStatusComputed: (status: 'onApproval' | 'approved' | 'rejected') => Promise<void>
private readonly onStatusComputed: (status: ApprovalStatusKey) => Promise<void>
) {}
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
@@ -91,9 +95,12 @@ export class ApproverService {
return status.label;
}
// Статус заявки из строк маршрута: отклонение любой строки → «Отклонено»;
// все согласовали → «Согласовано»; иначе ещё «На согласовании».
private computeRequestStatus(rows: IApprovalRow[]): { key: 'onApproval' | 'approved' | 'rejected'; label: string } {
// Статус заявки из строк маршрута: возврат на доработку любой строкой → «На доработке»;
// отклонение → «Отклонено»; все согласовали → «Согласовано»; иначе ещё «На согласовании».
private computeRequestStatus(rows: IApprovalRow[]): { key: ApprovalStatusKey; label: string } {
if (rows.some((r) => r.status === REQUEST_STATUS.rework)) {
return { key: 'rework', label: REQUEST_STATUS.rework };
}
if (rows.some((r) => r.status === REQUEST_STATUS.rejected)) {
return { key: 'rejected', label: REQUEST_STATUS.rejected };
}
@@ -11,7 +11,7 @@ export { getRequestTypes, getRequestTypesCached, getRequestTypeIdByPageUrl, reso
export { ApprovalListService, DEFAULT_APPROVAL_FIELDS } from './approvalList.service';
export type { IApproverToWrite, IApprovalListFields } from './approvalList.service';
export { ApproverService } from './approverDecision.service';
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
export type { ApproverDecision, ApprovalStatusKey, IPendingRow } from './approverDecision.service';
export { getPlannedMatrix, writeRoute } from './approvalSubmission.service';
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor';
@@ -12,6 +12,8 @@ export const REQUEST_STATUS = {
onApproval: 'На согласовании',
approved: 'Согласовано',
rejected: 'Отклонено',
// Возвращено инициатору на исправление; после повторной отправки маршрут идёт с начала.
rework: 'На доработке',
} as const;
// Значения Choice-колонки статуса строки маршрута при провижининге.
@@ -21,4 +23,5 @@ export const APPROVAL_ROW_STATUS_CHOICES: string[] = [
REQUEST_STATUS.onApproval,
REQUEST_STATUS.approved,
REQUEST_STATUS.rejected,
REQUEST_STATUS.rework,
];
@@ -16,11 +16,13 @@ interface IDecisionPanelProps {
search?: (query: string) => Promise<ISiteUser[]>;
/** Вызывается после успешного переназначения (обновить экран/матрицу). */
onDelegated?: () => void;
/** Кнопка «На доработку» — вернуть инициатору на исправление. Пока включена только в СТ ОМТС. */
allowRework?: boolean;
}
// Панель решения текущего шага: согласующий (Согласовать/Отклонить). Если у шага
// разрешено переназначение (allowReassign) — можно передать шаг другому сотруднику.
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestId, onDecided, search, onDelegated }) => {
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestId, onDecided, search, onDelegated, allowRework }) => {
const [checking, setChecking] = useState<boolean>(true);
const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined);
const [comment, setComment] = useState<string>('');
@@ -61,6 +63,10 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
setError('Для отклонения укажите комментарий.');
return;
}
if (decision === 'rework' && comment.trim().length === 0) {
setError('Укажите в комментарии, что нужно доработать.');
return;
}
setError(null);
setSubmitting(true);
try {
@@ -206,13 +212,24 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
<div className={styles.title}>Ваше решение</div>
{error && <Alert variant="err">{error}</Alert>}
<FormField label="Комментарий" htmlFor="decisionComment">
<TextArea id="decisionComment" value={comment} onChange={setComment} placeholder="Комментарий (обязателен при отклонении)" />
<TextArea
id="decisionComment"
value={comment}
onChange={setComment}
placeholder={allowRework ? 'Комментарий (обязателен при отклонении и возврате на доработку)' : 'Комментарий (обязателен при отклонении)'}
/>
</FormField>
<div className={styles.actions}>
<Button variant="accent" disabled={submitting} onClick={() => decide('approve')}>
<Icon name="check" size="sm" />
Согласовать
</Button>
{allowRework && (
<Button variant="secondary" disabled={submitting} onClick={() => decide('rework')}>
<Icon name="refresh" size="sm" />
На доработку
</Button>
)}
<Button variant="danger" disabled={submitting} onClick={() => decide('reject')}>
<Icon name="close" size="sm" />
Отклонить