fix: финальные оповещения (oid/kps) + ленивая загрузка потребностей

This commit is contained in:
Тимур Городецкий
2026-09-14 16:10:39 +05:00
parent 0914c76e15
commit b0c08bcc83
7 changed files with 309 additions and 168 deletions
@@ -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<string>;
isReadOnly?: boolean;
}
@@ -24,17 +28,22 @@ export const RequestsTable: React.FC<Props> = ({
lockedProjectId,
onSelectionChange,
onRequestSelectionChange,
onExpand,
loadingPositionIds,
isReadOnly = false,
}) => {
const [expandedRequestIds, setExpandedRequestIds] = useState<Set<string>>(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<Props> = ({
{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 (
<React.Fragment key={request.id}>
@@ -95,7 +105,7 @@ export const RequestsTable: React.FC<Props> = ({
/>
</td>
<td className={styles.expandCell}>
{request.positions.length > 0 && (
{request.positionsCount > 0 && (
<button
className={styles.expandBtn}
onClick={() => toggleExpand(request.id)}
@@ -120,10 +130,18 @@ export const RequestsTable: React.FC<Props> = ({
<td>{request.organization || '—'}</td>
<td className={styles.dateCell}>{formatDate(request.createdAt)}</td>
<td className={styles.countCell}>
<span className={styles.positionCount}>{request.positions.length}</span>
<span className={styles.positionCount}>{request.positionsCount}</span>
</td>
</tr>
{isExpanded && isLoadingPositions && request.positions.length === 0 && (
<tr className={styles.positionRow}>
<td />
<td />
<td colSpan={7} className={styles.materialName}>Загрузка позиций…</td>
</tr>
)}
{isExpanded &&
request.positions.map(position => {
const isSelected = selectedPositionIds.has(position.id);
@@ -32,6 +32,7 @@ export const Step1PositionSelection: React.FC<Props> = ({
const [daysInput, setDaysInput] = useState(String(DEFAULT_DAYS));
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [showOnlySelected, setShowOnlySelected] = useState(false);
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
new Set(formData.selectedPositions.map(p => p.id))
);
@@ -39,7 +40,7 @@ export const Step1PositionSelection: React.FC<Props> = ({
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
const { requests, isLoading, error: loadError, reload } = useRequests(service, days, isReadOnly);
const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } = useRequests(service, days, isReadOnly);
const readOnlyRequests = useMemo(() => {
if (!isReadOnly) return [];
@@ -57,19 +58,25 @@ export const Step1PositionSelection: React.FC<Props> = ({
workType: positions[0].smrType,
organization: positions[0].organization ?? '',
createdAt: positions[0].requestCreatedAt ?? '',
positionsCount: positions.length,
positions,
}));
}, [isReadOnly, formData.selectedPositions, formData.selectedProject]);
const filteredRequests = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
const filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests;
let filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests;
// Режим «Только выбранные» — оставляем заявки, где есть хотя бы одна выбранная позиция,
// чтобы можно было свериться со своим выбором с любой страницы, не листая назад.
if (showOnlySelected) {
filtered = filtered.filter(r => r.positions.some(p => selectedPositionIds.has(p.id)));
}
return [...filtered].sort((a, b) => {
const aSelected = a.positions.some(p => selectedPositionIds.has(p.id)) ? 0 : 1;
const bSelected = b.positions.some(p => selectedPositionIds.has(p.id)) ? 0 : 1;
return aSelected - bSelected;
});
}, [requests, searchQuery, selectedPositionIds]);
}, [requests, searchQuery, selectedPositionIds, showOnlySelected]);
const totalPages = Math.max(1, Math.ceil(filteredRequests.length / PAGE_SIZE));
const safePage = Math.min(currentPage, totalPages);
@@ -111,12 +118,16 @@ export const Step1PositionSelection: React.FC<Props> = ({
);
const handleRequestSelectionChange = useCallback(
(request: Request, checked: boolean) => {
if (request.positions.length === 0) return;
async (request: Request, checked: boolean) => {
if (request.positionsCount === 0) return;
if (checked && lockedProjectId !== undefined && request.project.id !== lockedProjectId) return;
// «Выбрать все» может быть нажато на свёрнутой заявке — позиции ещё не загружены.
// Подтягиваем их (ленивая загрузка возвращает список), затем выделяем.
const positions = request.positions.length > 0 ? request.positions : await loadPositions(request.id);
if (positions.length === 0) return;
setSelectedPositionIds(prev => {
const next = new Set(prev);
request.positions.forEach(p => checked ? next.add(p.id) : next.delete(p.id));
positions.forEach(p => checked ? next.add(p.id) : next.delete(p.id));
return next;
});
if (checked && lockedProjectId === undefined) {
@@ -124,12 +135,12 @@ export const Step1PositionSelection: React.FC<Props> = ({
}
if (!checked) {
const remainingAfter = Array.from(selectedPositionIds).filter(
id => !request.positions.some(p => p.id === id)
id => !positions.some(p => p.id === id)
);
if (remainingAfter.length === 0) onSave({ selectedProject: undefined });
}
},
[lockedProjectId, onSave, selectedPositionIds]
[lockedProjectId, onSave, selectedPositionIds, loadPositions]
);
const handleDaysChange = (val: string): void => {
@@ -200,6 +211,13 @@ export const Step1PositionSelection: React.FC<Props> = ({
<Button variant="secondary" onClick={reload} disabled={isLoading}>
Обновить
</Button>
<Button
variant={showOnlySelected ? 'accent' : 'secondary'}
onClick={() => { setShowOnlySelected(v => !v); setCurrentPage(1); }}
disabled={!showOnlySelected && selectedPositionIds.size === 0}
>
{showOnlySelected ? 'Показать все' : `Только выбранные (${selectedPositionIds.size})`}
</Button>
<Button variant="secondary" onClick={() => window.open('https://tnsukz.sharepoint.com/sites/sed', '_blank')}>
Мои СТ
</Button>
@@ -227,6 +245,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
lockedProjectId={lockedProjectId}
onSelectionChange={handleSelectionChange}
onRequestSelectionChange={handleRequestSelectionChange}
onExpand={loadPositions}
loadingPositionIds={loadingPositionIds}
isReadOnly={isReadOnly}
/>
{!isReadOnly && totalPages > 1 && (
@@ -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<Position[]>;
/** Заявки, чьи позиции сейчас грузятся (для спиннера в строке). */
loadingPositionIds: Set<string>;
}
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<string | undefined>(undefined);
const [reloadToken, setReloadToken] = useState(0);
const [loadingPositionIds, setLoadingPositionIds] = useState<Set<string>>(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<Position[]> => {
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,
};
}
@@ -30,6 +30,9 @@ export interface Request {
workType: string;
organization: string;
createdAt: string;
// Кол-во позиций приходит в списке заявок (positionsCount) — для счётчика без загрузки самих
// позиций. Сами positions грузятся лениво при раскрытии заявки (см. loadRequestPositions).
positionsCount: number;
positions: Position[];
}
@@ -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<Position[]> {
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) => ({
return positions.map((p) => ({
...p,
organization: r.organization,
requestCreatedAt: r.createdAt,
organization: request.organization,
requestCreatedAt: request.createdAt,
}));
});
return requests;
}
private async _fetchPositionsForRequest(
@@ -268,14 +271,10 @@ export class CtService {
ctId: string,
positions: Position[],
): Promise<Position[]> {
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({
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,
@@ -287,15 +286,84 @@ export class CtService {
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) });
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<Record<string, unknown>>,
): Promise<number[]> {
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<T>(items: T[], size: number, worker: (item: T) => Promise<void>): Promise<void> {
for (let i = 0; i < items.length; i += size) {
await Promise.all(items.slice(i, i + size).map(worker));
}
return result;
}
// ─── Поставщики / Не получены КП ───────────────────────────────────────────
@@ -303,53 +371,59 @@ export class CtService {
async saveSuppliers(suppliers: Supplier[]): Promise<void> {
const withOffer = suppliers.filter((s) => s.hasOffer !== false);
const withoutOffer = suppliers.filter((s) => s.hasOffer === false);
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`,
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({
await this._createMissingByTitle(LISTS.SUPPLIERS, withOffer, (s) => ({
Title: s.name,
name: s.name,
status: s.status ?? "",
}),
},
);
if (!res.ok)
throw new Error(`Ошибка сохранения поставщика: ${res.status}`);
}));
await this._createMissingByTitle(LISTS.NO_OFFERS, withoutOffer, (s) => ({
Title: s.name,
}));
}
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`,
// Создаёт в справочнике только ОТСУТСТВУЮЩИХ по Title. Было: на каждого GET-проверка +
// POST по очереди (2×N последовательных запросов). Стало: один GET на все имена сразу +
// параллельное создание недостающих. Дедуп по имени (первое вхождение) — иначе одинаковые
// имена во входе создали бы дубли (проверка-то одна, до создания).
private async _createMissingByTitle(
listName: string,
items: Supplier[],
buildBody: (s: Supplier) => Record<string, unknown>,
): Promise<void> {
const byName = new Map<string, Supplier>();
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<string>();
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.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}`);
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);
}
}
}
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<void> {
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<number, true> = {};
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<string, unknown> = {
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,
{
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,13 +550,10 @@ export class CtService {
criteria: SupplierCriteria[],
ctSpId?: number,
): Promise<void> {
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({
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,
@@ -503,12 +562,8 @@ export class CtService {
sertificates: c.certificates,
comment: c.comment ?? "",
...(ctSpId ? { CTId: ctSpId } : {}),
}),
},
);
if (!res.ok)
throw new Error(`Ошибка сохранения критериев: ${res.status}`);
}
}));
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: [],
});
@@ -1139,10 +1139,15 @@ export class KpsCtFormService {
// точка для «после отправки» и «после каждого решения» (см. Step4Approval/InlineApprovalChain).
public async notifyActiveApprover(ctId: number): Promise<void> {
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,
@@ -1039,10 +1039,15 @@ export class OidService {
async notifyActiveApprover(oidId: number): Promise<void> {
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<void> {