Управление заявками (#7)
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:
@@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user