fix(ct): повторное сохранение шага 1 обновляет позиции той же СТ, а не создаёт новую
Вернулись на шаг 1 и нажали «Сохранить и продолжить» — создавалась ещё одна СТ (№10 → №11) с теми же позициями. Предложения оставались в прежней СТ, а форма переключалась на новую, которая по ссылке открывалась без поставщиков. Теперь, если СТ уже создана, позиции обновляются: новые дописываются, снятые уходят в корзину вместе с их предложениями, проект в шапке меняется при смене проекта (planStep1Save). Заодно при возврате на шаг 1 прежний выбор виден и сохраняется: позиции нераскрытых потребностей берутся из формы, выбранные считаются по id — раньше галочки не стояли, «Только выбранные» был пуст, «Сохранить» неактивна.
This commit is contained in:
+3
-1
@@ -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;
|
||||
|
||||
+42
-15
@@ -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 ?? '';
|
||||
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) {
|
||||
|
||||
+85
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user