diff --git a/provisioning/manifests/ct.provisioning.json b/provisioning/manifests/ct.provisioning.json index e550a7c..a5a3a87 100644 --- a/provisioning/manifests/ct.provisioning.json +++ b/provisioning/manifests/ct.provisioning.json @@ -27,7 +27,7 @@ "internalName": "status", "title": "Статус", "type": "Choice", - "choices": ["Черновик", "Согласовано", "На согласовании", "Отклонено"] + "choices": ["Черновик", "Согласовано", "На согласовании", "Отклонено", "На доработке"] }, { "internalName": "winSupplier", @@ -292,7 +292,8 @@ "Рекомендую", "Черновик", "Отклонено", - "Ждёт очереди" + "Ждёт очереди", + "На доработке" ] }, { diff --git a/src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts b/src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts index 3540ee8..37d2d23 100644 --- a/src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts +++ b/src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts @@ -4,14 +4,17 @@ const STATUS_LABEL: Record = { 'Черновик': 'Сформирована', 'На согласовании': 'На согласовании', 'Согласовано': 'Согласована', - 'Отклонено': 'Отклонена' + 'Отклонено': 'Отклонена', + 'На доработке': 'На доработке' }; const STATUS_VARIANT: Record = { 'Черновик': 'neutral', 'На согласовании': 'info', 'Согласовано': 'success', - 'Отклонено': 'error' + 'Отклонено': 'error', + // В реестре нет жёлтого варианта — как «На согласовании»: процесс идёт, ждём действий инициатора. + 'На доработке': 'info' }; export function createCtDocumentConfig(): DocumentTypeConfig { diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Breadcrumbs/Breadcrumbs.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Breadcrumbs/Breadcrumbs.tsx index 4fabd4c..999f71c 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Breadcrumbs/Breadcrumbs.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Breadcrumbs/Breadcrumbs.tsx @@ -21,9 +21,10 @@ const STATUS_LABELS: Record = { on_approval: 'На согласовании', approved: 'Согласована', rejected: 'Отклонена', + rework: 'На доработке', }; -const STATUS_VARIANT: Record = { +const STATUS_VARIANT: Record = { draft: 'neutral', kp_loading: 'neutral', kp_loaded: 'neutral', @@ -31,6 +32,7 @@ const STATUS_VARIANT: Record = { Отклонено: 'rejected', Согласовано: 'approved', 'На согласовании': 'on_approval', + 'На доработке': 'rework', }; // Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ по Teams-ссылке (?ctid=), @@ -56,6 +57,7 @@ export const InlineApprovalChain: React.FC = ({ service, formData, onSave onDecided={handleDecided} search={(q) => service.searchUsers(q)} onDelegated={() => setDecisionSeq((n) => n + 1)} + allowRework /> )} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/Step4Approval.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/Step4Approval.tsx index 6f41034..3d49cde 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/Step4Approval.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/Step4Approval.tsx @@ -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 = { Отклонено: "rejected", Согласовано: "approved", "На согласовании": "on_approval", + "На доработке": "rework", }; const INCORE_STEP_LABELS: Record = { @@ -57,6 +59,20 @@ export const Step4Approval: React.FC = ({ service, formData, onPrev, onSa const incore = useIncore({ service, formData, isInitiator }); + // Возврат на доработку: кто вернул и что исправить — из записанного маршрута. Сам шаг сейчас показывает + // новый планируемый маршрут для повторной отправки (идёт с начала), старых строк в нём не видно. + const [reworkRow, setReworkRow] = useState(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 => isSubmitted && ctId @@ -134,6 +150,13 @@ export const Step4Approval: React.FC = ({ service, formData, onPrev, onSa {status === "approved" && СТ согласована всеми участниками.} {status === "rejected" && СТ отклонена. Требуется корректировка и повторная отправка.} {status === "on_approval" && СТ находится на согласовании.} + {status === "rework" && ( + + СТ возвращена на доработку{reworkRow?.who ? ` (${reworkRow.who})` : ""}. + {reworkRow?.comment ? ` Что исправить: ${reworkRow.comment}.` : ""} Внесите правки и отправьте на согласование + заново — маршрут пойдёт с начала. + + )} service.searchUsers(q)} /> @@ -201,6 +224,7 @@ export const Step4Approval: React.FC = ({ service, formData, onPrev, onSa onDecided={handleDecided} search={(q) => service.searchUsers(q)} onDelegated={() => setDecisionSeq((n) => n + 1)} + allowRework /> )} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts b/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts index 6d50c60..878f284 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts @@ -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) diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/approvalAccess.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/approvalAccess.test.ts index 10c13db..b1fd81e 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/utils/approvalAccess.test.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/approvalAccess.test.ts @@ -1,4 +1,4 @@ -import { isUserInApprovalChain } from './approvalAccess'; +import { findReworkRow, isUserInApprovalChain } from './approvalAccess'; import { IMatrixRow } from '../../../../shared/approval'; function makeRow(overrides: Partial): 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(); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/approvalAccess.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/approvalAccess.ts index ef396a0..c261f0c 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/utils/approvalAccess.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/approvalAccess.ts @@ -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); +} diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index d3f3a7e..cfff997 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -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 = { onApproval: "on_approval", approved: "approved", rejected: "rejected", + rework: "rework", }; // Обратный маппинг для чтения статуса шапки СТ (loadCTById) — раньше status не читался вовсе, @@ -103,6 +105,7 @@ const CHOICE_TO_CT_STATUS: Record = { "На согласовании": "on_approval", Согласовано: "approved", Отклонено: "rejected", + "На доработке": "rework", }; // Контактные поля поставщика (из «База данных поставщиков») для saveSupplierContacts. @@ -299,6 +302,7 @@ export class CtService { approved: "Согласовано", formed: "Черновик", rejected: "Отклонено", + rework: "На доработке", }; const spStatus = statusMap[status]; if (!spStatus) return; diff --git a/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts b/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts index 7ac4b59..2e2ef43 100644 --- a/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts +++ b/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts @@ -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 = { onApproval: 'on_approval', approved: 'approved', - rejected: 'rejected' + rejected: 'rejected', + // Кнопки «На доработку» в КПС нет (пока только СТ ОМТС) — статус недостижим; на всякий случай как отклонение. + rework: 'rejected' }; // Округление денег до 2 знаков (срезаем хвост float перед записью в SP). diff --git a/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts b/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts index 576940c..2a9f647 100644 --- a/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts +++ b/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts @@ -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 = { onApproval: "on_approval", approved: "approved", rejected: "rejected", + // Кнопки «На доработку» в ОИД нет (пока только СТ ОМТС) — статус недостижим; на всякий случай как отклонение. + rework: "rejected", }; const EXT_API_RESOURCE = "9232d9e2-9807-4753-808f-07250e5474b0"; diff --git a/src/webparts/ctFormsPortal/shared/approval/approverDecision.service.test.ts b/src/webparts/ctFormsPortal/shared/approval/approverDecision.service.test.ts new file mode 100644 index 0000000..994c5d0 --- /dev/null +++ b/src/webparts/ctFormsPortal/shared/approval/approverDecision.service.test.ts @@ -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]); + }); +}); diff --git a/src/webparts/ctFormsPortal/shared/approval/approverDecision.service.ts b/src/webparts/ctFormsPortal/shared/approval/approverDecision.service.ts index 29235e2..276763b 100644 --- a/src/webparts/ctFormsPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/ctFormsPortal/shared/approval/approverDecision.service.ts @@ -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 = { 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 + private readonly onStatusComputed: (status: ApprovalStatusKey) => Promise ) {} // Ожидающая строка текущего пользователя, если сейчас его очередь; иначе 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 }; } diff --git a/src/webparts/ctFormsPortal/shared/approval/index.ts b/src/webparts/ctFormsPortal/shared/approval/index.ts index 77d274f..aba62a2 100644 --- a/src/webparts/ctFormsPortal/shared/approval/index.ts +++ b/src/webparts/ctFormsPortal/shared/approval/index.ts @@ -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'; diff --git a/src/webparts/ctFormsPortal/shared/approval/models/statuses.ts b/src/webparts/ctFormsPortal/shared/approval/models/statuses.ts index 28f9ba0..5eb5e6c 100644 --- a/src/webparts/ctFormsPortal/shared/approval/models/statuses.ts +++ b/src/webparts/ctFormsPortal/shared/approval/models/statuses.ts @@ -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, ]; diff --git a/src/webparts/ctFormsPortal/shared/approval/ui/DecisionPanel.tsx b/src/webparts/ctFormsPortal/shared/approval/ui/DecisionPanel.tsx index 4db9269..2f797b4 100644 --- a/src/webparts/ctFormsPortal/shared/approval/ui/DecisionPanel.tsx +++ b/src/webparts/ctFormsPortal/shared/approval/ui/DecisionPanel.tsx @@ -16,11 +16,13 @@ interface IDecisionPanelProps { search?: (query: string) => Promise; /** Вызывается после успешного переназначения (обновить экран/матрицу). */ onDelegated?: () => void; + /** Кнопка «На доработку» — вернуть инициатору на исправление. Пока включена только в СТ ОМТС. */ + allowRework?: boolean; } // Панель решения текущего шага: согласующий (Согласовать/Отклонить). Если у шага // разрешено переназначение (allowReassign) — можно передать шаг другому сотруднику. -export const DecisionPanel: React.FC = ({ service, requestId, onDecided, search, onDelegated }) => { +export const DecisionPanel: React.FC = ({ service, requestId, onDecided, search, onDelegated, allowRework }) => { const [checking, setChecking] = useState(true); const [pendingRow, setPendingRow] = useState(undefined); const [comment, setComment] = useState(''); @@ -61,6 +63,10 @@ export const DecisionPanel: React.FC = ({ 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 = ({ service, requestI
Ваше решение
{error && {error}} -