feat: этап исполнения в заявке 1С (исполнитель из маршрута + Planner)
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:
Тимур Городецкий
2026-10-02 10:09:04 +05:00
parent 4c851b4892
commit 4c8410bdcc
8 changed files with 247 additions and 112 deletions
@@ -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);