From efedfc765868f86a155005971e3414962f6c01c9 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: Thu, 1 Oct 2026 15:11:39 +0500 Subject: [PATCH] =?UTF-8?q?fix(ct):=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=88=D0=B0=D0=B3=D0=B0=201=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B7=D0=B8=D1=86=D0=B8=D0=B8=20=D1=82=D0=BE=D0=B9=20=D0=B6?= =?UTF-8?q?=D0=B5=20=D0=A1=D0=A2,=20=D0=B0=20=D0=BD=D0=B5=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D1=91=D1=82=20=D0=BD=D0=BE=D0=B2=D1=83=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вернулись на шаг 1 и нажали «Сохранить и продолжить» — создавалась ещё одна СТ (№10 → №11) с теми же позициями. Предложения оставались в прежней СТ, а форма переключалась на новую, которая по ссылке открывалась без поставщиков. Теперь, если СТ уже создана, позиции обновляются: новые дописываются, снятые уходят в корзину вместе с их предложениями, проект в шапке меняется при смене проекта (planStep1Save). Заодно при возврате на шаг 1 прежний выбор виден и сохраняется: позиции нераскрытых потребностей берутся из формы, выбранные считаются по id — раньше галочки не стояли, «Только выбранные» был пуст, «Сохранить» неактивна. --- .../Step1PositionSelection/RequestsTable.tsx | 4 +- .../Step1PositionSelection.tsx | 57 +++++++++---- .../Step1PositionSelection/step1Plan.test.ts | 85 +++++++++++++++++++ .../Step1PositionSelection/step1Plan.ts | 56 ++++++++++++ .../forms/ct/services/CtService.ts | 36 ++++++++ 5 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/RequestsTable.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/RequestsTable.tsx index 4c02cf9..d71e4ef 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/RequestsTable.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/RequestsTable.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { useState, useCallback } from 'react'; import { Checkbox } from '@tnsu/ui-kit'; import { Request, Position } from '../types/domain'; +import { countSelectedInRequest } from './step1Plan'; import styles from './RequestsTable.module.scss'; interface Props { @@ -45,8 +46,9 @@ export const RequestsTable: React.FC = ({ if (willExpand && onExpand) onExpand(id); }, [expandedRequestIds, onExpand]); + // По id позиций, а не по загруженным: позиции нераскрытой потребности ещё не загружены. const getRequestSelectionCount = (request: Request): number => - request.positions.filter(p => selectedPositionIds.has(p.id)).length; + countSelectedInRequest(selectedPositionIds, request.id); const isProjectLocked = (request: Request): boolean => lockedProjectId !== undefined && request.project.id !== lockedProjectId; diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/Step1PositionSelection.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/Step1PositionSelection.tsx index d6107d5..c8a0a18 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/Step1PositionSelection.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/Step1PositionSelection.tsx @@ -6,6 +6,7 @@ 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 styles from './Step1PositionSelection.module.scss'; interface Props { @@ -68,14 +69,13 @@ export const Step1PositionSelection: React.FC = ({ let filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests; // Режим «Только выбранные» — оставляем заявки, где есть хотя бы одна выбранная позиция, // чтобы можно было свериться со своим выбором с любой страницы, не листая назад. + // Выбор считаем по id позиций, а не по загруженным позициям: они грузятся только при раскрытии, + // и после возврата на шаг 1 «Только выбранные» иначе показывал пустой список. + const hasSelected = (r: Request): boolean => countSelectedInRequest(selectedPositionIds, r.id) > 0; if (showOnlySelected) { - filtered = filtered.filter(r => r.positions.some(p => selectedPositionIds.has(p.id))); + filtered = filtered.filter(hasSelected); } - 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; - }); + return [...filtered].sort((a, b) => (hasSelected(a) ? 0 : 1) - (hasSelected(b) ? 0 : 1)); }, [requests, searchQuery, selectedPositionIds, showOnlySelected]); const totalPages = Math.max(1, Math.ceil(filteredRequests.length / PAGE_SIZE)); @@ -157,12 +157,16 @@ export const Step1PositionSelection: React.FC = ({ setCurrentPage(1); }; - const selectedPositions: Position[] = Array.from(selectedPositionIds) - .map(id => positionMap.get(id)?.position) - .filter((p): p is Position => p !== undefined); + // Позиции нераскрытых потребностей берём из сохранённых в форме — иначе после возврата на шаг 1 + // выбор есть, а «Сохранить» неактивна. + const selectedPositions: Position[] = resolveSelectedPositions( + Array.from(selectedPositionIds), + id => positionMap.get(id)?.position, + formData.selectedPositions, + ); const selectedProject = selectedPositions.length > 0 - ? positionMap.get(selectedPositions[0].id)?.request.project ?? null + ? positionMap.get(selectedPositions[0].id)?.request.project ?? formData.selectedProject ?? null : null; const canSave = selectedPositions.length > 0; @@ -174,16 +178,39 @@ export const Step1PositionSelection: React.FC = ({ setSaveError(undefined); try { - const ctNumber = await service.generateCtNumber(selectedProject.name); - const ct = await service.createCT(selectedProject.name, ctNumber); const enrichedPositions = selectedPositions.map(p => ({ ...p, disciplineCode: p.disciplineCode || (positionMap.get(p.id)?.request.discipline ?? ''), })); - const savedPositions = await service.createCTPositions(ct.id, enrichedPositions); + const initiatorCompany = positionMap.get(selectedPositions[0].id)?.request.organization + ?? formData.initiatorCompany ?? ''; - const initiatorCompany = positionMap.get(selectedPositions[0].id)?.request.organization ?? ''; - onSave({ ct, selectedProject, selectedPositions: savedPositions, initiatorCompany }); + if (formData.ct) { + // СТ уже создана (вернулись на шаг 1) — обновляем её позиции, а не создаём новую СТ: + // иначе предложения остаются в прежней СТ, а новая по ссылке открывается пустой. + const ct = formData.ct; + const plan = planStep1Save(formData.selectedPositions, enrichedPositions); + await service.removeCTPositions( + plan.toRemove.map(p => p.spId).filter((id): id is number => id !== undefined), + ); + const created = plan.toCreate.length > 0 ? await service.createCTPositions(ct.id, plan.toCreate) : []; + if (ct.projectName !== selectedProject.name) { + await service.updateCTProject(ct.id, selectedProject.name); + } + const removedIds = new Set(plan.toRemove.map(p => p.id)); + onSave({ + ct: { ...ct, projectName: selectedProject.name }, + selectedProject, + selectedPositions: [...plan.toKeep, ...created], + initiatorCompany, + offers: formData.offers.filter(o => !removedIds.has(o.positionId)), + }); + } else { + const ctNumber = await service.generateCtNumber(selectedProject.name); + const ct = await service.createCT(selectedProject.name, ctNumber); + const savedPositions = await service.createCTPositions(ct.id, enrichedPositions); + onSave({ ct, selectedProject, selectedPositions: savedPositions, initiatorCompany }); + } onStepComplete(1); onNext(); } catch (err) { diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts new file mode 100644 index 0000000..91b6388 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts @@ -0,0 +1,85 @@ +import { countSelectedInRequest, planStep1Save, resolveSelectedPositions } from './step1Plan'; +import { Position } from '../types/domain'; + +function makePosition(id: string, overrides: Partial = {}): Position { + return { + id, + requestId: id.split('__')[0], + requestNumber: '051/26-08/0015', + materialName: `Позиция ${id}`, + disciplineCode: '', + smrType: '', + unit: 'шт', + agskCode: '', + uniqueCode: '', + quantity: 1, + positionType: 'ТМЦ', + deadline: '', + ...overrides, + }; +} + +describe('planStep1Save (СТ уже создана — повторное сохранение шага 1)', () => { + // СТ №10 → вернулись на шаг 1 → «Сохранить» создавало СТ №11 без предложений. + // Теперь позиции существующей СТ обновляются: оставленные — как есть, новые — дописать, снятые — убрать. + const saved = [ + makePosition('req1__0', { spId: 1598 }), + makePosition('req1__1', { spId: 1599 }), + makePosition('req1__2', { spId: 1600 }), + ]; + + it('тот же выбор — ничего не создаём и не удаляем', () => { + const plan = planStep1Save(saved, saved.map(p => ({ ...p, spId: undefined }))); + + expect(plan.toCreate).toEqual([]); + expect(plan.toRemove).toEqual([]); + expect(plan.toKeep.map(p => p.spId)).toEqual([1598, 1599, 1600]); + }); + + it('добавленная позиция — создать, снятая — убрать', () => { + const selected = [makePosition('req1__0'), makePosition('req1__2'), makePosition('req1__3')]; + + const plan = planStep1Save(saved, selected); + + expect(plan.toKeep.map(p => p.spId)).toEqual([1598, 1600]); + expect(plan.toCreate.map(p => p.id)).toEqual(['req1__3']); + expect(plan.toRemove.map(p => p.spId)).toEqual([1599]); + }); + + it('у оставленной позиции берём сохранённую версию (с SP Id)', () => { + const plan = planStep1Save(saved, [makePosition('req1__1')]); + + expect(plan.toKeep).toEqual([saved[1]]); + }); +}); + +describe('countSelectedInRequest', () => { + // Позиции потребности грузятся только при раскрытии — считаем выбранные по id «потребность__номер». + it('считает выбранные позиции потребности без загрузки её позиций', () => { + const selected = new Set(['req1__0', 'req1__4', 'req2__0']); + + expect(countSelectedInRequest(selected, 'req1')).toBe(2); + expect(countSelectedInRequest(selected, 'req2')).toBe(1); + expect(countSelectedInRequest(selected, 'req3')).toBe(0); + }); + + it('не путает потребность с похожим id', () => { + expect(countSelectedInRequest(new Set(['req10__0']), 'req1')).toBe(0); + }); +}); + +describe('resolveSelectedPositions', () => { + // Вернулись на шаг 1: потребность не раскрыта — выбранные позиции берём из сохранённых, + // иначе «Сохранить» неактивна, хотя выбор есть. + it('берёт загруженную позицию, а если её нет — сохранённую', () => { + const loaded = new Map([['req1__1', makePosition('req1__1', { quantity: 7 })]]); + const saved = [makePosition('req1__0', { spId: 1598 }), makePosition('req1__1', { spId: 1599 })]; + + const result = resolveSelectedPositions(['req1__0', 'req1__1', 'req9__0'], id => loaded.get(id), saved); + + expect(result.map(p => ({ id: p.id, quantity: p.quantity }))).toEqual([ + { id: 'req1__0', quantity: 1 }, + { id: 'req1__1', quantity: 7 }, + ]); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts new file mode 100644 index 0000000..71c81df --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts @@ -0,0 +1,56 @@ +import { Position } from '../types/domain'; + +// id позиции заявки — `${id потребности}__${номер}` (см. CtService._mapPosition). +const POSITION_ID_SEPARATOR = '__'; + +// Повторное сохранение шага 1, когда СТ уже создана: что оставить (позиции уже в SP), что дописать, +// что убрать. Раньше шаг 1 всегда создавал новую СТ — предложения оставались в прежней, а новая +// открывалась по ссылке пустой. +export interface Step1SavePlan { + toKeep: Position[]; // сохранённые версии (с SP Id) + toCreate: Position[]; + toRemove: Position[]; +} + +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 toKeep: Position[] = []; + const toCreate: Position[] = []; + selected.forEach(p => { + const savedPosition = savedById.get(p.id); + if (savedPosition?.spId !== undefined) { + toKeep.push(savedPosition); + } else { + toCreate.push(p); + } + }); + + return { + toKeep, + toCreate, + toRemove: saved.filter(p => p.spId !== undefined && !selectedIds.has(p.id)), + }; +} + +// Сколько позиций потребности выбрано — по id, без загрузки её позиций (они грузятся при раскрытии). +export function countSelectedInRequest(selectedIds: Set, requestId: string): number { + const prefix = `${requestId}${POSITION_ID_SEPARATOR}`; + let count = 0; + selectedIds.forEach(id => { if (id.startsWith(prefix)) count++; }); + return count; +} + +// Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме. +// Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна. +export function resolveSelectedPositions( + selectedIds: string[], + findLoaded: (id: 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)) + .filter((p): p is Position => p !== undefined); +} diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index 74d8a69..fd11a3d 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -248,6 +248,42 @@ export class CtService { }; } + // Повторное сохранение шага 1 с позициями другого проекта — проект в шапке той же СТ. + async updateCTProject(ctId: string, projectName: string): Promise { + const res = await this.http.fetch(this._liById(LISTS.CT, ctId), SPHttpClient.configurations.v1, { + method: "POST", + headers: { + "Content-Type": "application/json;odata=nometadata", + "IF-MATCH": "*", + "X-HTTP-Method": "MERGE", + }, + body: JSON.stringify({ project: projectName }), + }); + if (!res.ok) throw new Error(`Ошибка обновления проекта СТ: ${res.status}`); + } + + // Позиции, снятые при повторном сохранении шага 1: их предложения и сами позиции — в корзину. + // Сначала предложения: без позиции они бы остались висеть в «Оценка поставщиков». + async removeCTPositions(positionSpIds: number[]): Promise { + if (positionSpIds.length === 0) return; + const filter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or "); + const res = await this.http.get( + `${this._li(LISTS.OFFERS)}?$select=Id&$filter=${filter}&$top=5000`, + SPHttpClient.configurations.v1, + ); + if (!res.ok) throw new Error(`Ошибка чтения предложений позиций: ${res.status}`); + const data = await res.json(); + const offerIds = ((data.value ?? []) as Array<{ Id: number }>).map((item) => item.Id); + // MERGE/DELETE на списке предложений блокирует ресивер — убираем через recycle(), как в saveOffers. + const recycle = (listName: string) => async (id: number): Promise => { + await this.http + .post(`${this._liById(listName, id)}/recycle()`, SPHttpClient.configurations.v1, {}) + .catch(() => undefined /* уже в корзине / не найден — игнорируем */); + }; + await this._runChunked(offerIds, 20, recycle(LISTS.OFFERS)); + await this._runChunked(positionSpIds, 20, recycle(LISTS.CT_POSITIONS)); + } + async updateCTStatus( ctId: string, status: CTStatus,