fix: финальные оповещения (oid/kps) + ленивая загрузка потребностей
This commit is contained in:
+22
-4
@@ -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);
|
||||
|
||||
+28
-8
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user