fix(ct): повторное сохранение шага 1 обновляет позиции той же СТ, а не создаёт новую

Вернулись на шаг 1 и нажали «Сохранить и продолжить» — создавалась ещё одна СТ (№10 → №11) с
теми же позициями. Предложения оставались в прежней СТ, а форма переключалась на новую,
которая по ссылке открывалась без поставщиков. Теперь, если СТ уже создана, позиции
обновляются: новые дописываются, снятые уходят в корзину вместе с их предложениями, проект
в шапке меняется при смене проекта (planStep1Save).

Заодно при возврате на шаг 1 прежний выбор виден и сохраняется: позиции нераскрытых
потребностей берутся из формы, выбранные считаются по id — раньше галочки не стояли,
«Только выбранные» был пуст, «Сохранить» неактивна.
This commit is contained in:
Тимур Городецкий
2026-10-01 15:11:39 +05:00
parent 6ef39f8fc9
commit efedfc7658
5 changed files with 222 additions and 16 deletions
@@ -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<Props> = ({
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;
@@ -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<Props> = ({
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<Props> = ({
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<Props> = ({
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 ?? '';
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) {
@@ -0,0 +1,85 @@
import { countSelectedInRequest, planStep1Save, resolveSelectedPositions } from './step1Plan';
import { Position } from '../types/domain';
function makePosition(id: string, overrides: Partial<Position> = {}): 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 },
]);
});
});
@@ -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<string>, 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);
}
@@ -248,6 +248,42 @@ export class CtService {
};
}
// Повторное сохранение шага 1 с позициями другого проекта — проект в шапке той же СТ.
async updateCTProject(ctId: string, projectName: string): Promise<void> {
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<void> {
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<void> => {
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,