diff --git a/src/webparts/requestPortal/forms/Ticket1C/README.md b/src/webparts/requestPortal/forms/Ticket1C/README.md index 6033f63..548b44c 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/README.md +++ b/src/webparts/requestPortal/forms/Ticket1C/README.md @@ -6,9 +6,11 @@ **RequestTypeId (внешний API маршрутов):** 10 ## Маршрут -- Фазы: только согласование. Отдельной фазы исполнения нет — после того как - согласование пройдено (или если для вида работ оно не требуется), заявка сразу - уходит не исполнителю из матрицы, а в Microsoft Planner (см. «Особое поведение»). +- Фазы: согласование + исполнение. Шаги согласования условные (вид работ/отдел), + шаг исполнения — сотрудник отдела 1С (в маршруте №8 без условия). После + согласования заявка «Выполнение» у исполнителя; он отмечает «Выполнено» → «Закрыта». + Если для вида работ согласование не требуется — заявка сразу «Выполнение». + Работу отдел 1С ведёт в карточке Microsoft Planner (см. «Особое поведение»). - Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»** (`WorkType`) и **«Отдел»** (`Department`, свободный текст из профиля AAD, см. «Особое поведение»), которые передаются в `resolveRoute`/`getPlannedMatrix` через @@ -40,13 +42,16 @@ телефон, отдел, должность — единственная форма портала, которая это делает (`services/profile.service.ts`, только для новой заявки, при ошибке Graph — просто остаётся пусто, без ошибки формы). -- **Planner вместо фазы исполнения.** Когда маршрут согласования полностью пройден - (или изначально пуст) — `Ticket1CForm`/`Ticket1CView` создают карточку в Microsoft - Planner через `services/plannerTicket.service.ts` (`api.tnsu.kz/Planner/CreateTask`): - рабочий бакет — при согласовании, отдельный бакет «Отклонённые» — при отклонении - (с указанием, кто отклонил и по какой причине). Id созданной карточки сохраняется в - колонку `PlannerTaskId` — это защита от повторного создания карточки, если решение - применяется из нескольких вкладок/сессий одновременно. +- **Planner вместе с фазой исполнения** (`plannerHandoff.ts`). Когда заявка доходит + до исполнения («Выполнение» — после согласования в `Ticket1CView` или сразу при + отправке в `Ticket1CForm`, если согласование не требуется), создаётся карточка в + Microsoft Planner через `services/plannerTicket.service.ts` + (`api.tnsu.kz/Planner/CreateTask`) — рабочий бакет; при отклонении — бакет + «Отклонённые» (кто отклонил и по какой причине). Маршрут без шага исполнения + («Согласовано») тоже уходит в рабочий бакет. Отметку «Выполнено» исполнитель + ставит в портале — Planner с порталом не синхронизируется. Id созданной карточки + сохраняется в колонку `PlannerTaskId` — это защита от повторного создания карточки, + если решение применяется из нескольких вкладок/сессий одновременно. - Ошибку резолва маршрута (например, если в админке маршрутов ещё не настроен маршрут для этого вида работ) форма не путает с «согласование не требуется» — отправка блокируется с понятным сообщением, а не молча уходит в Planner в обход согласования. diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index c76e245..da6e9c3 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -28,7 +28,8 @@ import { REQUEST_STATUS, isEditableStatus, IApproverToWrite, - unassignedStepMessage + unassignedStepMessage, + APPROVAL_PHASE } from '../../shared/approval'; import { uploadAttachments, @@ -54,6 +55,8 @@ import { RequestSection } from './sections/RequestSection'; import { ContactSection } from './sections/ContactSection'; import { useStableCallback } from '../../shared/useStableCallback'; import { applyManagerOverride, managerEmail } from './managerOverride'; +import { handoffToPlanner } from './plannerHandoff'; +import { submitStatus } from './submitStatus'; const Ticket1CForm: React.FC = ({ requestType, @@ -168,23 +171,39 @@ const Ticket1CForm: React.FC = ({ // а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА нередактируема // (editable=false) — заявитель только видит, кто согласует, без возможности // добавить/убрать/переставить согласующего. + // Условия шагов (и согласования, и исполнения) — по виду работ и отделу. + const fieldValues = useMemo( + () => [ + { fieldName: 'WorkType', value: data.WorkType }, + { fieldName: 'Department', value: data.Department } + ], + [data.WorkType, data.Department] + ); + const matrixKey = `${userEmail}-${editId ?? 'new'}-${data.WorkType}-${data.Department}`; + const matrixReady = !!editId || (!!data.WorkType && !!requestTypeId); const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) - : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, [ - { fieldName: 'WorkType', value: data.WorkType }, - { fieldName: 'Department', value: data.Department } - ]), - [context, userEmail, approval, editId, data.WorkType, data.Department, requestTypeId] + ? approval + .getMatrixForRequest(editId) + .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)) + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, fieldValues), + [context, userEmail, approval, editId, fieldValues, requestTypeId] ); - const matrix = useMatrixEditor( - loadMatrix, - `${userEmail}-${editId ?? 'new'}-${data.WorkType}-${data.Department}`, - false, - !!editId || (!!data.WorkType && !!requestTypeId), - (login) => approval.getJobTitle(login) + const matrix = useMatrixEditor(loadMatrix, matrixKey, false, matrixReady, (login) => approval.getJobTitle(login)); + + // Исполнение — сотрудник отдела 1С (шаг «Исполнение» маршрута): работу ведёт + // в карточке Planner, в портале отмечает «Выполнено» (см. plannerHandoff.ts). + const loadExecutors = useCallback( + () => + editId + ? approval + .getMatrixForRequest(editId) + .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)) + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', null, fieldValues), + [context, userEmail, approval, editId, fieldValues, requestTypeId] ); + const executors = useMatrixEditor(loadExecutors, matrixKey, false, matrixReady, (login) => approval.getJobTitle(login)); // Должность нового «Руководителя» — approval-route её не знает (резолвил шаг // на ДРУГОГО человека и отдал его должность), поэтому подтягиваем сами по // e-mail из поля (SPS-JobTitle, тот же источник, что и у обычных согласующих, @@ -272,12 +291,7 @@ const Ticket1CForm: React.FC = ({ if (!rtId) { throw new Error('Тип заявки не определён — обновите страницу и повторите.'); } - // Маршрут запрашивается для ЛЮБОГО вида работ и используется как есть — - // без авто-approve, если админ ещё не настроил шаги для конкретного вида - // работ (раньше в этом случае заявка сразу становилась «Согласовано» в - // обход согласования; теперь это просто «На согласовании» с пустой - // матрицей — сигнал администратору маршрутов донастроить условие). - const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : REQUEST_STATUS.onApproval; + const finalStatus = submitStatus(intendedStatus, matrixRows.length, executors.rows.length); // requestId уже задан → редактируем существующую заявку, пишем в ту же // партицию (editListId). Иначе — создаём новую, и партицию для записи @@ -292,6 +306,7 @@ const Ticket1CForm: React.FC = ({ requestId, data, toApprovers(matrixRows), + toApprovers(executors.rows), finalStatus, writeListId ); @@ -311,10 +326,18 @@ const Ticket1CForm: React.FC = ({ onSaved(); } - // Передача в Planner теперь происходит только после реального решения - // согласующего (см. Ticket1CView.handoffToPlanner) — здесь заявка всегда - // отправляется на согласование, минуя Planner напрямую. - if (finalStatus === REQUEST_STATUS.onApproval) { + // Согласование для этого вида работ не нужно — заявка сразу у исполнителя, + // и карточка Planner нужна уже сейчас (иначе её создаёт решение согласующего + // в Ticket1CView). Сбой Planner не отменяет уже отправленную заявку. + if (finalStatus === REQUEST_STATUS.execution) { + try { + await handoffToPlanner({ context, approval, itemId: id, listId: writeListId, status: finalStatus, data, code: code || requestCode || '' }); + } catch (e) { + setSubmitError(`Заявка отправлена, но не удалось создать карточку в Planner: ${friendlyErrorMessage(e)}`); + } + } + + if (finalStatus !== REQUEST_STATUS.draft) { // Имена списков — той же партиции, куда фактически записали (writeListId), // не обязательно текущей на момент рендера формы: поток ищет строку // маршрута именно в НЕЙ. @@ -344,7 +367,13 @@ const Ticket1CForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.` ); if (unassignedError) return unassignedError; - return undefined; + if (executors.error) { + return `Не удалось определить исполнителя: ${executors.error}. Попробуйте ещё раз или обратитесь к администратору.`; + } + return unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.` + ); }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); @@ -422,6 +451,16 @@ const Ticket1CForm: React.FC = ({ emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С." /> + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение" + roleLabel="Исполнитель" + emptyText="Исполнители не назначены." + /> + )} + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx index ae504f4..d215a91 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx @@ -25,7 +25,7 @@ import { notifyActiveStep, useRequestTypeId, isEditableStatus, - REQUEST_STATUS + APPROVAL_PHASE } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -35,11 +35,10 @@ import { dataListTitle, dataListTitleForListId } from '../../shared/listTitles'; -import { FORM_ID, IFormData, EMPTY_DATA, SetField, hasOtherDatabase } from './model'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadTicketRequest } from './load'; -import { ticket1cApprovalService, getPlannerTaskId, setPlannerTaskId } from './submit'; -import { PlannerTicketService } from './services/plannerTicket.service'; -import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config'; +import { ticket1cApprovalService } from './submit'; +import { handoffToPlanner, isPlannerHandoffStatus } from './plannerHandoff'; import { RequestSection } from './sections/RequestSection'; import { ContactSection } from './sections/ContactSection'; import { useStableCallback } from '../../shared/useStableCallback'; @@ -55,35 +54,6 @@ interface ITicket1CViewProps { onCodeLoaded?: (code: string) => void; } -/** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */ -function buildPlannerNotes( - data: IFormData, - code: string, - rejection?: { who: string; comment: string } -): string { - const lines: string[] = []; - if (code) lines.push(`Код заявки: ${code}`); - lines.push(`Организация: ${data.Organization}`); - lines.push(`ФИО: ${data.FIO}`); - lines.push(`Email: ${data.Email}`); - lines.push(`Телефон: ${data.Phone}`); - lines.push(`Отдел: ${data.Department}`); - lines.push(`Должность: ${data.Position}`); - lines.push(`База 1С: ${data.DB1C.join(', ')}`); - if (hasOtherDatabase(data)) lines.push(`Другая база 1С: ${data.DB1COther}`); - lines.push(`Виды работ: ${data.WorkType}`); - lines.push(''); - lines.push('Вопрос:'); - lines.push(data.Question); - if (rejection) { - lines.push(''); - lines.push('ОТКЛОНЕНО'); - lines.push(`Кто отклонил: ${rejection.who}`); - lines.push(`Причина: ${rejection.comment}`); - } - return lines.join('\n'); -} - // Заявка в отдел 1С только для просмотра (режим согласующего). const Ticket1CView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка @@ -112,10 +82,24 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, // decisionSeq входит в reloadKey: после решения строки маршрута меняются на // сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback( + () => + approval.getMatrixForRequest(itemId).then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)), + [approval, itemId] + ); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + // Исполнение — отдел 1С (шаг «Исполнение» маршрута): отмечает «Выполнено» здесь, + // а саму работу ведёт в карточке Planner (см. plannerHandoff.ts). + const loadExecutors = useCallback( + () => + approval.getMatrixForRequest(itemId).then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)), + [approval, itemId] + ); + const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) => + approval.getJobTitle(l) + ); useEffect(() => { let alive = true; @@ -143,41 +127,6 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, }; }, [itemId, listId, notifyCode]); - // Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита - // от повторного создания карточки, если решение применяется из нескольких - // вкладок/сессий одновременно (см. submit.ts). - const handoffToPlanner = useCallback( - async (finalStatus: string, currentData: IFormData, currentCode: string): Promise => { - const existingTaskId = await getPlannerTaskId(itemId, listId); - if (existingTaskId) { - return; - } - - let rejection: { who: string; comment: string } | undefined; - if (finalStatus === REQUEST_STATUS.rejected) { - const rows = await approval.getMatrixForRequest(itemId); - const rejectedRow = rows.filter((r) => r.status === REQUEST_STATUS.rejected)[0]; - if (rejectedRow) { - rejection = { who: rejectedRow.who, comment: rejectedRow.comment }; - } - } - - const bucketName = - finalStatus === REQUEST_STATUS.rejected ? PLANNER_REJECTED_BUCKET_NAME : PLANNER_BUCKET_NAME; - const atts = await getAttachments(listId || dataListTitle(FORM_ID), itemId); - const taskId = await new PlannerTicketService(context).createTask({ - planId: PLANNER_PLAN_ID, - bucketName, - title: currentData.TaskName, - notes: buildPlannerNotes(currentData, currentCode, rejection), - attachments: atts, - webAbsoluteUrl: context.pageContext.web.absoluteUrl - }); - await setPlannerTaskId(itemId, taskId, listId); - }, - [approval, context, itemId, listId] - ); - if (loading) { return
Загрузка заявки…
; } @@ -246,6 +195,16 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, search={(q) => approval.searchUsers(q)} emptyText="Согласование не требовалось для этого вида работ." /> + + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение" + roleLabel="Исполнитель" + emptyText="Исполнители не назначены." + /> + )} {/* История согласования: маршрут пересохраняется патчем (см. @@ -265,8 +224,10 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, // Очередь сдвинулась — уведомляем следующий активный шаг. notify(); - if (newStatus === REQUEST_STATUS.approved || newStatus === REQUEST_STATUS.rejected) { - handoffToPlanner(newStatus, data, code).catch((e) => + // Дошла до исполнения / завершилась / отклонена — карточка в Planner + // (повторно не создаётся, см. plannerHandoff.ts). + if (isPlannerHandoffStatus(newStatus)) { + handoffToPlanner({ context, approval, itemId, listId, status: newStatus, data, code }).catch((e) => setError(`Не удалось передать заявку в Planner: ${(e as Error).message}`) ); } diff --git a/src/webparts/requestPortal/forms/Ticket1C/plannerHandoff.ts b/src/webparts/requestPortal/forms/Ticket1C/plannerHandoff.ts new file mode 100644 index 0000000..a4238e5 --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/plannerHandoff.ts @@ -0,0 +1,91 @@ +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import { ApprovalListService, REQUEST_STATUS } from '../../shared/approval'; +import { getAttachments } from '../../shared/attachments/attachments.service'; +import { dataListTitle } from '../../shared/listTitles'; +import { FORM_ID, IFormData, hasOtherDatabase } from './model'; +import { getPlannerTaskId, setPlannerTaskId } from './submit'; +import { PlannerTicketService } from './services/plannerTicket.service'; +import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config'; + +/** + * Статусы, при которых заявка уходит в Planner: согласование пройдено и + * началось исполнение («Выполнение»), маршрут без исполнения завершён + * («Согласовано») или заявку отклонили (бакет «Отклонённые»). + */ +const HANDOFF_STATUSES: ReadonlyArray = [ + REQUEST_STATUS.execution, + REQUEST_STATUS.approved, + REQUEST_STATUS.rejected +]; + +export function isPlannerHandoffStatus(status: string): boolean { + return HANDOFF_STATUSES.indexOf(status) !== -1; +} + +/** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */ +function buildPlannerNotes(data: IFormData, code: string, rejection?: { who: string; comment: string }): string { + const lines: string[] = []; + if (code) lines.push(`Код заявки: ${code}`); + lines.push(`Организация: ${data.Organization}`); + lines.push(`ФИО: ${data.FIO}`); + lines.push(`Email: ${data.Email}`); + lines.push(`Телефон: ${data.Phone}`); + lines.push(`Отдел: ${data.Department}`); + lines.push(`Должность: ${data.Position}`); + lines.push(`База 1С: ${data.DB1C.join(', ')}`); + if (hasOtherDatabase(data)) lines.push(`Другая база 1С: ${data.DB1COther}`); + lines.push(`Виды работ: ${data.WorkType}`); + lines.push(''); + lines.push('Вопрос:'); + lines.push(data.Question); + if (rejection) { + lines.push(''); + lines.push('ОТКЛОНЕНО'); + lines.push(`Кто отклонил: ${rejection.who}`); + lines.push(`Причина: ${rejection.comment}`); + } + return lines.join('\n'); +} + +export interface IPlannerHandoffParams { + context: WebPartContext; + approval: ApprovalListService; + itemId: number; + listId?: string; + status: string; + data: IFormData; + code: string; +} + +/** + * Создать карточку Planner для заявки. Отдел 1С ведёт работу в Planner, а в + * портале исполнитель отмечает «Выполнено» — поэтому карточка создаётся, когда + * заявка доходит до исполнения, а не вместо него. PlannerTaskId — защита от + * повторного создания, если решение применяется из нескольких вкладок/сессий. + */ +export async function handoffToPlanner({ context, approval, itemId, listId, status, data, code }: IPlannerHandoffParams): Promise { + if (!isPlannerHandoffStatus(status)) return; + const existingTaskId = await getPlannerTaskId(itemId, listId); + if (existingTaskId) return; + + let rejection: { who: string; comment: string } | undefined; + if (status === REQUEST_STATUS.rejected) { + const rows = await approval.getMatrixForRequest(itemId); + const rejectedRow = rows.filter((r) => r.status === REQUEST_STATUS.rejected)[0]; + if (rejectedRow) { + rejection = { who: rejectedRow.who, comment: rejectedRow.comment }; + } + } + + const bucketName = status === REQUEST_STATUS.rejected ? PLANNER_REJECTED_BUCKET_NAME : PLANNER_BUCKET_NAME; + const attachments = await getAttachments(listId || dataListTitle(FORM_ID), itemId); + const taskId = await new PlannerTicketService(context).createTask({ + planId: PLANNER_PLAN_ID, + bucketName, + title: data.TaskName, + notes: buildPlannerNotes(data, code, rejection), + attachments, + webAbsoluteUrl: context.pageContext.web.absoluteUrl + }); + await setPlannerTaskId(itemId, taskId, listId); +} diff --git a/src/webparts/requestPortal/forms/Ticket1C/submit.ts b/src/webparts/requestPortal/forms/Ticket1C/submit.ts index b4e459a..52a15c5 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/submit.ts +++ b/src/webparts/requestPortal/forms/Ticket1C/submit.ts @@ -32,10 +32,12 @@ function buildItem(data: IFormData, status: string): Record { export type ISaveResult = ISaveItemResult; /** - * Сохранить заявку в отдел 1С: содержимое + маршрут согласования. + * Сохранить заявку в отдел 1С: содержимое + маршрут (согласование и исполнение). * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю - * из матрицы. Возвращает Id заявки (+ код при создании). + * Исполнение — сотрудник отдела 1С из маршрута: работу ведёт в карточке Planner + * (создаётся при переходе в «Выполнение», см. plannerHandoff.ts), а в портале + * отмечает «Выполнено». executors может быть пустым — тогда после согласования + * заявка станет «Согласовано». Возвращает Id заявки (+ код при создании). * * listId — партиция, в которую фактически пишем: при редактировании — партиция * ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция, @@ -49,13 +51,14 @@ export async function saveTicketRequest( existingId: number | undefined, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { const item = buildItem(data, status); const result = await saveRequestItem(FORM_ID, existingId, listId, TICKET1C_CODE_PREFIX, item); - await writeRoute(ticket1cApprovalService(listId), result.id, approvers, [], status); + await writeRoute(ticket1cApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/Ticket1C/submitStatus.test.ts b/src/webparts/requestPortal/forms/Ticket1C/submitStatus.test.ts new file mode 100644 index 0000000..a6b9953 --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/submitStatus.test.ts @@ -0,0 +1,20 @@ +import { REQUEST_STATUS } from '../../shared/approval/models/statuses'; +import { submitStatus } from './submitStatus'; + +describe('Ticket1C submitStatus', () => { + it('черновик остаётся черновиком', () => { + expect(submitStatus(REQUEST_STATUS.draft, 2, 1)).toBe(REQUEST_STATUS.draft); + }); + + it('есть согласующие — на согласование', () => { + expect(submitStatus(REQUEST_STATUS.onApproval, 2, 1)).toBe(REQUEST_STATUS.onApproval); + }); + + it('согласование не нужно, исполнитель есть — сразу выполнение', () => { + expect(submitStatus(REQUEST_STATUS.onApproval, 0, 1)).toBe(REQUEST_STATUS.execution); + }); + + it('маршрут пуст совсем — на согласование, не «Согласовано» в обход', () => { + expect(submitStatus(REQUEST_STATUS.onApproval, 0, 0)).toBe(REQUEST_STATUS.onApproval); + }); +}); diff --git a/src/webparts/requestPortal/forms/Ticket1C/submitStatus.ts b/src/webparts/requestPortal/forms/Ticket1C/submitStatus.ts new file mode 100644 index 0000000..657202f --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/submitStatus.ts @@ -0,0 +1,16 @@ +import { REQUEST_STATUS } from '../../shared/approval/models/statuses'; + +/** + * Статус заявки 1С при сохранении. + * - Черновик — черновик. + * - Есть согласующие — «На согласовании». + * - Согласующих нет (условия маршрута для этого вида работ не сработали), но есть + * исполнитель — сразу «Выполнение»: заявка у отдела 1С. + * - Пусто совсем — «На согласовании» с пустым маршрутом: не «Согласовано» в обход + * согласования, а сигнал администратору маршрутов донастроить условия. + */ +export function submitStatus(intendedStatus: string, approversCount: number, executorsCount: number): string { + if (intendedStatus === REQUEST_STATUS.draft) return REQUEST_STATUS.draft; + if (approversCount === 0 && executorsCount > 0) return REQUEST_STATUS.execution; + return REQUEST_STATUS.onApproval; +} diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index edf5210..82a1224 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -241,8 +241,8 @@ export class ApproverService { // 2) согласование завершено: нет исполнителей → «Согласовано» (финал); // исполнители есть и не все выполнили → «Выполнение»; // все выполнили → «Закрыта». - // Строка «выполнена» исполнителем помечается тем же статусом «Согласовано» - // (действие «Выполнено» в DecisionPanel), фазы различаются колонкой Phase. + // Строка исполнителя после действия «Выполнено» (DecisionPanel) получает + // статус «Выполнено»; фазы различаются колонкой Phase. private computeRequestStatus(rows: IApprovalRow[]): string { const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution); const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);