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
|
||||
|
||||
## Маршрут
|
||||
- Фазы: только согласование. Отдельной фазы исполнения нет — после того как
|
||||
согласование пройдено (или если для вида работ оно не требуется), заявка сразу
|
||||
уходит не исполнителю из матрицы, а в 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 в обход согласования.
|
||||
|
||||
@@ -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<IRequestFormProps> = ({
|
||||
requestType,
|
||||
@@ -168,23 +171,39 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
// а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА нередактируема
|
||||
// (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<IRequestFormProps> = ({
|
||||
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<IRequestFormProps> = ({
|
||||
requestId,
|
||||
data,
|
||||
toApprovers(matrixRows),
|
||||
toApprovers(executors.rows),
|
||||
finalStatus,
|
||||
writeListId
|
||||
);
|
||||
@@ -311,10 +326,18 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
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<IRequestFormProps> = ({
|
||||
(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<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
@@ -422,6 +451,16 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
|
||||
/>
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не назначены."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
|
||||
@@ -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<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||
@@ -112,10 +82,24 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
||||
// decisionSeq входит в reloadKey: после решения строки маршрута меняются на
|
||||
// сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу.
|
||||
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) =>
|
||||
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<ITicket1CViewProps> = ({ context, itemId, formKey,
|
||||
};
|
||||
}, [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) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
}
|
||||
@@ -246,6 +195,16 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
emptyText="Согласование не требовалось для этого вида работ."
|
||||
/>
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не назначены."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
@@ -265,8 +224,10 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ 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}`)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* Сохранить заявку в отдел 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<ISaveResult> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) согласование завершено: нет исполнителей → «Согласовано» (финал);
|
||||
// исполнители есть и не все выполнили → «Выполнение»;
|
||||
// все выполнили → «Закрыта».
|
||||
// Строка «выполнена» исполнителем помечается тем же статусом «Согласовано»
|
||||
// (действие «Выполнено» в 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);
|
||||
|
||||
Reference in New Issue
Block a user