diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/RequestsTable.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/RequestsTable.tsx index 13ba40f..4c02cf9 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/RequestsTable.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/RequestsTable.tsx @@ -10,6 +10,10 @@ interface Props { lockedProjectId: string | undefined; onSelectionChange: (positionId: string, position: Position, request: Request, checked: boolean) => void; onRequestSelectionChange: (request: Request, checked: boolean) => void; + /** Лениво подгрузить позиции заявки (при раскрытии). В read-only не передаётся. */ + onExpand?: (requestId: string) => void; + /** Заявки, чьи позиции сейчас грузятся — показываем спиннер-строку. */ + loadingPositionIds?: Set; isReadOnly?: boolean; } @@ -24,17 +28,22 @@ export const RequestsTable: React.FC = ({ lockedProjectId, onSelectionChange, onRequestSelectionChange, + onExpand, + loadingPositionIds, isReadOnly = false, }) => { const [expandedRequestIds, setExpandedRequestIds] = useState>(new Set()); const toggleExpand = useCallback((id: string) => { + const willExpand = !expandedRequestIds.has(id); setExpandedRequestIds(prev => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); - }, []); + // При раскрытии — лениво подгружаем позиции этой заявки (идемпотентно в useRequests). + if (willExpand && onExpand) onExpand(id); + }, [expandedRequestIds, onExpand]); const getRequestSelectionCount = (request: Request): number => request.positions.filter(p => selectedPositionIds.has(p.id)).length; @@ -70,8 +79,9 @@ export const RequestsTable: React.FC = ({ {requests.map(request => { const isExpanded = expandedRequestIds.has(request.id); const selCount = getRequestSelectionCount(request); - const totalCount = request.positions.length; + const totalCount = request.positionsCount; const locked = isProjectLocked(request); + const isLoadingPositions = loadingPositionIds?.has(request.id) ?? false; return ( @@ -95,7 +105,7 @@ export const RequestsTable: React.FC = ({ /> - {request.positions.length > 0 && ( + {request.positionsCount > 0 && ( + @@ -227,6 +245,8 @@ export const Step1PositionSelection: React.FC = ({ lockedProjectId={lockedProjectId} onSelectionChange={handleSelectionChange} onRequestSelectionChange={handleRequestSelectionChange} + onExpand={loadPositions} + loadingPositionIds={loadingPositionIds} isReadOnly={isReadOnly} /> {!isReadOnly && totalPages > 1 && ( diff --git a/src/webparts/ctFormsPortal/forms/ct/components/hooks/useRequests.ts b/src/webparts/ctFormsPortal/forms/ct/components/hooks/useRequests.ts index 1becf69..61eb09d 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/hooks/useRequests.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/hooks/useRequests.ts @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react'; -import { Request } from '../types/domain'; +import { useState, useEffect, useCallback } from 'react'; +import { Request, Position } from '../types/domain'; import { CtService } from '../../services/CtService'; interface UseRequestsResult { @@ -7,6 +7,11 @@ interface UseRequestsResult { isLoading: boolean; error: string | undefined; reload: () => void; + /** Лениво подгрузить позиции одной заявки (при раскрытии/выборе-всех). Идемпотентно. + * Возвращает позиции заявки (уже загруженные или только что подтянутые). */ + loadPositions: (requestId: string) => Promise; + /** Заявки, чьи позиции сейчас грузятся (для спиннера в строке). */ + loadingPositionIds: Set; } export function useRequests(service: CtService, days: number, skip?: boolean): UseRequestsResult { @@ -14,6 +19,7 @@ export function useRequests(service: CtService, days: number, skip?: boolean): U const [isLoading, setIsLoading] = useState(!skip); const [error, setError] = useState(undefined); const [reloadToken, setReloadToken] = useState(0); + const [loadingPositionIds, setLoadingPositionIds] = useState>(new Set()); useEffect(() => { if (skip) { @@ -46,10 +52,38 @@ export function useRequests(service: CtService, days: number, skip?: boolean): U }; }, [service, days, reloadToken, skip]); + const loadPositions = useCallback( + async (requestId: string): Promise => { + const target = requests.find(r => r.id === requestId); + if (!target) return []; + // Уже загружены / грузятся — не дёргаем API повторно. + if (target.positions.length > 0) return target.positions; + if (loadingPositionIds.has(requestId)) return []; + + setLoadingPositionIds(prev => new Set(prev).add(requestId)); + try { + const positions = await service.loadRequestPositions(target); + setRequests(prev => prev.map(r => (r.id === requestId ? { ...r, positions } : r))); + return positions; + } catch { + return []; /* оставляем заявку без позиций — счётчик из positionsCount уже показан */ + } finally { + setLoadingPositionIds(prev => { + const next = new Set(prev); + next.delete(requestId); + return next; + }); + } + }, + [service, requests, loadingPositionIds], + ); + return { requests, isLoading, error, reload: () => setReloadToken(t => t + 1), + loadPositions, + loadingPositionIds, }; } diff --git a/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts b/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts index 56dfdcb..ecb37c4 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts @@ -30,6 +30,9 @@ export interface Request { workType: string; organization: string; createdAt: string; + // Кол-во позиций приходит в списке заявок (positionsCount) — для счётчика без загрузки самих + // позиций. Сами positions грузятся лениво при раскрытии заявки (см. loadRequestPositions). + positionsCount: number; positions: Position[]; } diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index 007a144..6fcdec2 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -146,23 +146,26 @@ export class CtService { } const data: ApiRequest[] = await res.json(); - const requests = data.map(this._mapRequest); + // Позиции НЕ грузим здесь: раньше был N+1 (по GET /positions на каждую заявку — сотни + // запросов при открытии Шага 1). Счётчик берём из positionsCount (пришёл в списке), + // сами позиции подтягиваем лениво при раскрытии заявки (loadRequestPositions). + return data.map(this._mapRequest); + } - // Load positions for all requests in parallel - const positionsAll = await Promise.all( - requests.map((r) => - this._fetchPositionsForRequest(r.id, r.requestNumber, token), - ), + // Ленивая загрузка позиций одной заявки — при раскрытии строки на Шаге 1. Обогащаем + // organization/requestCreatedAt из шапки заявки (как раньше делал fetchRequests). + async loadRequestPositions(request: Request): Promise { + const token = await this._getApiToken(); + const positions = await this._fetchPositionsForRequest( + request.id, + request.requestNumber, + token, ); - requests.forEach((r, i) => { - r.positions = positionsAll[i].map((p) => ({ - ...p, - organization: r.organization, - requestCreatedAt: r.createdAt, - })); - }); - - return requests; + return positions.map((p) => ({ + ...p, + organization: request.organization, + requestCreatedAt: request.createdAt, + })); } private async _fetchPositionsForRequest( @@ -268,34 +271,99 @@ export class CtService { ctId: string, positions: Position[], ): Promise { - const result: Position[] = []; - for (const pos of positions) { - const res = await this.http.post( - this._li(LISTS.CT_POSITIONS), - SPHttpClient.configurations.v1, - { - headers: { "Content-Type": "application/json;odata=nometadata" }, - body: JSON.stringify({ - Title: pos.materialName, - CTId: parseInt(ctId, 10), - demandNumber: pos.requestNumber, - position: pos.materialName, - disciplineCode: pos.disciplineCode ?? "", - SMR: pos.smrType ?? "", - unit: pos.unit ?? "", - agskCode: pos.agskCode ?? "", - uniqueCode: pos.uniqueCode ?? "", - quantity: pos.quantity, - positionType: pos.positionType ?? "ТМЦ", - deadline: pos.deadline ? pos.deadline.replace(/Z?$/, "Z") : null, - }), - }, - ); - if (!res.ok) throw new Error(`Ошибка создания позиции СТ: ${res.status}`); - const data = await res.json(); - result.push({ ...pos, spId: Number(data.Id) }); + if (positions.length === 0) return []; + // Раньше — по одному POST на позицию в цикле await (последовательно): 100 позиций = + // 100 запросов и ~30-60 сек + троттлинг 429. Теперь одним $batch (до 100 операций/запрос). + const bodies = positions.map((pos) => ({ + Title: pos.materialName, + CTId: parseInt(ctId, 10), + demandNumber: pos.requestNumber, + position: pos.materialName, + disciplineCode: pos.disciplineCode ?? "", + SMR: pos.smrType ?? "", + unit: pos.unit ?? "", + agskCode: pos.agskCode ?? "", + uniqueCode: pos.uniqueCode ?? "", + quantity: pos.quantity, + positionType: pos.positionType ?? "ТМЦ", + deadline: pos.deadline ? pos.deadline.replace(/Z?$/, "Z") : undefined, + })); + const ids = await this.batchCreateItems(LISTS.CT_POSITIONS, bodies); + return positions.map((pos, i) => ({ ...pos, spId: ids[i] })); + } + + // Пакетное создание элементов списка через SharePoint OData $batch: вместо N POST — + // один HTTP-запрос (до 100 операций/changeset, при большем числе бьём на чанки). + // Возвращает Id созданных элементов В ТОМ ЖЕ порядке, что и bodies. Changeset атомарен: + // при сбое любой операции откатывается весь пакет (тогда бросаем — вызывающий увидит ошибку). + private async batchCreateItems( + listName: string, + bodies: Array>, + ): Promise { + if (bodies.length === 0) return []; + const CHUNK = 100; + const listUrl = this._li(listName); + const ids: number[] = []; + for (let start = 0; start < bodies.length; start += CHUNK) { + const chunk = bodies.slice(start, start + CHUNK); + const batchId = `batch_${this._guid()}`; + const changesetId = `changeset_${this._guid()}`; + const lines: string[] = [`--${batchId}`, `Content-Type: multipart/mixed; boundary="${changesetId}"`, ""]; + chunk.forEach((body, idx) => { + lines.push( + `--${changesetId}`, + "Content-Type: application/http", + "Content-Transfer-Encoding: binary", + `Content-ID: ${idx + 1}`, + "", + `POST ${listUrl} HTTP/1.1`, + "Content-Type: application/json;odata=nometadata", + "Accept: application/json;odata=nometadata", + "", + JSON.stringify(body), + "", + ); + }); + lines.push(`--${changesetId}--`, `--${batchId}--`, ""); + const res = await this.http.post(`${this.baseUrl}/_api/$batch`, SPHttpClient.configurations.v1, { + headers: { "Content-Type": `multipart/mixed; boundary="${batchId}"` }, + body: lines.join("\r\n"), + }); + if (!res.ok) throw new Error(`Ошибка $batch создания в «${listName}»: ${res.status} ${res.statusText}`); + const text = await res.text(); + // Ответ multipart: у каждой созданной строки в теле свой "Id":N. Ключи-лукапы (CTId, + // AuthorId…) не начинаются ровно с "Id", поэтому регэксп ловит только собственный Id. + const re = /"Id":\s*(\d+)/g; + let matched = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + ids.push(Number(m[1])); + matched += 1; + } + if (matched !== chunk.length) { + throw new Error(`$batch «${listName}»: ожидалось ${chunk.length} элементов, создано ${matched} (возможно, ошибка в теле)`); + } + } + return ids; + } + + private _guid(): string { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } + + // Параллельный прогон worker по items пачками фиксированного размера: чанк идёт + // через Promise.all, следующий чанк — после него. Ограничивает одновременные запросы + // (защита от троттлинга SharePoint), сохраняя per-item обработку ответа/ошибки — + // в отличие от $batch, где changeset атомарен. Для списков с capризным вебхуком + // (офферы: 500 FormatException при успешной вставке) это единственный безопасный путь. + private async _runChunked(items: T[], size: number, worker: (item: T) => Promise): Promise { + for (let i = 0; i < items.length; i += size) { + await Promise.all(items.slice(i, i + size).map(worker)); } - return result; } // ─── Поставщики / Не получены КП ─────────────────────────────────────────── @@ -303,52 +371,58 @@ export class CtService { async saveSuppliers(suppliers: Supplier[]): Promise { const withOffer = suppliers.filter((s) => s.hasOffer !== false); const withoutOffer = suppliers.filter((s) => s.hasOffer === false); + await this._createMissingByTitle(LISTS.SUPPLIERS, withOffer, (s) => ({ + Title: s.name, + name: s.name, + status: s.status ?? "", + })); + await this._createMissingByTitle(LISTS.NO_OFFERS, withoutOffer, (s) => ({ + Title: s.name, + })); + } - for (const s of withOffer) { - const escaped = s.name.replace(/'/g, "''"); - const checkRes = await this.http.get( - `${this._li(LISTS.SUPPLIERS)}?$filter=Title eq '${escaped}'&$select=Id&$top=1`, + // Создаёт в справочнике только ОТСУТСТВУЮЩИХ по Title. Было: на каждого GET-проверка + + // POST по очереди (2×N последовательных запросов). Стало: один GET на все имена сразу + + // параллельное создание недостающих. Дедуп по имени (первое вхождение) — иначе одинаковые + // имена во входе создали бы дубли (проверка-то одна, до создания). + private async _createMissingByTitle( + listName: string, + items: Supplier[], + buildBody: (s: Supplier) => Record, + ): Promise { + const byName = new Map(); + items.forEach((s) => { + if (s.name && !byName.has(s.name)) byName.set(s.name, s); + }); + const names = Array.from(byName.keys()); + if (names.length === 0) return; + + // Какие имена уже есть — одним GET (пачками по 40, чтобы не раздувать $filter). + const existing = new Set(); + const NAME_CHUNK = 40; + for (let i = 0; i < names.length; i += NAME_CHUNK) { + const slice = names.slice(i, i + NAME_CHUNK); + const filter = slice.map((n) => `Title eq '${n.replace(/'/g, "''")}'`).join(" or "); + const res = await this.http.get( + `${this._li(listName)}?$select=Title&$filter=${filter}&$top=5000`, SPHttpClient.configurations.v1, ); - const exists = - checkRes.ok && ((await checkRes.json()).value ?? []).length > 0; - if (exists) continue; - const res = await this.http.post( - this._li(LISTS.SUPPLIERS), - SPHttpClient.configurations.v1, - { - headers: { "Content-Type": "application/json;odata=nometadata" }, - body: JSON.stringify({ - Title: s.name, - name: s.name, - status: s.status ?? "", - }), - }, - ); - if (!res.ok) - throw new Error(`Ошибка сохранения поставщика: ${res.status}`); + if (res.ok) { + const data = await res.json(); + for (const it of (data.value ?? []) as Array<{ Title?: string }>) { + if (it.Title) existing.add(it.Title); + } + } } - for (const s of withoutOffer) { - const escaped = s.name.replace(/'/g, "''"); - const checkRes = await this.http.get( - `${this._li(LISTS.NO_OFFERS)}?$filter=Title eq '${escaped}'&$select=Id&$top=1`, - SPHttpClient.configurations.v1, - ); - const exists = - checkRes.ok && ((await checkRes.json()).value ?? []).length > 0; - if (exists) continue; - const res = await this.http.post( - this._li(LISTS.NO_OFFERS), - SPHttpClient.configurations.v1, - { - headers: { "Content-Type": "application/json;odata=nometadata" }, - body: JSON.stringify({ Title: s.name }), - }, - ); - if (!res.ok) - throw new Error(`Ошибка сохранения поставщика без КП: ${res.status}`); - } + const toCreate = names.filter((n) => !existing.has(n)).map((n) => byName.get(n) as Supplier); + await this._runChunked(toCreate, 15, async (s) => { + const res = await this.http.post(this._li(listName), SPHttpClient.configurations.v1, { + headers: { "Content-Type": "application/json;odata=nometadata" }, + body: JSON.stringify(buildBody(s)), + }); + if (!res.ok) throw new Error(`Ошибка сохранения в «${listName}»: ${res.status}`); + }); } // ─── Курс Валют ──────────────────────────────────────────────────────────── @@ -387,21 +461,30 @@ export class CtService { ): Promise { if (offers.length === 0) return; + // Валидация SP-Id ДО записи — чтобы не создать половину офферов и упасть на середине. + offers.forEach((o) => { + if (isNaN(parseInt(o.positionId, 10))) + throw new Error( + `Некорректный SP ID позиции: "${o.positionId}". Попробуйте вернуться на Шаг 2 и сохранить заново.`, + ); + if (isNaN(parseInt(o.supplierId, 10))) + throw new Error( + `Некорректный SP ID поставщика: "${o.supplierId}". Попробуйте вернуться на Шаг 2 и сохранить заново.`, + ); + }); + // Load ALL existing records for the involved positions and recycle them before re-creating. // Bundle support requires multiple records per (supplier, position), so we can't rely on // a single Title per pair — instead we recycle by positionId filter. // MERGE is blocked on this list (event receiver), recycle() works fine. - const posSpIdsSet: Record = {}; - offers.forEach((o) => { - const id = parseInt(o.positionId, 10); - if (!isNaN(id)) posSpIdsSet[id] = true; - }); - const posSpIds = Object.keys(posSpIdsSet).map(Number); + const posSpIds = Array.from( + new Set(offers.map((o) => parseInt(o.positionId, 10)).filter((id) => !isNaN(id))), + ); const existingIds: number[] = []; if (posSpIds.length > 0) { const filter = posSpIds.map((id) => `positionId eq ${id}`).join(" or "); const existRes = await this.http.get( - `${this._li(LISTS.OFFERS)}?$select=Id&$filter=${filter}`, + `${this._li(LISTS.OFFERS)}?$select=Id&$filter=${filter}&$top=5000`, SPHttpClient.configurations.v1, ); if (existRes.ok) { @@ -412,38 +495,22 @@ export class CtService { } } - for (const id of existingIds) { + // Было: два последовательных цикла await (recycle N + создание N), N = позиции×поставщики + // (100+ записей → десятки секунд + троттлинг). Стало: параллельно пачками. Не $batch — + // см. _runChunked (капризный вебхук офферов ломает атомарный changeset). + await this._runChunked(existingIds, 20, async (id) => { await this.http - .post( - `${this._liById(LISTS.OFFERS, id)}/recycle()`, - SPHttpClient.configurations.v1, - {}, - ) - .catch(() => { - /* ignore — already in recycle bin or not found */ - }); - } + .post(`${this._liById(LISTS.OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {}) + .catch(() => undefined /* уже в корзине / не найден — игнорируем */); + }); - for (const o of offers) { + await this._runChunked(offers, 15, async (o) => { const posId = parseInt(o.positionId, 10); const supId = parseInt(o.supplierId, 10); - - if (isNaN(posId)) - throw new Error( - `Некорректный SP ID позиции: "${o.positionId}". Попробуйте вернуться на Шаг 2 и сохранить заново.`, - ); - if (isNaN(supId)) - throw new Error( - `Некорректный SP ID поставщика: "${o.supplierId}". Попробуйте вернуться на Шаг 2 и сохранить заново.`, - ); - const itemIndex = o.itemIndex ?? 0; const currency = currencyRates.find((r) => r.code === o.currencyCode); const body: Record = { - Title: - itemIndex === 0 - ? `${supId}-${posId}` - : `${supId}-${posId}-${itemIndex}`, + Title: itemIndex === 0 ? `${supId}-${posId}` : `${supId}-${posId}-${itemIndex}`, positionId: posId, supplierId: supId, supplierPosition: o.supplierPosition ?? "", @@ -460,26 +527,21 @@ export class CtService { if (!isNaN(valId)) body.valutaId = valId; } const resolvedOfferId = o.offerId ? parseInt(o.offerId, 10) : undefined; - if (resolvedOfferId && !isNaN(resolvedOfferId)) - body.offerId = resolvedOfferId; + if (resolvedOfferId && !isNaN(resolvedOfferId)) body.offerId = resolvedOfferId; - const res = await this.http.post( - this._li(LISTS.OFFERS), - SPHttpClient.configurations.v1, - { - headers: { "Content-Type": "application/json;odata=nometadata" }, - body: JSON.stringify(body), - }, - ); + const res = await this.http.post(this._li(LISTS.OFFERS), SPHttpClient.configurations.v1, { + headers: { "Content-Type": "application/json;odata=nometadata" }, + body: JSON.stringify(body), + }); if (!res.ok) { const errText = await res.text(); // SP webhook serialization bug: item IS created but async webhook throws FormatException. // Treat this as success — the item is in the list. - if (res.status === 500 && errText.includes("FormatException")) continue; + if (res.status === 500 && errText.includes("FormatException")) return; console.error("[saveOffers] POST error body:", errText); throw new Error(`Ошибка сохранения предложения: ${res.status}`); } - } + }); } // ─── Критерии ────────────────────────────────────────────────────────────── @@ -488,27 +550,20 @@ export class CtService { criteria: SupplierCriteria[], ctSpId?: number, ): Promise { - for (const c of criteria) { - const res = await this.http.post( - this._li(LISTS.CRITERIA), - SPHttpClient.configurations.v1, - { - headers: { "Content-Type": "application/json;odata=nometadata" }, - body: JSON.stringify({ - Title: c.supplierId, - supplierId: parseInt(c.supplierId, 10), - deliveryType: c.deliveryType, - paymentType: c.paymentType, - deadline: c.deadline, - sertificates: c.certificates, - comment: c.comment ?? "", - ...(ctSpId ? { CTId: ctSpId } : {}), - }), - }, - ); - if (!res.ok) - throw new Error(`Ошибка сохранения критериев: ${res.status}`); - } + if (criteria.length === 0) return; + // Было: POST на каждый критерий в цикле await. Стало: один $batch (у списка «Критерии» + // капризного вебхука нет, changeset безопасен). + const bodies = criteria.map((c) => ({ + Title: c.supplierId, + supplierId: parseInt(c.supplierId, 10), + deliveryType: c.deliveryType, + paymentType: c.paymentType, + deadline: c.deadline, + sertificates: c.certificates, + comment: c.comment ?? "", + ...(ctSpId ? { CTId: ctSpId } : {}), + })); + await this.batchCreateItems(LISTS.CRITERIA, bodies); } async saveFlowCriteria( @@ -1492,6 +1547,7 @@ export class CtService { workType: String(item.workType ?? ""), organization: String(item.organization ?? ""), createdAt: String(item.requestDate ?? ""), + positionsCount: Number(item.positionsCount ?? 0), positions: [], }); diff --git a/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts b/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts index 8f8469e..455f551 100644 --- a/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts +++ b/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts @@ -1139,10 +1139,15 @@ export class KpsCtFormService { // точка для «после отправки» и «после каждого решения» (см. Step4Approval/InlineApprovalChain). public async notifyActiveApprover(ctId: number): Promise { const rows = await this.approval.getForRequest(ctId); + if (rows.length === 0) return; + // Есть активный согласующий → уведомляем его («поступила на согласование»). + // Иначе финал (все согласовали / кто-то отклонил): активной строки уже нет, но поток + // сам выбирает получателя (инициатора) по статусу СТ — ему нужен лишь любой RowID этой СТ, + // чтобы выйти на неё и её автора. Берём последнюю строку. Без этого «Согласовано»/«Отклонено» + // инициатору не уходили (поток вообще не вызывался). Зеркало CtService/OidService. const active = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0]; - if (active) { - await this.triggerApprovalNotification(active.id); - } + const rowToNotify = active ?? rows[rows.length - 1]; + await this.triggerApprovalNotification(rowToNotify.id); } // Оповещение согласующего в Teams. Entra-триггер — зовём через AadHttpClient (токен Flow Service, diff --git a/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts b/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts index 3a2ed17..dab6b41 100644 --- a/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts +++ b/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts @@ -1039,10 +1039,15 @@ export class OidService { async notifyActiveApprover(oidId: number): Promise { const rows = await this.approval.getForRequest(oidId); + if (rows.length === 0) return; + // Есть активный согласующий → уведомляем его («поступила на согласование»). + // Иначе финал (все согласовали / кто-то отклонил): активной строки уже нет, но поток + // сам выбирает получателя (инициатора) по статусу ОИД — ему нужен лишь любой RowID этой + // заявки, чтобы выйти на неё и её автора. Берём последнюю строку. Без этого «Согласовано»/ + // «Отклонено» инициатору не уходили (поток вообще не вызывался). Зеркало CtService. const active = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0]; - if (active) { - await this.triggerApprovalNotification(active.id); - } + const rowToNotify = active ?? rows[rows.length - 1]; + await this.triggerApprovalNotification(rowToNotify.id); } async triggerApprovalNotification(spItemId: number): Promise {