feat: этап исполнения в заявке 1С (исполнитель из маршрута + Planner)
Tests / frontend-test (push) Successful in 1m20s
Tests / frontend-test (push) Successful in 1m20s
В маршруте №8 есть шаг «Исполнение», но форма 1С запрашивала только согласование и записывала пустой список исполнителей: после согласования заявка навсегда оставалась «Согласовано», без исполнителя и даты исполнения. Теперь исполнитель берётся из маршрута с теми же условиями, заявка идёт «Выполнение» → «Закрыта», а карточка Planner создаётся при переходе в исполнение (в т.ч. сразу при отправке, если согласование не требуется). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,11 @@
|
|||||||
**RequestTypeId (внешний API маршрутов):** 10
|
**RequestTypeId (внешний API маршрутов):** 10
|
||||||
|
|
||||||
## Маршрут
|
## Маршрут
|
||||||
- Фазы: только согласование. Отдельной фазы исполнения нет — после того как
|
- Фазы: согласование + исполнение. Шаги согласования условные (вид работ/отдел),
|
||||||
согласование пройдено (или если для вида работ оно не требуется), заявка сразу
|
шаг исполнения — сотрудник отдела 1С (в маршруте №8 без условия). После
|
||||||
уходит не исполнителю из матрицы, а в Microsoft Planner (см. «Особое поведение»).
|
согласования заявка «Выполнение» у исполнителя; он отмечает «Выполнено» → «Закрыта».
|
||||||
|
Если для вида работ согласование не требуется — заявка сразу «Выполнение».
|
||||||
|
Работу отдел 1С ведёт в карточке Microsoft Planner (см. «Особое поведение»).
|
||||||
- Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»**
|
- Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»**
|
||||||
(`WorkType`) и **«Отдел»** (`Department`, свободный текст из профиля AAD, см.
|
(`WorkType`) и **«Отдел»** (`Department`, свободный текст из профиля AAD, см.
|
||||||
«Особое поведение»), которые передаются в `resolveRoute`/`getPlannedMatrix` через
|
«Особое поведение»), которые передаются в `resolveRoute`/`getPlannedMatrix` через
|
||||||
@@ -40,13 +42,16 @@
|
|||||||
телефон, отдел, должность — единственная форма портала, которая это делает
|
телефон, отдел, должность — единственная форма портала, которая это делает
|
||||||
(`services/profile.service.ts`, только для новой заявки, при ошибке Graph — просто
|
(`services/profile.service.ts`, только для новой заявки, при ошибке Graph — просто
|
||||||
остаётся пусто, без ошибки формы).
|
остаётся пусто, без ошибки формы).
|
||||||
- **Planner вместо фазы исполнения.** Когда маршрут согласования полностью пройден
|
- **Planner вместе с фазой исполнения** (`plannerHandoff.ts`). Когда заявка доходит
|
||||||
(или изначально пуст) — `Ticket1CForm`/`Ticket1CView` создают карточку в Microsoft
|
до исполнения («Выполнение» — после согласования в `Ticket1CView` или сразу при
|
||||||
Planner через `services/plannerTicket.service.ts` (`api.tnsu.kz/Planner/CreateTask`):
|
отправке в `Ticket1CForm`, если согласование не требуется), создаётся карточка в
|
||||||
рабочий бакет — при согласовании, отдельный бакет «Отклонённые» — при отклонении
|
Microsoft Planner через `services/plannerTicket.service.ts`
|
||||||
(с указанием, кто отклонил и по какой причине). Id созданной карточки сохраняется в
|
(`api.tnsu.kz/Planner/CreateTask`) — рабочий бакет; при отклонении — бакет
|
||||||
колонку `PlannerTaskId` — это защита от повторного создания карточки, если решение
|
«Отклонённые» (кто отклонил и по какой причине). Маршрут без шага исполнения
|
||||||
применяется из нескольких вкладок/сессий одновременно.
|
(«Согласовано») тоже уходит в рабочий бакет. Отметку «Выполнено» исполнитель
|
||||||
|
ставит в портале — Planner с порталом не синхронизируется. Id созданной карточки
|
||||||
|
сохраняется в колонку `PlannerTaskId` — это защита от повторного создания карточки,
|
||||||
|
если решение применяется из нескольких вкладок/сессий одновременно.
|
||||||
- Ошибку резолва маршрута (например, если в админке маршрутов ещё не настроен маршрут
|
- Ошибку резолва маршрута (например, если в админке маршрутов ещё не настроен маршрут
|
||||||
для этого вида работ) форма не путает с «согласование не требуется» — отправка
|
для этого вида работ) форма не путает с «согласование не требуется» — отправка
|
||||||
блокируется с понятным сообщением, а не молча уходит в Planner в обход согласования.
|
блокируется с понятным сообщением, а не молча уходит в Planner в обход согласования.
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import {
|
|||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
unassignedStepMessage
|
unassignedStepMessage,
|
||||||
|
APPROVAL_PHASE
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import {
|
import {
|
||||||
uploadAttachments,
|
uploadAttachments,
|
||||||
@@ -54,6 +55,8 @@ import { RequestSection } from './sections/RequestSection';
|
|||||||
import { ContactSection } from './sections/ContactSection';
|
import { ContactSection } from './sections/ContactSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
import { applyManagerOverride, managerEmail } from './managerOverride';
|
import { applyManagerOverride, managerEmail } from './managerOverride';
|
||||||
|
import { handoffToPlanner } from './plannerHandoff';
|
||||||
|
import { submitStatus } from './submitStatus';
|
||||||
|
|
||||||
const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -168,23 +171,39 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
// а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА нередактируема
|
// а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА нередактируема
|
||||||
// (editable=false) — заявитель только видит, кто согласует, без возможности
|
// (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(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
editId
|
editId
|
||||||
? approval.getMatrixForRequest(editId)
|
? approval
|
||||||
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, [
|
.getMatrixForRequest(editId)
|
||||||
{ fieldName: 'WorkType', value: data.WorkType },
|
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
|
||||||
{ fieldName: 'Department', value: data.Department }
|
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, fieldValues),
|
||||||
]),
|
[context, userEmail, approval, editId, fieldValues, requestTypeId]
|
||||||
[context, userEmail, approval, editId, data.WorkType, data.Department, requestTypeId]
|
|
||||||
);
|
);
|
||||||
const matrix = useMatrixEditor(
|
const matrix = useMatrixEditor(loadMatrix, matrixKey, false, matrixReady, (login) => approval.getJobTitle(login));
|
||||||
loadMatrix,
|
|
||||||
`${userEmail}-${editId ?? 'new'}-${data.WorkType}-${data.Department}`,
|
// Исполнение — сотрудник отдела 1С (шаг «Исполнение» маршрута): работу ведёт
|
||||||
false,
|
// в карточке Planner, в портале отмечает «Выполнено» (см. plannerHandoff.ts).
|
||||||
!!editId || (!!data.WorkType && !!requestTypeId),
|
const loadExecutors = useCallback(
|
||||||
(login) => approval.getJobTitle(login)
|
() =>
|
||||||
|
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 её не знает (резолвил шаг
|
// Должность нового «Руководителя» — approval-route её не знает (резолвил шаг
|
||||||
// на ДРУГОГО человека и отдал его должность), поэтому подтягиваем сами по
|
// на ДРУГОГО человека и отдал его должность), поэтому подтягиваем сами по
|
||||||
// e-mail из поля (SPS-JobTitle, тот же источник, что и у обычных согласующих,
|
// e-mail из поля (SPS-JobTitle, тот же источник, что и у обычных согласующих,
|
||||||
@@ -272,12 +291,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (!rtId) {
|
if (!rtId) {
|
||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
}
|
}
|
||||||
// Маршрут запрашивается для ЛЮБОГО вида работ и используется как есть —
|
const finalStatus = submitStatus(intendedStatus, matrixRows.length, executors.rows.length);
|
||||||
// без авто-approve, если админ ещё не настроил шаги для конкретного вида
|
|
||||||
// работ (раньше в этом случае заявка сразу становилась «Согласовано» в
|
|
||||||
// обход согласования; теперь это просто «На согласовании» с пустой
|
|
||||||
// матрицей — сигнал администратору маршрутов донастроить условие).
|
|
||||||
const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : REQUEST_STATUS.onApproval;
|
|
||||||
|
|
||||||
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||||
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
||||||
@@ -292,6 +306,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
requestId,
|
requestId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrixRows),
|
toApprovers(matrixRows),
|
||||||
|
toApprovers(executors.rows),
|
||||||
finalStatus,
|
finalStatus,
|
||||||
writeListId
|
writeListId
|
||||||
);
|
);
|
||||||
@@ -311,10 +326,18 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Передача в Planner теперь происходит только после реального решения
|
// Согласование для этого вида работ не нужно — заявка сразу у исполнителя,
|
||||||
// согласующего (см. Ticket1CView.handoffToPlanner) — здесь заявка всегда
|
// и карточка Planner нужна уже сейчас (иначе её создаёт решение согласующего
|
||||||
// отправляется на согласование, минуя Planner напрямую.
|
// в Ticket1CView). Сбой Planner не отменяет уже отправленную заявку.
|
||||||
if (finalStatus === REQUEST_STATUS.onApproval) {
|
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),
|
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||||
// не обязательно текущей на момент рендера формы: поток ищет строку
|
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||||
// маршрута именно в НЕЙ.
|
// маршрута именно в НЕЙ.
|
||||||
@@ -344,7 +367,13 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`
|
||||||
);
|
);
|
||||||
if (unassignedError) return unassignedError;
|
if (unassignedError) return unassignedError;
|
||||||
return undefined;
|
if (executors.error) {
|
||||||
|
return `Не удалось определить исполнителя: ${executors.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
|
}
|
||||||
|
return unassignedStepMessage(
|
||||||
|
executors.rows,
|
||||||
|
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
@@ -422,6 +451,16 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
|
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={executors}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
title="Исполнение"
|
||||||
|
roleLabel="Исполнитель"
|
||||||
|
emptyText="Исполнители не назначены."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||||
{editId && (
|
{editId && (
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
REQUEST_STATUS
|
APPROVAL_PHASE
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
@@ -35,11 +35,10 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} 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 { loadTicketRequest } from './load';
|
||||||
import { ticket1cApprovalService, getPlannerTaskId, setPlannerTaskId } from './submit';
|
import { ticket1cApprovalService } from './submit';
|
||||||
import { PlannerTicketService } from './services/plannerTicket.service';
|
import { handoffToPlanner, isPlannerHandoffStatus } from './plannerHandoff';
|
||||||
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config';
|
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
import { ContactSection } from './sections/ContactSection';
|
import { ContactSection } from './sections/ContactSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
@@ -55,35 +54,6 @@ interface ITicket1CViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
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С только для просмотра (режим согласующего).
|
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
||||||
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
@@ -112,10 +82,24 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
// decisionSeq входит в reloadKey: после решения строки маршрута меняются на
|
// decisionSeq входит в reloadKey: после решения строки маршрута меняются на
|
||||||
// сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу.
|
// сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу.
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
const [decisionSeq, setDecisionSeq] = useState<number>(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) =>
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(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(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -143,41 +127,6 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
};
|
};
|
||||||
}, [itemId, listId, notifyCode]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
// Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита
|
|
||||||
// от повторного создания карточки, если решение применяется из нескольких
|
|
||||||
// вкладок/сессий одновременно (см. submit.ts).
|
|
||||||
const handoffToPlanner = useCallback(
|
|
||||||
async (finalStatus: string, currentData: IFormData, currentCode: string): Promise<void> => {
|
|
||||||
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) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
}
|
}
|
||||||
@@ -246,6 +195,16 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
emptyText="Согласование не требовалось для этого вида работ."
|
emptyText="Согласование не требовалось для этого вида работ."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={executors}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
title="Исполнение"
|
||||||
|
roleLabel="Исполнитель"
|
||||||
|
emptyText="Исполнители не назначены."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
@@ -265,8 +224,10 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
||||||
notify();
|
notify();
|
||||||
|
|
||||||
if (newStatus === REQUEST_STATUS.approved || newStatus === REQUEST_STATUS.rejected) {
|
// Дошла до исполнения / завершилась / отклонена — карточка в Planner
|
||||||
handoffToPlanner(newStatus, data, code).catch((e) =>
|
// (повторно не создаётся, см. plannerHandoff.ts).
|
||||||
|
if (isPlannerHandoffStatus(newStatus)) {
|
||||||
|
handoffToPlanner({ context, approval, itemId, listId, status: newStatus, data, code }).catch((e) =>
|
||||||
setError(`Не удалось передать заявку в Planner: ${(e as Error).message}`)
|
setError(`Не удалось передать заявку в Planner: ${(e as Error).message}`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string> = [
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -32,10 +32,12 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
|||||||
export type ISaveResult = ISaveItemResult;
|
export type ISaveResult = ISaveItemResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку в отдел 1С: содержимое + маршрут согласования.
|
* Сохранить заявку в отдел 1С: содержимое + маршрут (согласование и исполнение).
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю
|
* Исполнение — сотрудник отдела 1С из маршрута: работу ведёт в карточке Planner
|
||||||
* из матрицы. Возвращает Id заявки (+ код при создании).
|
* (создаётся при переходе в «Выполнение», см. plannerHandoff.ts), а в портале
|
||||||
|
* отмечает «Выполнено». executors может быть пустым — тогда после согласования
|
||||||
|
* заявка станет «Согласовано». Возвращает Id заявки (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||||
* ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция,
|
* ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция,
|
||||||
@@ -49,13 +51,14 @@ export async function saveTicketRequest(
|
|||||||
existingId: number | undefined,
|
existingId: number | undefined,
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
const item = buildItem(data, status);
|
const item = buildItem(data, status);
|
||||||
|
|
||||||
const result = await saveRequestItem(FORM_ID, existingId, listId, TICKET1C_CODE_PREFIX, item);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -241,8 +241,8 @@ export class ApproverService {
|
|||||||
// 2) согласование завершено: нет исполнителей → «Согласовано» (финал);
|
// 2) согласование завершено: нет исполнителей → «Согласовано» (финал);
|
||||||
// исполнители есть и не все выполнили → «Выполнение»;
|
// исполнители есть и не все выполнили → «Выполнение»;
|
||||||
// все выполнили → «Закрыта».
|
// все выполнили → «Закрыта».
|
||||||
// Строка «выполнена» исполнителем помечается тем же статусом «Согласовано»
|
// Строка исполнителя после действия «Выполнено» (DecisionPanel) получает
|
||||||
// (действие «Выполнено» в DecisionPanel), фазы различаются колонкой Phase.
|
// статус «Выполнено»; фазы различаются колонкой Phase.
|
||||||
private computeRequestStatus(rows: IApprovalRow[]): string {
|
private computeRequestStatus(rows: IApprovalRow[]): string {
|
||||||
const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
|
const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
|
||||||
const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
|
const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
|
||||||
|
|||||||
Reference in New Issue
Block a user