Управление заявками (#7)
Tests / frontend-test (push) Successful in 43s
Tests / backend-test (push) Successful in 2m36s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/7
This commit was merged in pull request #7.
This commit is contained in:
2026-08-07 09:49:38 +00:00
parent 9d9f390548
commit 0ffa032ade
42 changed files with 2436 additions and 65 deletions
@@ -0,0 +1,308 @@
import { spfi, SPFI, SPFx } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
import '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
APPROVAL_PHASE,
ManagedApprovalRow,
ManagedRequestMatrix,
ManagedRequestPage,
ManagedRequestSummary,
RequestType
} from '../models';
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
// (конвенция имён; см. request-portal/src/webparts/requestPortal/forms/*/provisioning.json
// и forms/*/approval/provisioning.json — этот сервис читает и пишет ровно те же
// списки SharePoint, только с другого клиента).
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
// request-portal/src/webparts/requestPortal/shared/approval/approvalList.service.ts.
// Это тот же самый список SharePoint, поэтому имена колонок должны совпадать
// буквально — здесь их не выбирают заново, а просто дублируют.
const FIELDS = {
approver: 'TansuSiteApprover',
status: 'RequestStatus',
ticket: 'Ticket',
date: 'Date',
comment: 'Comment',
order: 'StepOrder',
allowReassign: 'AllowReassignToOtherApprovers',
phase: 'Phase'
};
function siteOrigin(siteUrl: string): string {
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
return match ? match[0] : siteUrl;
}
function listAbsUrl(siteUrl: string, listId: string): string {
return `${siteOrigin(siteUrl)}${listId}`;
}
// KQL не переносит большинство спецсимволов в свободном терме — оставляем буквы
// (лат./кир.), цифры и пробелы. Та же логика, что в request-portal
// (features/myRequests/myRequests.service.ts/sanitizeForKql).
function sanitizeForKql(text: string): string {
return text.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
}
function freeTextClause(query: string): string | undefined {
const q = sanitizeForKql(query);
if (!q) return undefined;
return `(${q.split(' ').map(w => `${w}*`).join(' AND ')})`;
}
function formatDateIso(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
return `${dd}.${mm}.${d.getFullYear()}`;
}
interface IRawDataRow {
Id: number;
Title?: string;
Status?: string;
Created?: string;
Author?: { Title?: string };
}
function toSummary(r: IRawDataRow): ManagedRequestSummary {
return {
id: r.Id,
title: r.Title || `#${r.Id}`,
status: r.Status || '',
created: r.Created || '',
authorName: r.Author?.Title || ''
};
}
/**
* Чтение/правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint —
* в отличие от остального этого веб-парта (который говорит с Tnsu.ApprovalRoute.Api),
* данные заявок и строки маршрута живут только в списках SharePoint, которыми
* управляет request-portal. У бэкенда этого API нет и не будет: типы заявок
* знают только list_id/site_url (см. models/requestTypes.ts), содержимое списков
* ему не принадлежит.
*/
export class RequestManageService {
constructor(private readonly context: WebPartContext) {}
private clientFor(siteUrl: string): SPFI {
return spfi(siteUrl).using(SPFx(this.context));
}
// Title списка данных → Title списка согласования. undefined, если Title не
// оканчивается на «— данные» (список заведён не по конвенции request-portal,
// управлять маршрутом для него отсюда нельзя).
private async approvalListTitle(sp: SPFI, listId: string): Promise<string | undefined> {
const info = (await sp.web.getList(listId).select('Title')()) as { Title: string };
if (!DATA_LIST_SUFFIX.test(info.Title)) return undefined;
return `${info.Title.replace(DATA_LIST_SUFFIX, '')} — согласование`;
}
/**
* Заявки типа requestType постранично, для комбобокса выбора заявки. Сначала
* Search по названию (свободный терм, пустой query — просто последние без
* фильтра), затем дочитывание полей REST'ом по найденным Id — Search не
* отдаёт произвольные поля списка надёжно (та же двухшаговая схема, что в
* request-portal).
*/
public async searchRequests(
requestType: RequestType,
query: string,
page: number,
pageSize = 20
): Promise<ManagedRequestPage> {
if (!requestType.listId || !requestType.siteUrl) return { items: [], hasMore: false };
const sp = this.clientFor(requestType.siteUrl);
const absUrl = listAbsUrl(requestType.siteUrl, requestType.listId);
const clauses = [`Path:"${absUrl}*"`];
const text = freeTextClause(query);
if (text) clauses.push(text);
const result = await sp.search({
Querytext: clauses.join(' AND '),
SelectProperties: ['ListItemID', 'Path'],
RowLimit: pageSize,
StartRow: (page - 1) * pageSize,
TrimDuplicates: false,
SortList: [{ Property: 'Created', Direction: 1 }]
});
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string }>;
const ids = rawRows.map(r => Number(r.ListItemID)).filter(id => !isNaN(id) && id > 0);
if (ids.length === 0) return { items: [], hasMore: false };
const list = sp.web.getList(requestType.listId);
const dataRows = (await list.items
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'Status', 'Created', 'Author/Title')
.expand('Author')
.top(ids.length)()) as IRawDataRow[];
const byId = new Map(dataRows.map(r => [r.Id, r]));
// Порядок задаёт Search (SortList по дате), не порядок ответа REST.
const items = ids
.map(id => byId.get(id))
.filter((r): r is IRawDataRow => !!r)
.map(toSummary);
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
return { items, hasMore: page * pageSize < totalRows };
}
/** Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования. */
public async getMatrix(requestType: RequestType, requestId: number): Promise<ManagedRequestMatrix> {
if (!requestType.listId || !requestType.siteUrl) {
throw new Error('У типа заявки не указан список SharePoint.');
}
const sp = this.clientFor(requestType.siteUrl);
const dataList = sp.web.getList(requestType.listId);
const [requestRow, approvalListTitle] = await Promise.all([
dataList.items
.getById(requestId)
.select('Id', 'Title', 'Status', 'Created', 'Author/Title')
.expand('Author')() as Promise<IRawDataRow>,
this.approvalListTitle(sp, requestType.listId)
]);
if (!approvalListTitle) {
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, approvalListTitle: '', rows: [] };
}
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
const rawRows = (await approvalList.items
.filter(`${FIELDS.ticket}Id eq ${requestId}`)
.select(
'Id',
'Title',
FIELDS.status,
FIELDS.date,
FIELDS.comment,
FIELDS.order,
FIELDS.allowReassign,
FIELDS.phase,
`${FIELDS.approver}/Id`,
`${FIELDS.approver}/Title`,
`${FIELDS.approver}/EMail`
)
.expand(FIELDS.approver)
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
const rows: ManagedApprovalRow[] = rawRows.map((r, i) => {
const approver = r[FIELDS.approver] as { Id?: number; Title?: string; EMail?: string } | undefined;
return {
id: r.Id as number,
ticketId: requestId,
approverId: approver?.Id ?? null,
approverName: approver?.Title || '',
approverEmail: approver?.EMail || '',
position: (r.Title as string) || '',
status: (r[FIELDS.status] as string) || '',
date: formatDateIso((r[FIELDS.date] as string) || ''),
comment: (r[FIELDS.comment] as string) || '',
order: (r[FIELDS.order] as number) ?? i + 1,
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval
};
});
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, approvalListTitle, rows };
}
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
private async resolveUserId(sp: SPFI, email: string): Promise<number> {
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
const user = await sp.web.ensureUser(login);
return user.Id;
}
/** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана
* проверить isEditableRowStatus(row.status) — сервис это не перепроверяет. */
public async reassignApprover(
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
rowId: number,
email: string,
position: string
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
const approverId = await this.resolveUserId(sp, email);
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({
Title: position,
[`${FIELDS.approver}Id`]: approverId
});
}
/** Удалить строку маршрута. */
public async removeRow(
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
rowId: number
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).delete();
}
/**
* Добавить новую строку в фазу заявки. order считает вызывающая сторона
* (см. ManageRequestsScreen) — он обязан быть больше order любой уже решённой
* строки той же фазы (иначе новый согласующий встанет впереди того, кто уже
* согласовал, а это правило запрещено интерфейсом).
*/
public async addRow(
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
ticketId: number,
phase: string,
order: number,
status: string,
email: string,
position: string,
allowReassign: boolean
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
const approverId = await this.resolveUserId(sp, email);
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.add({
Title: position || email,
[`${FIELDS.approver}Id`]: approverId,
[FIELDS.status]: status,
[`${FIELDS.ticket}Id`]: ticketId,
[FIELDS.order]: order,
[FIELDS.allowReassign]: allowReassign,
[FIELDS.phase]: phase
});
}
/**
* Переставить StepOrder нескольких строк разом (перестановка шагов местами) и,
* при необходимости, статус той же строки — см. ManageRequestsScreen/
* computeActivationUpdates: после любой перестановки строка, ставшая первой
* нерешённой в очереди фазы, должна получить «На согласовании»/«На
* исполнении» вместо «Ждёт очереди» (и наоборот — та, что раньше была первой,
* а теперь нет). Оба поля пишутся одним update, чтобы не плодить лишний
* запрос на строку.
*/
public async reorderRows(
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
updates: Array<{ id: number; order?: number; status?: string }>
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
const list = sp.web.lists.getByTitle(matrix.approvalListTitle);
await Promise.all(
updates.map(u => {
const patch: Record<string, unknown> = {};
if (u.order !== undefined) patch[FIELDS.order] = u.order;
if (u.status !== undefined) patch[FIELDS.status] = u.status;
return list.items.getById(u.id).update(patch);
})
);
}
}