From 61913ee79bc18369806a9d270a18d186fa988690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Wed, 21 Oct 2026 12:51:38 +0500 Subject: [PATCH] =?UTF-8?q?fix(ct):=20=D1=88=D0=B0=D0=B3=201=20=D0=B2?= =?UTF-8?q?=D0=B8=D0=B4=D0=B8=D1=82=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=A1=D0=A2,=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B8=D0=B7=20=D1=80=D0=B5=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Позиции СТ из SP не были связаны с потребностями (id = SP Id), поэтому на шаге 1 при «Выбрано: N» ни одна строка не была отмечена, «Только выбранные» показывал пустой список, а снять выбор было негде. - в «Позиции СТ» пишется requestPositionId (GUID потребности__номер), шаг 1 сопоставляет выбор по нему; id позиции остаётся SP Id, к нему привязаны предложения; - потребности, чьи позиции уже в СТ, показываются и вне выбранного периода (позиции догружаются сразу); - проект восстанавливается из шапки СТ — «Сохранить и продолжить» молча ничего не делала и снимался запрет на чужой проект. У СТ, созданных до колонки, связи нет — их позиции в таблице не видны. --- .../Step1PositionSelection.tsx | 33 +++++-- .../Step1PositionSelection/step1Plan.test.ts | 93 ++++++++++++++++++- .../Step1PositionSelection/step1Plan.ts | 64 +++++++++++-- .../forms/ct/components/hooks/useRequests.ts | 27 +++++- .../forms/ct/components/types/domain.ts | 3 + .../forms/ct/services/CtService.ts | 27 ++++-- 6 files changed, 217 insertions(+), 30 deletions(-) diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/Step1PositionSelection.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/Step1PositionSelection.tsx index c8a0a18..b52c8f3 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/Step1PositionSelection.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/Step1PositionSelection.tsx @@ -6,7 +6,14 @@ import { CtService } from '../../services/CtService'; import { CTFormData } from '../hooks/useCtForm'; import { useRequests } from '../hooks/useRequests'; import { RequestsTable } from './RequestsTable'; -import { countSelectedInRequest, planStep1Save, resolveSelectedPositions } from './step1Plan'; +import { + buildPinnedRequests, + countSelectedInRequest, + planStep1Save, + resolveProject, + resolveSelectedPositions, + selectionKey, +} from './step1Plan'; import styles from './Step1PositionSelection.module.scss'; interface Props { @@ -35,13 +42,21 @@ export const Step1PositionSelection: React.FC = ({ const [currentPage, setCurrentPage] = useState(1); const [showOnlySelected, setShowOnlySelected] = useState(false); const [selectedPositionIds, setSelectedPositionIds] = useState>( - new Set(formData.selectedPositions.map(p => p.id)) + new Set(formData.selectedPositions.map(selectionKey)) ); const [positionMap, setPositionMap] = useState>(new Map()); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(undefined); - const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } = useRequests(service, days, isReadOnly); + // Сохранённые позиции есть только у созданной СТ, поэтому проект для закреплённых потребностей — из её шапки. + const ctProjectName = formData.ct?.projectName; + const pinnedRequests = useMemo( + () => buildPinnedRequests(formData.selectedPositions, resolveProject(undefined, ctProjectName, true)), + [formData.selectedPositions, ctProjectName], + ); + + const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } = + useRequests(service, days, isReadOnly, pinnedRequests); const readOnlyRequests = useMemo(() => { if (!isReadOnly) return []; @@ -93,7 +108,8 @@ export const Step1PositionSelection: React.FC = ({ setPositionMap(map); }, [requests]); - const lockedProjectId = formData.selectedProject?.id; + const project = resolveProject(formData.selectedProject, ctProjectName, selectedPositionIds.size > 0); + const lockedProjectId = project?.id; const handleSelectionChange = useCallback( (positionId: string, _position: Position, request: Request, checked: boolean) => { @@ -166,7 +182,7 @@ export const Step1PositionSelection: React.FC = ({ ); const selectedProject = selectedPositions.length > 0 - ? positionMap.get(selectedPositions[0].id)?.request.project ?? formData.selectedProject ?? null + ? positionMap.get(selectedPositions[0].id)?.request.project ?? project ?? null : null; const canSave = selectedPositions.length > 0; @@ -197,7 +213,8 @@ export const Step1PositionSelection: React.FC = ({ if (ct.projectName !== selectedProject.name) { await service.updateCTProject(ct.id, selectedProject.name); } - const removedIds = new Set(plan.toRemove.map(p => p.id)); + // Предложения привязаны к позиции либо по id из сессии, либо по SP Id (загружены из SP). + const removedIds = new Set(plan.toRemove.flatMap(p => [p.id, String(p.spId)])); onSave({ ct: { ...ct, projectName: selectedProject.name }, selectedProject, @@ -250,9 +267,9 @@ export const Step1PositionSelection: React.FC = ({ - {formData.selectedProject && ( + {project && ( - Выбран проект: {formData.selectedProject.name} — позиции из других проектов недоступны + Выбран проект: {project.name} — позиции из других проектов недоступны )} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts index 91b6388..90cbead 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts @@ -1,4 +1,11 @@ -import { countSelectedInRequest, planStep1Save, resolveSelectedPositions } from './step1Plan'; +import { + buildPinnedRequests, + countSelectedInRequest, + planStep1Save, + resolveProject, + resolveSelectedPositions, + selectionKey, +} from './step1Plan'; import { Position } from '../types/domain'; function makePosition(id: string, overrides: Partial = {}): Position { @@ -83,3 +90,87 @@ describe('resolveSelectedPositions', () => { ]); }); }); + +describe('СТ открыта из реестра — позиции из SP связаны с потребностью через requestPositionId', () => { + // СТ №8: позиции пришли из SP (id = SP Id). Таблица шага 1 их не находила — «Выбрано: 3», + // а увидеть и снять выбор было негде. + const fromSp = [ + makePosition('1543', { spId: 1543, requestPositionId: 'req1__0', requestId: 'req1' }), + makePosition('1544', { spId: 1544, requestPositionId: 'req1__2', requestId: 'req1' }), + ]; + + it('ключ выбора — позиция потребности, а не SP Id', () => { + expect(fromSp.map(selectionKey)).toEqual(['req1__0', 'req1__2']); + expect(countSelectedInRequest(new Set(fromSp.map(selectionKey)), 'req1')).toBe(2); + }); + + it('старая СТ без связи — ключ остаётся SP Id', () => { + expect(selectionKey(makePosition('1600', { spId: 1600, requestId: '' }))).toBe('1600'); + }); + + it('повторное сохранение: та же позиция из таблицы не создаётся заново, снятая — удаляется', () => { + const plan = planStep1Save(fromSp, [makePosition('req1__0'), makePosition('req1__5')]); + + expect(plan.toKeep.map(p => p.spId)).toEqual([1543]); + expect(plan.toCreate.map(p => p.id)).toEqual(['req1__5']); + expect(plan.toRemove.map(p => p.spId)).toEqual([1544]); + }); + + it('нераскрытая потребность — выбранная позиция берётся из сохранённых по ключу', () => { + const result = resolveSelectedPositions(['req1__2'], () => undefined, fromSp); + + expect(result.map(p => p.spId)).toEqual([1544]); + }); +}); + +describe('buildPinnedRequests', () => { + // Потребность старше выбранного периода не приходит в списке — закрепляем её, раз её позиции в СТ. + const project = { id: 'Проект X', name: 'Проект X', code: '' }; + + it('по одной потребности на каждую, из которой есть позиции в СТ', () => { + const saved = [ + makePosition('1', { requestId: 'req1', requestNumber: 'A' }), + makePosition('2', { requestId: 'req2', requestNumber: 'B' }), + makePosition('3', { requestId: 'req1', requestNumber: 'A' }), + ]; + + expect(buildPinnedRequests(saved, project).map(r => [r.id, r.requestNumber])).toEqual([ + ['req1', 'A'], + ['req2', 'B'], + ]); + }); + + it('позиции без связи с потребностью (старые СТ) — пропускаем', () => { + expect(buildPinnedRequests([makePosition('1600', { requestId: '' })], project)).toEqual([]); + }); + + it('проект неизвестен — не закрепляем', () => { + expect(buildPinnedRequests([makePosition('req1__0')], undefined)).toEqual([]); + }); +}); + +describe('resolveProject', () => { + // Из реестра selectedProject не восстанавливался → «Сохранить и продолжить» молча ничего не делала + // и пропадал запрет выбирать позиции чужого проекта. + const project = { id: 'Проект X', name: 'Проект X', code: '' }; + + it('берёт выбранный проект, если он есть', () => { + expect(resolveProject(project, 'Другой', true)).toBe(project); + }); + + it('без выбранного проекта, но с выбором — проект из шапки СТ (id проекта = его название)', () => { + expect(resolveProject(undefined, 'Выпуск геомембран', true)).toEqual({ + id: 'Выпуск геомембран', + name: 'Выпуск геомембран', + code: '', + }); + }); + + it('выбор снят полностью — проект не навязываем, можно выбрать другой', () => { + expect(resolveProject(undefined, 'Выпуск геомембран', false)).toBeUndefined(); + }); + + it('СТ ещё не создана — проекта нет', () => { + expect(resolveProject(undefined, undefined, true)).toBeUndefined(); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts index 71c81df..b6b0767 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts @@ -1,8 +1,15 @@ -import { Position } from '../types/domain'; +import { Position, Project, Request } from '../types/domain'; // id позиции заявки — `${id потребности}__${номер}` (см. CtService._mapPosition). const POSITION_ID_SEPARATOR = '__'; +// Ключ выбора на шаге 1 — id позиции потребности. У позиции, загруженной из SP (СТ открыта из реестра), +// id — это SP Id, а связь с потребностью лежит в requestPositionId. У СТ, созданных до появления +// этого поля, связи нет — тогда ключ SP Id, и в таблице потребностей такая позиция не найдётся. +export function selectionKey(position: Position): string { + return position.requestPositionId || position.id; +} + // Повторное сохранение шага 1, когда СТ уже создана: что оставить (позиции уже в SP), что дописать, // что убрать. Раньше шаг 1 всегда создавал новую СТ — предложения оставались в прежней, а новая // открывалась по ссылке пустой. @@ -13,13 +20,13 @@ export interface Step1SavePlan { } export function planStep1Save(saved: Position[], selected: Position[]): Step1SavePlan { - const selectedIds = new Set(selected.map(p => p.id)); - const savedById = new Map(saved.map(p => [p.id, p])); + const selectedKeys = new Set(selected.map(selectionKey)); + const savedByKey = new Map(saved.map(p => [selectionKey(p), p])); const toKeep: Position[] = []; const toCreate: Position[] = []; selected.forEach(p => { - const savedPosition = savedById.get(p.id); + const savedPosition = savedByKey.get(selectionKey(p)); if (savedPosition?.spId !== undefined) { toKeep.push(savedPosition); } else { @@ -30,7 +37,7 @@ export function planStep1Save(saved: Position[], selected: Position[]): Step1Sav return { toKeep, toCreate, - toRemove: saved.filter(p => p.spId !== undefined && !selectedIds.has(p.id)), + toRemove: saved.filter(p => p.spId !== undefined && !selectedKeys.has(selectionKey(p))), }; } @@ -45,12 +52,49 @@ export function countSelectedInRequest(selectedIds: Set, requestId: stri // Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме. // Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна. export function resolveSelectedPositions( - selectedIds: string[], - findLoaded: (id: string) => Position | undefined, + selectedKeys: string[], + findLoaded: (key: string) => Position | undefined, saved: Position[], ): Position[] { - const savedById = new Map(saved.map(p => [p.id, p])); - return selectedIds - .map(id => findLoaded(id) ?? savedById.get(id)) + const savedByKey = new Map(saved.map(p => [selectionKey(p), p])); + return selectedKeys + .map(key => findLoaded(key) ?? savedByKey.get(key)) .filter((p): p is Position => p !== undefined); } + +// Потребности, позиции которых уже в СТ. Их надо показать в таблице, даже если потребность старше +// выбранного периода, — иначе «Выбрано: 3», а увидеть и снять выбор негде. Шапку собираем из позиций +// (организацию и дату создания в позициях СТ не храним), сами позиции потребности догружает useRequests. +export function buildPinnedRequests(saved: Position[], project: Project | undefined): Request[] { + if (!project) return []; + const byRequestId = new Map(); + saved.forEach(p => { + if (!p.requestId || byRequestId.has(p.requestId)) return; + byRequestId.set(p.requestId, { + id: p.requestId, + requestNumber: p.requestNumber, + project, + discipline: p.disciplineCode, + workType: p.smrType, + organization: p.organization ?? '', + createdAt: p.requestCreatedAt ?? '', + positionsCount: 0, + positions: [], + }); + }); + return Array.from(byRequestId.values()); +} + +// Проект СТ. Открыли СТ из реестра — selectedProject в форме нет, берём из шапки СТ (у потребностей +// id проекта = его название, см. CtService._mapRequest). Без этого «Сохранить и продолжить» молча +// ничего не делала и снимался запрет на позиции чужого проекта. Если выбор снят целиком — проект не +// навязываем, чтобы можно было собрать СТ по другому проекту. +export function resolveProject( + selectedProject: Project | undefined, + ctProjectName: string | undefined, + hasSelection: boolean, +): Project | undefined { + if (selectedProject) return selectedProject; + if (!hasSelection || !ctProjectName) return undefined; + return { id: ctProjectName, name: ctProjectName, code: '' }; +} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/hooks/useRequests.ts b/src/webparts/ctFormsPortal/forms/ct/components/hooks/useRequests.ts index 61eb09d..51dd1ea 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/hooks/useRequests.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/hooks/useRequests.ts @@ -14,7 +14,25 @@ interface UseRequestsResult { loadingPositionIds: Set; } -export function useRequests(service: CtService, days: number, skip?: boolean): UseRequestsResult { +// Потребности, позиции которых уже в СТ, но которые не пришли за выбранный период: показываем +// их в таблице и сразу грузим позиции — без них неизвестно реальное число позиций потребности. +async function loadMissingPinned(service: CtService, pinned: Request[], loaded: Request[]): Promise { + const loadedIds = new Set(loaded.map(r => r.id)); + const missing = pinned.filter(r => !loadedIds.has(r.id)); + return Promise.all( + missing.map(async request => { + const positions = await service.loadRequestPositions(request); + return { ...request, positions, positionsCount: positions.length }; + }), + ); +} + +export function useRequests( + service: CtService, + days: number, + skip: boolean, + pinnedRequests: Request[], +): UseRequestsResult { const [requests, setRequests] = useState([]); const [isLoading, setIsLoading] = useState(!skip); const [error, setError] = useState(undefined); @@ -34,9 +52,10 @@ export function useRequests(service: CtService, days: number, skip?: boolean): U service .fetchRequests(days) - .then(data => { + .then(async data => { + const pinned = await loadMissingPinned(service, pinnedRequests, data); if (!cancelled) { - setRequests(data); + setRequests([...data, ...pinned]); setIsLoading(false); } }) @@ -50,7 +69,7 @@ export function useRequests(service: CtService, days: number, skip?: boolean): U return () => { cancelled = true; }; - }, [service, days, reloadToken, skip]); + }, [service, days, reloadToken, skip, pinnedRequests]); const loadPositions = useCallback( async (requestId: string): Promise => { diff --git a/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts b/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts index 878f284..932a51b 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts @@ -7,6 +7,9 @@ export interface Project { export interface Position { id: string; spId?: number; // SP item ID in Позиции СТ (populated after createCTPositions) + // Позиция потребности, из которой взята позиция СТ: `${GUID потребности}__${номер}` — id позиции + // в таблице шага 1. Хранится в SP-поле 'requestPositionId'; у СТ, созданных до его появления, пусто. + requestPositionId?: string; requestId: string; // GUID потребности requestNumber: string; // demandNumber — номер потребности (e.g. "081/26-04/0017") materialName: string; // position — наименование позиции diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index df8af50..2be103c 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -342,9 +342,14 @@ export class CtService { if (positions.length === 0) return []; // Раньше — по одному POST на позицию в цикле await (последовательно): 100 позиций = // 100 запросов и ~30-60 сек + троттлинг 429. Теперь одним $batch (до 100 операций/запрос). + // Связь с позицией потребности нужна шагу 1, когда СТ открыта из реестра (см. Position.requestPositionId). + // У доп. позиций шага 3 потребности нет — requestId пустой, связь не пишем. + const requestPositionIdOf = (pos: Position): string => + pos.requestPositionId || (pos.requestId ? pos.id : ""); const bodies = positions.map((pos) => ({ Title: pos.materialName, CTId: parseInt(ctId, 10), + requestPositionId: requestPositionIdOf(pos), demandNumber: pos.requestNumber, position: pos.materialName, disciplineCode: pos.disciplineCode ?? "", @@ -357,7 +362,11 @@ export class CtService { 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] })); + return positions.map((pos, i) => ({ + ...pos, + spId: ids[i], + requestPositionId: requestPositionIdOf(pos) || undefined, + })); } // Пакетное создание элементов списка через SharePoint OData $batch: вместо N POST — @@ -798,17 +807,21 @@ export class CtService { async loadPositionsForCT(ctId: string): Promise { const url = `${this._li(LISTS.CT_POSITIONS)}` + - `?$select=Id,Title,unit,quantity,positionType,demandNumber,disciplineCode,SMR,agskCode,uniqueCode,deadline` + + `?$select=Id,Title,unit,quantity,positionType,requestPositionId,demandNumber,disciplineCode,SMR,agskCode,uniqueCode,deadline` + `&$filter=CTId eq ${parseInt(ctId, 10)}` + `&$top=500`; const res = await this.http.get(url, SPHttpClient.configurations.v1); if (!res.ok) return []; const data = await res.json(); - return (data.value ?? []).map( - (item: Record): Position => ({ + return (data.value ?? []).map((item: Record): Position => { + // `${GUID потребности}__${номер}`; у СТ, созданных до появления поля, пусто. + const requestPositionId = String(item.requestPositionId ?? ""); + return { + // id остаётся SP Id: по нему к позиции привязаны предложения из SP (см. utils/spOffers). id: String(item["Id"]), spId: Number(item["Id"]), - requestId: "", + requestPositionId: requestPositionId || undefined, + requestId: requestPositionId.split("__")[0], requestNumber: String(item["demandNumber"] ?? ""), materialName: String(item["Title"] ?? ""), disciplineCode: String(item["disciplineCode"] ?? ""), @@ -819,8 +832,8 @@ export class CtService { quantity: Number(item["quantity"] ?? 0), positionType: String(item["positionType"] ?? "ТМЦ"), deadline: String(item["deadline"] ?? ""), - }), - ); + }; + }); } async loadFullOffersForPositions(positionSpIds: number[]): Promise<