fix(ct): шаг 1 видит позиции СТ, открытой из реестра

Позиции СТ из SP не были связаны с потребностями (id = SP Id), поэтому
на шаге 1 при «Выбрано: N» ни одна строка не была отмечена, «Только
выбранные» показывал пустой список, а снять выбор было негде.

- в «Позиции СТ» пишется requestPositionId (GUID потребности__номер),
  шаг 1 сопоставляет выбор по нему; id позиции остаётся SP Id, к нему
  привязаны предложения;
- потребности, чьи позиции уже в СТ, показываются и вне выбранного
  периода (позиции догружаются сразу);
- проект восстанавливается из шапки СТ — «Сохранить и продолжить»
  молча ничего не делала и снимался запрет на чужой проект.

У СТ, созданных до колонки, связи нет — их позиции в таблице не видны.
This commit is contained in:
Тимур Городецкий
2026-10-21 12:51:38 +05:00
parent e40fc402d9
commit 61913ee79b
6 changed files with 217 additions and 30 deletions
@@ -6,7 +6,14 @@ import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
import { useRequests } from '../hooks/useRequests'; import { useRequests } from '../hooks/useRequests';
import { RequestsTable } from './RequestsTable'; 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'; import styles from './Step1PositionSelection.module.scss';
interface Props { interface Props {
@@ -35,13 +42,21 @@ export const Step1PositionSelection: React.FC<Props> = ({
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [showOnlySelected, setShowOnlySelected] = useState(false); const [showOnlySelected, setShowOnlySelected] = useState(false);
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>( const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
new Set(formData.selectedPositions.map(p => p.id)) new Set(formData.selectedPositions.map(selectionKey))
); );
const [positionMap, setPositionMap] = useState<Map<string, { position: Position; request: Request }>>(new Map()); const [positionMap, setPositionMap] = useState<Map<string, { position: Position; request: Request }>>(new Map());
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined); const [saveError, setSaveError] = useState<string | undefined>(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(() => { const readOnlyRequests = useMemo(() => {
if (!isReadOnly) return []; if (!isReadOnly) return [];
@@ -93,7 +108,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
setPositionMap(map); setPositionMap(map);
}, [requests]); }, [requests]);
const lockedProjectId = formData.selectedProject?.id; const project = resolveProject(formData.selectedProject, ctProjectName, selectedPositionIds.size > 0);
const lockedProjectId = project?.id;
const handleSelectionChange = useCallback( const handleSelectionChange = useCallback(
(positionId: string, _position: Position, request: Request, checked: boolean) => { (positionId: string, _position: Position, request: Request, checked: boolean) => {
@@ -166,7 +182,7 @@ export const Step1PositionSelection: React.FC<Props> = ({
); );
const selectedProject = selectedPositions.length > 0 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; : null;
const canSave = selectedPositions.length > 0; const canSave = selectedPositions.length > 0;
@@ -197,7 +213,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
if (ct.projectName !== selectedProject.name) { if (ct.projectName !== selectedProject.name) {
await service.updateCTProject(ct.id, 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({ onSave({
ct: { ...ct, projectName: selectedProject.name }, ct: { ...ct, projectName: selectedProject.name },
selectedProject, selectedProject,
@@ -250,9 +267,9 @@ export const Step1PositionSelection: React.FC<Props> = ({
</Button> </Button>
</div> </div>
{formData.selectedProject && ( {project && (
<Alert variant="info"> <Alert variant="info">
Выбран проект: <strong>{formData.selectedProject.name}</strong> — позиции из других проектов недоступны Выбран проект: <strong>{project.name}</strong> — позиции из других проектов недоступны
</Alert> </Alert>
)} )}
</div> </div>
@@ -1,4 +1,11 @@
import { countSelectedInRequest, planStep1Save, resolveSelectedPositions } from './step1Plan'; import {
buildPinnedRequests,
countSelectedInRequest,
planStep1Save,
resolveProject,
resolveSelectedPositions,
selectionKey,
} from './step1Plan';
import { Position } from '../types/domain'; import { Position } from '../types/domain';
function makePosition(id: string, overrides: Partial<Position> = {}): Position { function makePosition(id: string, overrides: Partial<Position> = {}): 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();
});
});
@@ -1,8 +1,15 @@
import { Position } from '../types/domain'; import { Position, Project, Request } from '../types/domain';
// id позиции заявки — `${id потребности}__${номер}` (см. CtService._mapPosition). // id позиции заявки — `${id потребности}__${номер}` (см. CtService._mapPosition).
const POSITION_ID_SEPARATOR = '__'; 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, когда СТ уже создана: что оставить (позиции уже в SP), что дописать,
// что убрать. Раньше шаг 1 всегда создавал новую СТ — предложения оставались в прежней, а новая // что убрать. Раньше шаг 1 всегда создавал новую СТ — предложения оставались в прежней, а новая
// открывалась по ссылке пустой. // открывалась по ссылке пустой.
@@ -13,13 +20,13 @@ export interface Step1SavePlan {
} }
export function planStep1Save(saved: Position[], selected: Position[]): Step1SavePlan { export function planStep1Save(saved: Position[], selected: Position[]): Step1SavePlan {
const selectedIds = new Set(selected.map(p => p.id)); const selectedKeys = new Set(selected.map(selectionKey));
const savedById = new Map(saved.map(p => [p.id, p])); const savedByKey = new Map(saved.map(p => [selectionKey(p), p]));
const toKeep: Position[] = []; const toKeep: Position[] = [];
const toCreate: Position[] = []; const toCreate: Position[] = [];
selected.forEach(p => { selected.forEach(p => {
const savedPosition = savedById.get(p.id); const savedPosition = savedByKey.get(selectionKey(p));
if (savedPosition?.spId !== undefined) { if (savedPosition?.spId !== undefined) {
toKeep.push(savedPosition); toKeep.push(savedPosition);
} else { } else {
@@ -30,7 +37,7 @@ export function planStep1Save(saved: Position[], selected: Position[]): Step1Sav
return { return {
toKeep, toKeep,
toCreate, 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<string>, requestId: stri
// Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме. // Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме.
// Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна. // Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна.
export function resolveSelectedPositions( export function resolveSelectedPositions(
selectedIds: string[], selectedKeys: string[],
findLoaded: (id: string) => Position | undefined, findLoaded: (key: string) => Position | undefined,
saved: Position[], saved: Position[],
): Position[] { ): Position[] {
const savedById = new Map(saved.map(p => [p.id, p])); const savedByKey = new Map(saved.map(p => [selectionKey(p), p]));
return selectedIds return selectedKeys
.map(id => findLoaded(id) ?? savedById.get(id)) .map(key => findLoaded(key) ?? savedByKey.get(key))
.filter((p): p is Position => p !== undefined); .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<string, Request>();
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: '' };
}
@@ -14,7 +14,25 @@ interface UseRequestsResult {
loadingPositionIds: Set<string>; loadingPositionIds: Set<string>;
} }
export function useRequests(service: CtService, days: number, skip?: boolean): UseRequestsResult { // Потребности, позиции которых уже в СТ, но которые не пришли за выбранный период: показываем
// их в таблице и сразу грузим позиции — без них неизвестно реальное число позиций потребности.
async function loadMissingPinned(service: CtService, pinned: Request[], loaded: Request[]): Promise<Request[]> {
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<Request[]>([]); const [requests, setRequests] = useState<Request[]>([]);
const [isLoading, setIsLoading] = useState(!skip); const [isLoading, setIsLoading] = useState(!skip);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
@@ -34,9 +52,10 @@ export function useRequests(service: CtService, days: number, skip?: boolean): U
service service
.fetchRequests(days) .fetchRequests(days)
.then(data => { .then(async data => {
const pinned = await loadMissingPinned(service, pinnedRequests, data);
if (!cancelled) { if (!cancelled) {
setRequests(data); setRequests([...data, ...pinned]);
setIsLoading(false); setIsLoading(false);
} }
}) })
@@ -50,7 +69,7 @@ export function useRequests(service: CtService, days: number, skip?: boolean): U
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [service, days, reloadToken, skip]); }, [service, days, reloadToken, skip, pinnedRequests]);
const loadPositions = useCallback( const loadPositions = useCallback(
async (requestId: string): Promise<Position[]> => { async (requestId: string): Promise<Position[]> => {
@@ -7,6 +7,9 @@ export interface Project {
export interface Position { export interface Position {
id: string; id: string;
spId?: number; // SP item ID in Позиции СТ (populated after createCTPositions) spId?: number; // SP item ID in Позиции СТ (populated after createCTPositions)
// Позиция потребности, из которой взята позиция СТ: `${GUID потребности}__${номер}` — id позиции
// в таблице шага 1. Хранится в SP-поле 'requestPositionId'; у СТ, созданных до его появления, пусто.
requestPositionId?: string;
requestId: string; // GUID потребности requestId: string; // GUID потребности
requestNumber: string; // demandNumber — номер потребности (e.g. "081/26-04/0017") requestNumber: string; // demandNumber — номер потребности (e.g. "081/26-04/0017")
materialName: string; // position — наименование позиции materialName: string; // position — наименование позиции
@@ -342,9 +342,14 @@ export class CtService {
if (positions.length === 0) return []; if (positions.length === 0) return [];
// Раньше — по одному POST на позицию в цикле await (последовательно): 100 позиций = // Раньше — по одному POST на позицию в цикле await (последовательно): 100 позиций =
// 100 запросов и ~30-60 сек + троттлинг 429. Теперь одним $batch (до 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) => ({ const bodies = positions.map((pos) => ({
Title: pos.materialName, Title: pos.materialName,
CTId: parseInt(ctId, 10), CTId: parseInt(ctId, 10),
requestPositionId: requestPositionIdOf(pos),
demandNumber: pos.requestNumber, demandNumber: pos.requestNumber,
position: pos.materialName, position: pos.materialName,
disciplineCode: pos.disciplineCode ?? "", disciplineCode: pos.disciplineCode ?? "",
@@ -357,7 +362,11 @@ export class CtService {
deadline: pos.deadline ? pos.deadline.replace(/Z?$/, "Z") : undefined, deadline: pos.deadline ? pos.deadline.replace(/Z?$/, "Z") : undefined,
})); }));
const ids = await this.batchCreateItems(LISTS.CT_POSITIONS, bodies); 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 — // Пакетное создание элементов списка через SharePoint OData $batch: вместо N POST —
@@ -798,17 +807,21 @@ export class CtService {
async loadPositionsForCT(ctId: string): Promise<Position[]> { async loadPositionsForCT(ctId: string): Promise<Position[]> {
const url = const url =
`${this._li(LISTS.CT_POSITIONS)}` + `${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)}` + `&$filter=CTId eq ${parseInt(ctId, 10)}` +
`&$top=500`; `&$top=500`;
const res = await this.http.get(url, SPHttpClient.configurations.v1); const res = await this.http.get(url, SPHttpClient.configurations.v1);
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); const data = await res.json();
return (data.value ?? []).map( return (data.value ?? []).map((item: Record<string, unknown>): Position => {
(item: Record<string, unknown>): Position => ({ // `${GUID потребности}__${номер}`; у СТ, созданных до появления поля, пусто.
const requestPositionId = String(item.requestPositionId ?? "");
return {
// id остаётся SP Id: по нему к позиции привязаны предложения из SP (см. utils/spOffers).
id: String(item["Id"]), id: String(item["Id"]),
spId: Number(item["Id"]), spId: Number(item["Id"]),
requestId: "", requestPositionId: requestPositionId || undefined,
requestId: requestPositionId.split("__")[0],
requestNumber: String(item["demandNumber"] ?? ""), requestNumber: String(item["demandNumber"] ?? ""),
materialName: String(item["Title"] ?? ""), materialName: String(item["Title"] ?? ""),
disciplineCode: String(item["disciplineCode"] ?? ""), disciplineCode: String(item["disciplineCode"] ?? ""),
@@ -819,8 +832,8 @@ export class CtService {
quantity: Number(item["quantity"] ?? 0), quantity: Number(item["quantity"] ?? 0),
positionType: String(item["positionType"] ?? "ТМЦ"), positionType: String(item["positionType"] ?? "ТМЦ"),
deadline: String(item["deadline"] ?? ""), deadline: String(item["deadline"] ?? ""),
}), };
); });
} }
async loadFullOffersForPositions(positionSpIds: number[]): Promise< async loadFullOffersForPositions(positionSpIds: number[]): Promise<