Compare commits
9 Commits
e40fc402d9
...
0f9afdba7d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f9afdba7d | |||
| cfbdfd17c9 | |||
| a0d9179d9d | |||
| 48c7999fa0 | |||
| 1669c1b0e8 | |||
| f99732962b | |||
| 62ebcb2f32 | |||
| 5382459414 | |||
| 61913ee79b |
@@ -26,6 +26,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
background: $surface;
|
||||
|
||||
@@ -14,6 +14,8 @@ import { buildOffersFromSpRecords } from './utils/spOffers';
|
||||
import { isUserInApprovalChain } from './utils/approvalAccess';
|
||||
import { CtAccess, getCtAccess, isCtAuthor } from './utils/ctAccess';
|
||||
import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers';
|
||||
import { reachableStep } from './utils/reachableStep';
|
||||
import { Alert, Spinner } from '@tnsu/ui-kit';
|
||||
import styles from './CtCreateForm.module.scss';
|
||||
import '@tnsu/ui-kit/styles.css';
|
||||
|
||||
@@ -35,6 +37,11 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
ensureHighestStep,
|
||||
} = useCtForm();
|
||||
|
||||
// Открыта существующая СТ — шаги не показываем, пока она не загрузится из SP. Иначе шаг 1 успевал
|
||||
// появиться с пустой сессией и запоминал пустой выбор: «Выберите позиции», хотя позиции в СТ есть.
|
||||
const [isCtLoading, setIsCtLoading] = useState(!!initialCtId);
|
||||
const [ctLoadError, setCtLoadError] = useState<string | undefined>(undefined);
|
||||
|
||||
// Режим формы — по тому, кто открыл и в каком статусе СТ (getCtAccess), а не по ctid/ctId в ссылке.
|
||||
// Считается на каждом рендере из данных СТ — ничего не «прилипает» в сессии: после «Перезапустить
|
||||
// согласование» или возврата на доработку режим меняется сам. astel-k — цены не видит никогда.
|
||||
@@ -185,18 +192,25 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
// Автор, СТ возвращена на доработку — шаг 4: там видно, кто вернул и что исправить.
|
||||
ensureHighestStep(4);
|
||||
goToStep(4);
|
||||
} else if (approvalMatrix.length > 0) {
|
||||
// Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved
|
||||
ensureHighestStep(4);
|
||||
} else {
|
||||
// Автор открыл свою СТ — открываем шаги, до которых она уже дошла (позиции → 2, КП → 3, маршрут → 4).
|
||||
ensureHighestStep(reachableStep({
|
||||
hasPositions: positions.length > 0,
|
||||
hasKp: rawOffers.length > 0 || uploadedKpFiles.length > 0,
|
||||
hasRoute: approvalMatrix.length > 0,
|
||||
}));
|
||||
if (ct.status === 'approved' || ct.status === 'rejected') {
|
||||
markStepComplete(4);
|
||||
}
|
||||
}
|
||||
setIsCtLoading(false);
|
||||
}).catch((err) => {
|
||||
// Раньше ошибка глоталась молча → при сбое любого запроса форма оставалась
|
||||
// на устаревшем статусе из session storage (напр. «Сформирована» вместо
|
||||
// «На согласовании»). Теперь хотя бы видно, какой запрос упал.
|
||||
// «На согласовании»). Показываем ошибку, а шаги — по данным сессии, если они есть.
|
||||
console.error("[CtCreateForm] загрузка СТ по ссылке упала:", err);
|
||||
setCtLoadError(`Не удалось загрузить СТ: ${(err as Error).message ?? 'неизвестная ошибка'}`);
|
||||
setIsCtLoading(false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -256,8 +270,19 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
}
|
||||
};
|
||||
|
||||
if (isCtLoading) {
|
||||
return (
|
||||
<div className={[styles.root, isDarkTheme ? styles.dark : ''].filter(Boolean).join(' ')}>
|
||||
<div className={styles.loader}>
|
||||
<Spinner size="large" label="Загрузка СТ…" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={[styles.root, isDarkTheme ? styles.dark : ''].filter(Boolean).join(' ')}>
|
||||
{ctLoadError && <Alert variant="err">{ctLoadError}</Alert>}
|
||||
<Breadcrumbs
|
||||
currentStep={currentStep}
|
||||
ctNumber={formData.ct?.number}
|
||||
|
||||
+25
-8
@@ -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<Props> = ({
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [showOnlySelected, setShowOnlySelected] = useState(false);
|
||||
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 [isSaving, setIsSaving] = useState(false);
|
||||
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(() => {
|
||||
if (!isReadOnly) return [];
|
||||
@@ -93,7 +108,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
);
|
||||
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formData.selectedProject && (
|
||||
{project && (
|
||||
<Alert variant="info">
|
||||
Выбран проект: <strong>{formData.selectedProject.name}</strong> — позиции из других проектов недоступны
|
||||
Выбран проект: <strong>{project.name}</strong> — позиции из других проектов недоступны
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+101
-2
@@ -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> = {}): Position {
|
||||
@@ -54,7 +61,7 @@ describe('planStep1Save (СТ уже создана — повторное со
|
||||
});
|
||||
|
||||
describe('countSelectedInRequest', () => {
|
||||
// Позиции потребности грузятся только при раскрытии — считаем выбранные по id «потребность__номер».
|
||||
// Позиции потребности грузятся только при раскрытии — считаем выбранные по префиксу id «потребность__».
|
||||
it('считает выбранные позиции потребности без загрузки её позиций', () => {
|
||||
const selected = new Set(['req1__0', 'req1__4', 'req2__0']);
|
||||
|
||||
@@ -83,3 +90,95 @@ 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('вид работ потребности в позициях не хранится — пусто, а не вид СМР позиции', () => {
|
||||
// СТ 367: у потребности 081/26-10/0028 вид работ «СТЕНЫ И ПЕРЕГОРОДКИ», а в шапке закреплённой
|
||||
// потребности показывалось «ГКЛ перегородки» — вид СМР её позиции.
|
||||
const saved = [makePosition('1', { requestId: 'req1', smrType: 'ГКЛ перегородки' })];
|
||||
|
||||
expect(buildPinnedRequests(saved, project)[0].workType).toBe('');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+57
-11
@@ -1,8 +1,15 @@
|
||||
import { Position } from '../types/domain';
|
||||
import { Position, Project, Request } from '../types/domain';
|
||||
|
||||
// id позиции заявки — `${id потребности}__${номер}` (см. CtService._mapPosition).
|
||||
// id позиции потребности начинается с `${id потребности}__` (см. utils/positionKey).
|
||||
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,51 @@ export function countSelectedInRequest(selectedIds: Set<string>, 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<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,
|
||||
// Вид работ потребности в позициях СТ не хранится (smrType — вид СМР позиции, это другое) —
|
||||
// пусто, таблица покажет «—».
|
||||
workType: '',
|
||||
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>;
|
||||
}
|
||||
|
||||
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 [isLoading, setIsLoading] = useState(!skip);
|
||||
const [error, setError] = useState<string | undefined>(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<Position[]> => {
|
||||
|
||||
@@ -7,6 +7,9 @@ export interface Project {
|
||||
export interface Position {
|
||||
id: string;
|
||||
spId?: number; // SP item ID in Позиции СТ (populated after createCTPositions)
|
||||
// Позиция потребности, из которой взята позиция СТ: её ключ (см. utils/positionKey) — id позиции
|
||||
// в таблице шага 1. Хранится в SP-поле 'requestPositionId'; у СТ, созданных до его появления, пусто.
|
||||
requestPositionId?: string;
|
||||
requestId: string; // GUID потребности
|
||||
requestNumber: string; // demandNumber — номер потребности (e.g. "081/26-04/0017")
|
||||
materialName: string; // position — наименование позиции
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mapFlowResult } from './flowResult';
|
||||
|
||||
describe('mapFlowResult (ответ потока разбора КП)', () => {
|
||||
// СТ 367: в КП нет сертификатов, поток вернул sertificates: null → String(null) = «null»,
|
||||
// и в шаге 3 у всех поставщиков в «Сертификатах» стоял текст «null».
|
||||
it('null в текстовых полях — пусто, а не строка «null»', () => {
|
||||
const result = mapFlowResult({
|
||||
position: null, unit: null, conditions: null, deliveryType: null,
|
||||
deadline: null, sertificates: null, supplier: null, supplierBIN: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('null в числах — не распознано (undefined), а не 0', () => {
|
||||
const result = mapFlowResult({ price: null, cost: null, quantity: null });
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('заполненные поля переносятся как есть', () => {
|
||||
const result = mapFlowResult({
|
||||
position: 'Дюбель-гвоздь 6 x 40',
|
||||
unit: 'шт.',
|
||||
conditions: '40% предоплата, 60% перед отгрузкой',
|
||||
deadline: '25-30 рабочих дней',
|
||||
supplier: 'BuildCore Supply Ltd.',
|
||||
price: '0.10',
|
||||
cost: 1527.5,
|
||||
quantity: 15275,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
position: 'Дюбель-гвоздь 6 x 40',
|
||||
unit: 'шт.',
|
||||
conditions: '40% предоплата, 60% перед отгрузкой',
|
||||
deadline: '25-30 рабочих дней',
|
||||
supplier: 'BuildCore Supply Ltd.',
|
||||
price: 0.1,
|
||||
cost: 1527.5,
|
||||
quantity: 15275,
|
||||
});
|
||||
});
|
||||
|
||||
it('нечисловая цена — 0, как раньше', () => {
|
||||
expect(mapFlowResult({ price: 'по запросу' }).price).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FlowResult } from '../types/domain';
|
||||
|
||||
// Поток отдаёт null, когда в КП поля нет (например, сертификатов). String(null) давал строку «null»,
|
||||
// и она показывалась в шаге 3 и уходила в SP. null и undefined — «не распознано».
|
||||
function optionalText(value: unknown): string | undefined {
|
||||
return value !== undefined && value !== null ? String(value) : undefined;
|
||||
}
|
||||
|
||||
// Нечисловое значение (например, «по запросу») — 0, как и раньше.
|
||||
function optionalNumber(value: unknown): number | undefined {
|
||||
return value !== undefined && value !== null ? Number(value) || 0 : undefined;
|
||||
}
|
||||
|
||||
// Разбор ответа потока КП (JSON из responsev2.predictionOutput.text) — как в КПС (_mapFlowResult).
|
||||
export function mapFlowResult(parsed: Record<string, unknown>): FlowResult {
|
||||
return {
|
||||
position: optionalText(parsed.position),
|
||||
unit: optionalText(parsed.unit),
|
||||
conditions: optionalText(parsed.conditions),
|
||||
deliveryType: optionalText(parsed.deliveryType),
|
||||
deadline: optionalText(parsed.deadline),
|
||||
sertificates: optionalText(parsed.sertificates),
|
||||
cost: optionalNumber(parsed.cost),
|
||||
price: optionalNumber(parsed.price),
|
||||
quantity: optionalNumber(parsed.quantity),
|
||||
supplier: optionalText(parsed.supplier),
|
||||
supplierBIN: optionalText(parsed.supplierBIN),
|
||||
};
|
||||
}
|
||||
@@ -4,14 +4,44 @@ describe('buildKpStorageName (имя файла КП в библиотеке)',
|
||||
// КП сохранялись под исходным именем с перезаписью: «КП.pdf» из одной СТ затирал «КП.pdf» другой,
|
||||
// и согласующий первой СТ открывал чужое КП. Префикс СТ делает имя уникальным.
|
||||
it('добавляет префикс СТ', () => {
|
||||
expect(buildKpStorageName('357', 'КП.pdf')).toBe('357_КП.pdf');
|
||||
expect(buildKpStorageName('357', 'KP.pdf')).toBe('357_KP.pdf');
|
||||
});
|
||||
|
||||
it('не добавляет префикс второй раз', () => {
|
||||
expect(buildKpStorageName('357', '357_КП.pdf')).toBe('357_КП.pdf');
|
||||
expect(buildKpStorageName('357', '357_KP.pdf')).toBe('357_KP.pdf');
|
||||
});
|
||||
|
||||
it('без СТ — исходное имя', () => {
|
||||
expect(buildKpStorageName(undefined, 'КП.pdf')).toBe('КП.pdf');
|
||||
it('без СТ — имя без префикса', () => {
|
||||
expect(buildKpStorageName(undefined, 'KP.pdf')).toBe('KP.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildKpStorageName — только латиница', () => {
|
||||
// СТ 365, файл 306: PDF с кириллицей в имени → extractPDFText 500 (OCR 415 «unsupported file type ''»),
|
||||
// тот же PDF с латинским именем проходит. Бэкенд не наш — храним файл под латинским именем.
|
||||
it('транслитерирует кириллицу', () => {
|
||||
expect(buildKpStorageName('365', 'КП_тест_кириллица.pdf')).toBe('365_KP_test_kirillitsa.pdf');
|
||||
});
|
||||
|
||||
it('транслитерирует заглавные и казахские буквы', () => {
|
||||
expect(buildKpStorageName('274', 'Қорғаныш_Әлем_Щит.pdf')).toBe('274_Qorganysh_Alem_Shchit.pdf');
|
||||
});
|
||||
|
||||
it('пробелы, №, кавычки и тире — в безопасные символы', () => {
|
||||
expect(buildKpStorageName('363', 'Счет №1891 ТОО «Орда» — копия (2).pdf'))
|
||||
.toBe('363_Schet_N1891_TOO_Orda_kopiya_(2).pdf');
|
||||
});
|
||||
|
||||
it('латинское имя не меняется', () => {
|
||||
expect(buildKpStorageName('364', 'KP_02_Qurylys-Material_KZT.pdf')).toBe('364_KP_02_Qurylys-Material_KZT.pdf');
|
||||
});
|
||||
|
||||
it('уже сохранённое имя при повторном вызове не меняется (по нему ищем загруженный файл)', () => {
|
||||
const stored = buildKpStorageName('274', 'Северный мост.pdf');
|
||||
expect(buildKpStorageName('274', stored)).toBe(stored);
|
||||
});
|
||||
|
||||
it('расширение сохраняется', () => {
|
||||
expect(buildKpStorageName('1', 'КП.PDF')).toBe('1_KP.PDF');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,42 @@
|
||||
// Транслитерация кириллицы (русский + казахский) для имени файла в SP.
|
||||
const TRANSLIT: Record<string, string> = {
|
||||
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y',
|
||||
к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f',
|
||||
х: 'kh', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
|
||||
ә: 'a', ғ: 'g', қ: 'q', ң: 'n', ө: 'o', ұ: 'u', ү: 'u', һ: 'h', і: 'i',
|
||||
'№': 'N',
|
||||
};
|
||||
|
||||
// Всё, кроме этих символов, заменяем на «_».
|
||||
const UNSAFE_CHARS = /[^A-Za-z0-9_.()-]/g;
|
||||
|
||||
function transliterateChar(char: string): string {
|
||||
const lower = char.toLowerCase();
|
||||
const mapped = TRANSLIT[lower];
|
||||
if (mapped === undefined) return char;
|
||||
// Заглавная буква: «Щ» → «Shch», а не «SHCH».
|
||||
if (char !== lower && mapped.length > 0) return mapped[0].toUpperCase() + mapped.slice(1);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
// Бэкенд extractPDFText теряет расширение у PDF с кириллицей в имени (OCR 415 «unsupported file type ''»)
|
||||
// и отвечает 500 — КП падало при каждой первой загрузке. Бэкенд не наш, поэтому храним файл под именем
|
||||
// только из латиницы, цифр и «_ - . ( )». Результат детерминированный: по нему ищем уже загруженный файл.
|
||||
function toSafeFileName(name: string): string {
|
||||
return Array.from(name)
|
||||
.map(transliterateChar)
|
||||
.join('')
|
||||
.replace(UNSAFE_CHARS, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/_+\./g, '.');
|
||||
}
|
||||
|
||||
// Имя файла КП в библиотеке «Коммерческие предложения» — с префиксом СТ. Файлы лежат в корне библиотеки
|
||||
// и загружались под исходным именем с перезаписью: «КП.pdf» одной СТ затирал «КП.pdf» другой, и
|
||||
// согласующий открывал чужое КП. Ссылки, выгрузка INCORE и восстановление по ссылке работают по этому имени.
|
||||
export function buildKpStorageName(ctId: string | undefined, originalName: string): string {
|
||||
if (!ctId) return originalName;
|
||||
const safeName = toSafeFileName(originalName);
|
||||
if (!ctId) return safeName;
|
||||
const prefix = `${ctId}_`;
|
||||
return originalName.startsWith(prefix) ? originalName : `${prefix}${originalName}`;
|
||||
return safeName.startsWith(prefix) ? safeName : `${prefix}${safeName}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { buildPositionKeys, PositionKeySource } from './positionKey';
|
||||
|
||||
function makeSource(materialName: string, overrides: Partial<PositionKeySource> = {}): PositionKeySource {
|
||||
return {
|
||||
materialName,
|
||||
r66Description: `R66 ${materialName}`,
|
||||
agskCode: '',
|
||||
unit: 'Штука',
|
||||
quantity: 10,
|
||||
smrType: 'ГКЛ перегородки',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const REQUEST_ID = 'ace2f40343f5cfe011f1c60cee00e6ca';
|
||||
|
||||
describe('buildPositionKeys (id позиции потребности)', () => {
|
||||
// API /Requests/{oid}/positions отдаёт позиции без id и каждый раз в разном порядке (потребность
|
||||
// 081/26-10/0028: три разных порядка за день, lineNumber — номер после сортировки по алфавиту).
|
||||
// id был `GUID__номер в ответе` → снял «Дюбель» на шаге 1 — удалилась «ЦСП».
|
||||
const dowel = makeSource('Дюбель-гвоздь 6×40', { quantity: 15275 });
|
||||
const csp = makeSource('Цементно-стружечная плита ЦСП толщиной 12 мм', { quantity: 6211, unit: 'Квадратный метр' });
|
||||
const tape = makeSource('Лента армирующая', { quantity: 15669, unit: 'Метр' });
|
||||
|
||||
it('ключ позиции не зависит от порядка в ответе API', () => {
|
||||
const [dowelFirst, cspFirst] = buildPositionKeys(REQUEST_ID, [dowel, csp, tape]);
|
||||
const reordered = buildPositionKeys(REQUEST_ID, [tape, csp, dowel]);
|
||||
|
||||
expect(reordered[2]).toBe(dowelFirst);
|
||||
expect(reordered[1]).toBe(cspFirst);
|
||||
});
|
||||
|
||||
it('у разных позиций — разные ключи', () => {
|
||||
const keys = buildPositionKeys(REQUEST_ID, [dowel, csp, tape]);
|
||||
|
||||
expect(new Set(keys).size).toBe(3);
|
||||
});
|
||||
|
||||
it('ключ начинается с id потребности — по нему считаем выбранные в потребности', () => {
|
||||
const [key] = buildPositionKeys(REQUEST_ID, [dowel]);
|
||||
|
||||
expect(key.startsWith(`${REQUEST_ID}__`)).toBe(true);
|
||||
});
|
||||
|
||||
it('не совпадает со старым ключом `GUID__номер` — старые записи ни с чем не сопоставятся', () => {
|
||||
const [key] = buildPositionKeys(REQUEST_ID, [dowel]);
|
||||
|
||||
const suffix = key.slice(`${REQUEST_ID}__`.length);
|
||||
expect(suffix).toMatch(/^h-[0-9a-f]{8}$/);
|
||||
});
|
||||
|
||||
it('лишние пробелы и регистр не меняют ключ (в API «Саморез … 3.5х25 » с пробелом на конце)', () => {
|
||||
const [plain] = buildPositionKeys(REQUEST_ID, [makeSource('Саморез для гипсокартона, 3.5х25')]);
|
||||
const [spaced] = buildPositionKeys(REQUEST_ID, [makeSource(' Саморез для гипсокартона, 3.5х25 ')]);
|
||||
|
||||
expect(spaced).toBe(plain);
|
||||
});
|
||||
|
||||
it('другое количество — другая позиция', () => {
|
||||
const [a, b] = buildPositionKeys(REQUEST_ID, [dowel, { ...dowel, quantity: 100 }]);
|
||||
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('одинаковые строки — номер совпадения, ключи различаются', () => {
|
||||
const keys = buildPositionKeys(REQUEST_ID, [dowel, csp, dowel]);
|
||||
|
||||
expect(keys[2]).toBe(`${keys[0]}-2`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// Поля позиции потребности из API, по которым строим её ключ. null из API приводим к '' заранее.
|
||||
export interface PositionKeySource {
|
||||
materialName: string;
|
||||
r66Description: string;
|
||||
agskCode: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
smrType: string;
|
||||
}
|
||||
|
||||
// Метка в ключе: отличает его от старого `GUID__номер в ответе` (так id строился до 2026-10-22),
|
||||
// чтобы такие записи гарантированно ни с чем не сопоставлялись.
|
||||
const KEY_MARK = 'h-';
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
// FNV-1a, 32 бита → 8 hex-символов. Криптостойкость не нужна — только короткий стабильный отпечаток.
|
||||
function hash32(text: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function contentFingerprint(item: PositionKeySource): string {
|
||||
return hash32([
|
||||
normalize(item.materialName),
|
||||
normalize(item.r66Description),
|
||||
normalize(item.agskCode),
|
||||
normalize(item.unit),
|
||||
String(item.quantity),
|
||||
normalize(item.smrType),
|
||||
].join('|'));
|
||||
}
|
||||
|
||||
// Ключ позиции потребности: `${GUID потребности}__h-${отпечаток содержимого}`.
|
||||
// API отдаёт позиции без id и каждый раз в разном порядке (lineNumber — номер после сортировки по
|
||||
// наименованию, сдвигается при добавлении позиции), поэтому номер в ответе как id не годится:
|
||||
// снимали на шаге 1 одну позицию — удалялась другая. Ключ из содержимого от порядка не зависит.
|
||||
// Одинаковые строки получают номер совпадения (`-2`, `-3`): они неразличимы, путать их не страшно.
|
||||
// Если позицию в потребности отредактируют, ключ изменится и она просто перестанет быть отмеченной.
|
||||
export function buildPositionKeys(requestId: string, items: PositionKeySource[]): string[] {
|
||||
const seen = new Map<string, number>();
|
||||
return items.map(item => {
|
||||
const base = `${requestId}__${KEY_MARK}${contentFingerprint(item)}`;
|
||||
const occurrence = (seen.get(base) ?? 0) + 1;
|
||||
seen.set(base, occurrence);
|
||||
return occurrence === 1 ? base : `${base}-${occurrence}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { reachableStep } from './reachableStep';
|
||||
|
||||
describe('reachableStep (до какого шага открыт степпер у СТ, загруженной из SP)', () => {
|
||||
// Черновик в новой вкладке: сессии нет → степпер открывал только шаг 1, хотя позиции и КП уже есть,
|
||||
// и до шага 3 приходилось идти через «Сохранить и продолжить» на каждом шаге.
|
||||
it('пустая СТ — только шаг 1', () => {
|
||||
expect(reachableStep({ hasPositions: false, hasKp: false, hasRoute: false })).toBe(1);
|
||||
});
|
||||
|
||||
it('позиции выбраны — открыт шаг 2', () => {
|
||||
expect(reachableStep({ hasPositions: true, hasKp: false, hasRoute: false })).toBe(2);
|
||||
});
|
||||
|
||||
it('есть КП — открыт шаг 3', () => {
|
||||
expect(reachableStep({ hasPositions: true, hasKp: true, hasRoute: false })).toBe(3);
|
||||
});
|
||||
|
||||
it('есть маршрут согласования — открыт шаг 4', () => {
|
||||
expect(reachableStep({ hasPositions: true, hasKp: true, hasRoute: true })).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { FormStep } from '../types/domain';
|
||||
|
||||
// Что уже есть в СТ, загруженной из SP.
|
||||
export interface CtProgress {
|
||||
hasPositions: boolean;
|
||||
hasKp: boolean;
|
||||
hasRoute: boolean;
|
||||
}
|
||||
|
||||
// До какого шага открыт степпер по данным самой СТ, а не по сессии вкладки. Без этого черновик,
|
||||
// открытый в новой вкладке (из реестра), давал кликнуть только шаг 1, хотя позиции и КП уже есть.
|
||||
export function reachableStep(progress: CtProgress): FormStep {
|
||||
if (progress.hasRoute) return 4;
|
||||
if (progress.hasKp) return 3;
|
||||
if (progress.hasPositions) return 2;
|
||||
return 1;
|
||||
}
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
} from "../../../shared/offers/offerRecords";
|
||||
import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria";
|
||||
import { pickRowToNotify } from "../components/utils/approvalAccess";
|
||||
import { buildPositionKeys } from "../components/utils/positionKey";
|
||||
import { mapFlowResult } from "../components/utils/flowResult";
|
||||
import { aiCommentToText } from "../../../shared/aiComment/aiCommentText";
|
||||
import {
|
||||
NoOfferSupplierEntry,
|
||||
NoOfferSupplierRecord,
|
||||
@@ -209,8 +212,17 @@ export class CtService {
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data: ApiPosition[] = await res.json();
|
||||
// id позиции — из её содержимого, не из места в ответе: API отдаёт позиции в разном порядке.
|
||||
const keys = buildPositionKeys(oid, data.map((item) => ({
|
||||
materialName: String(item.materialName ?? ""),
|
||||
r66Description: String(item.r66Description ?? ""),
|
||||
agskCode: String(item.agskCode ?? ""),
|
||||
unit: String(item.unit ?? ""),
|
||||
quantity: Number(item.quantity ?? 0),
|
||||
smrType: String(item.smrType ?? ""),
|
||||
})));
|
||||
return data.map((item, idx) =>
|
||||
this._mapPosition(item, oid, requestNumber, idx),
|
||||
this._mapPosition(item, keys[idx], oid, requestNumber),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -342,9 +354,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 +374,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 +819,21 @@ export class CtService {
|
||||
async loadPositionsForCT(ctId: string): Promise<Position[]> {
|
||||
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<string, unknown>): Position => ({
|
||||
return (data.value ?? []).map((item: Record<string, unknown>): Position => {
|
||||
// Ключ позиции потребности (см. utils/positionKey); у СТ, созданных до появления поля, пусто.
|
||||
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 +844,8 @@ export class CtService {
|
||||
quantity: Number(item["quantity"] ?? 0),
|
||||
positionType: String(item["positionType"] ?? "ТМЦ"),
|
||||
deadline: String(item["deadline"] ?? ""),
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async loadFullOffersForPositions(positionSpIds: number[]): Promise<
|
||||
@@ -1399,13 +1424,8 @@ export class CtService {
|
||||
: ((raw as Record<string, string>)?.comment ??
|
||||
(raw as Record<string, Record<string, string>>)?.body?.comment ??
|
||||
(typeof raw === "string" ? raw : undefined));
|
||||
if (!text) return JSON.stringify(raw);
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return (parsed as Record<string, string>)?.comment ?? text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
// Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
|
||||
return aiCommentToText(text ?? JSON.stringify(raw));
|
||||
}
|
||||
|
||||
async loadCtComment(ctId: string): Promise<string | undefined> {
|
||||
@@ -1413,7 +1433,8 @@ export class CtService {
|
||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||
if (!res.ok) return undefined;
|
||||
const data = await res.json();
|
||||
return (data.comment as string) || undefined;
|
||||
// В SP мог сохраниться сырой JSON потока (до разбора структуры) — показываем текстом.
|
||||
return data.comment ? aiCommentToText(String(data.comment)) : undefined;
|
||||
}
|
||||
|
||||
async updateCtComment(ctId: string, comment: string): Promise<void> {
|
||||
@@ -1589,35 +1610,7 @@ export class CtService {
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
position:
|
||||
parsed.position !== undefined ? String(parsed.position) : undefined,
|
||||
unit: parsed.unit !== undefined ? String(parsed.unit) : undefined,
|
||||
conditions:
|
||||
parsed.conditions !== undefined ? String(parsed.conditions) : undefined,
|
||||
deliveryType:
|
||||
parsed.deliveryType !== undefined
|
||||
? String(parsed.deliveryType)
|
||||
: undefined,
|
||||
deadline:
|
||||
parsed.deadline !== undefined ? String(parsed.deadline) : undefined,
|
||||
sertificates:
|
||||
parsed.sertificates !== undefined
|
||||
? String(parsed.sertificates)
|
||||
: undefined,
|
||||
cost: parsed.cost !== undefined ? Number(parsed.cost) || 0 : undefined,
|
||||
price: parsed.price !== undefined ? Number(parsed.price) || 0 : undefined,
|
||||
quantity:
|
||||
parsed.quantity !== undefined
|
||||
? Number(parsed.quantity) || 0
|
||||
: undefined,
|
||||
supplier:
|
||||
parsed.supplier !== undefined ? String(parsed.supplier) : undefined,
|
||||
supplierBIN:
|
||||
parsed.supplierBIN !== undefined
|
||||
? String(parsed.supplierBIN)
|
||||
: undefined,
|
||||
};
|
||||
return mapFlowResult(parsed);
|
||||
}
|
||||
|
||||
async loadOffersForPositions(positionSpIds: number[]): Promise<
|
||||
@@ -1782,11 +1775,11 @@ export class CtService {
|
||||
|
||||
private _mapPosition = (
|
||||
item: ApiPosition,
|
||||
requestOid = "",
|
||||
requestNumber = "",
|
||||
idx = 0,
|
||||
positionKey: string,
|
||||
requestOid: string,
|
||||
requestNumber: string,
|
||||
): Position => ({
|
||||
id: `${requestOid}__${idx}`,
|
||||
id: positionKey,
|
||||
requestId: requestOid,
|
||||
requestNumber,
|
||||
materialName: String(item.materialName ?? ""),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IMatrixRow
|
||||
} from '../../../shared/approval';
|
||||
import { keepLatestOfferRecords, parseOfferItemIndex } from '../../../shared/offers/offerRecords';
|
||||
import { aiCommentToText } from '../../../shared/aiComment/aiCommentText';
|
||||
import type {
|
||||
CtFormData,
|
||||
CtStatus,
|
||||
@@ -1203,14 +1204,8 @@ export class KpsCtFormService {
|
||||
: ((asRec?.comment as string | undefined) ??
|
||||
(raw as { body?: { comment?: string } })?.body?.comment ??
|
||||
(typeof raw === 'string' ? raw : undefined));
|
||||
if (!text) {
|
||||
return JSON.stringify(raw);
|
||||
}
|
||||
try {
|
||||
return ((JSON.parse(text) as Record<string, string>)?.comment ?? text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
// Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
|
||||
return aiCommentToText(text ?? JSON.stringify(raw));
|
||||
}
|
||||
|
||||
// Шапка СТ для комментария ИИ: номер/проект (в payload) + сохранённый ранее комментарий (показать/поллинг).
|
||||
@@ -1219,7 +1214,9 @@ export class KpsCtFormService {
|
||||
LISTS.ct,
|
||||
`$select=${CT_FIELDS.number},${CT_FIELDS.project},${CT_FIELDS.comment}&$filter=Id eq ${ctId}&$top=1`
|
||||
);
|
||||
return rows[0] ?? {};
|
||||
const header = rows[0] ?? {};
|
||||
// В SP мог сохраниться сырой JSON потока (до разбора структуры) — показываем текстом.
|
||||
return header.comment ? { ...header, comment: aiCommentToText(header.comment) } : header;
|
||||
}
|
||||
|
||||
// Сохранение комментария ИИ в колонку `comment` шапки (MERGE — как setWinSupplier). Пустая строка = очистка.
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
writeRoute,
|
||||
} from "../../../shared/approval";
|
||||
import { findSupplierOfferIds } from "../../../shared/offers/offerRecords";
|
||||
import { aiCommentToText } from "../../../shared/aiComment/aiCommentText";
|
||||
|
||||
const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed";
|
||||
|
||||
@@ -1321,13 +1322,8 @@ export class OidService {
|
||||
: ((raw as Record<string, string>)?.comment ??
|
||||
(raw as Record<string, Record<string, string>>)?.body?.comment ??
|
||||
(typeof raw === "string" ? raw : undefined));
|
||||
if (!text) return JSON.stringify(raw);
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, string>;
|
||||
return parsed?.comment ?? text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
// Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
|
||||
return aiCommentToText(text ?? JSON.stringify(raw));
|
||||
}
|
||||
|
||||
async updateOidComment(oidId: number, comment: string): Promise<void> {
|
||||
@@ -1343,7 +1339,8 @@ export class OidService {
|
||||
const res = await this.sp.get(url, SPHttpClient.configurations.v1);
|
||||
if (!res.ok) return undefined;
|
||||
const data = await res.json() as Record<string, unknown>;
|
||||
return (data.comment as string) || undefined;
|
||||
// В SP мог сохраниться сырой JSON потока (до разбора структуры) — показываем текстом.
|
||||
return data.comment ? aiCommentToText(String(data.comment)) : undefined;
|
||||
}
|
||||
|
||||
async generateOidReport(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { aiCommentToText } from './aiCommentText';
|
||||
|
||||
describe('aiCommentToText (ответ потока «Комментарий ИИ» → текст)', () => {
|
||||
// СТ 367: поток стал отвечать структурой {summary, suppliers_evaluation, conclusion} вместо текста —
|
||||
// в шаге 3 и в SP (а значит и в PDF) лежал сырой JSON.
|
||||
const structured = JSON.stringify({
|
||||
summary: 'Анализ поставщиков по заявке 081/26-10/0028.',
|
||||
suppliers_evaluation: [
|
||||
{
|
||||
supplier: 'BuildCore Supply Ltd.',
|
||||
strengths: ['Самые низкие цены.', 'Широкий ассортимент.'],
|
||||
weaknesses: ['Самый длительный срок поставки.'],
|
||||
},
|
||||
{
|
||||
supplier: 'ТОО «СтройЛайн KZ»',
|
||||
strengths: ['Доставка включена в стоимость.'],
|
||||
weaknesses: [],
|
||||
},
|
||||
],
|
||||
conclusion: 'СтройЛайн — оптимальный выбор.',
|
||||
});
|
||||
|
||||
it('структурированный ответ — читаемый текст: вывод, плюсы/минусы по поставщикам, итог', () => {
|
||||
expect(aiCommentToText(structured)).toBe([
|
||||
'Анализ поставщиков по заявке 081/26-10/0028.',
|
||||
'',
|
||||
'BuildCore Supply Ltd.',
|
||||
'Плюсы:',
|
||||
'- Самые низкие цены.',
|
||||
'- Широкий ассортимент.',
|
||||
'Минусы:',
|
||||
'- Самый длительный срок поставки.',
|
||||
'',
|
||||
'ТОО «СтройЛайн KZ»',
|
||||
'Плюсы:',
|
||||
'- Доставка включена в стоимость.',
|
||||
'',
|
||||
'Вывод: СтройЛайн — оптимальный выбор.',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('обычный текст (прежний формат потока) — без изменений', () => {
|
||||
const text = 'Рекомендуем ТОО «СтройЛайн KZ»:\n- доставка включена';
|
||||
expect(aiCommentToText(text)).toBe(text);
|
||||
});
|
||||
|
||||
it('JSON с полем comment — берём comment', () => {
|
||||
expect(aiCommentToText('{"comment":"Готовый текст"}')).toBe('Готовый текст');
|
||||
});
|
||||
|
||||
it('JSON другой формы — без изменений, ничего не теряем', () => {
|
||||
const text = '{"foo":"bar"}';
|
||||
expect(aiCommentToText(text)).toBe(text);
|
||||
});
|
||||
|
||||
// Тот же промпт в другой раз вернул массив, другое имя списка и строки вместо массивов.
|
||||
it('вариант ответа: массив, suppliers_analysis, плюсы/минусы строкой', () => {
|
||||
const variant = JSON.stringify([{
|
||||
summary: 'Анализ поставщиков по критериям цены и условий поставки',
|
||||
suppliers_analysis: [
|
||||
{ supplier: 'BuildCore Supply Ltd.', strengths: 'Самые низкие цены.', weaknesses: 'Самый длительный срок.' },
|
||||
],
|
||||
conclusion: 'ТОО «СтройЛайн KZ» — оптимальный выбор.',
|
||||
}]);
|
||||
|
||||
expect(aiCommentToText(variant)).toBe([
|
||||
'Анализ поставщиков по критериям цены и условий поставки',
|
||||
'',
|
||||
'BuildCore Supply Ltd.',
|
||||
'Плюсы:',
|
||||
'- Самые низкие цены.',
|
||||
'Минусы:',
|
||||
'- Самый длительный срок.',
|
||||
'',
|
||||
'Вывод: ТОО «СтройЛайн KZ» — оптимальный выбор.',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('рекомендация — отдельной строкой', () => {
|
||||
expect(aiCommentToText('{"summary":"Итоги.","recommendation":"Берём СтройЛайн."}'))
|
||||
.toBe('Итоги.\n\nРекомендация: Берём СтройЛайн.');
|
||||
});
|
||||
|
||||
it('массив не из объектов — без изменений', () => {
|
||||
expect(aiCommentToText('["a","b"]')).toBe('["a","b"]');
|
||||
});
|
||||
|
||||
it('частично заполненная структура — только то, что есть', () => {
|
||||
expect(aiCommentToText('{"conclusion":"Берём BuildCore."}')).toBe('Вывод: Берём BuildCore.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
const LABEL_STRENGTHS = 'Плюсы:';
|
||||
const LABEL_WEAKNESSES = 'Минусы:';
|
||||
const LABEL_CONCLUSION = 'Вывод:';
|
||||
const LABEL_RECOMMENDATION = 'Рекомендация:';
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function tryParseJson(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return undefined;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asText(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
// Плюсы/минусы модель отдаёт то массивом строк, то одной строкой.
|
||||
function asList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map(asText).filter((item): item is string => item !== undefined);
|
||||
const text = asText(value);
|
||||
return text ? [text] : [];
|
||||
}
|
||||
|
||||
// Список поставщиков модель называет по-разному (suppliers_evaluation, suppliers_analysis…) —
|
||||
// ищем первое поле-массив из объектов с полем supplier.
|
||||
function findSupplierList(obj: JsonObject): JsonObject[] {
|
||||
for (const value of Object.values(obj)) {
|
||||
if (Array.isArray(value) && value.length > 0 && value.every(item => isObject(item) && 'supplier' in item)) {
|
||||
return value as JsonObject[];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isStructured(obj: JsonObject): boolean {
|
||||
return 'summary' in obj || 'conclusion' in obj || 'recommendation' in obj || findSupplierList(obj).length > 0;
|
||||
}
|
||||
|
||||
function bulletList(label: string, items: string[]): string[] {
|
||||
return items.length > 0 ? [label, ...items.map(item => `- ${item}`)] : [];
|
||||
}
|
||||
|
||||
function supplierBlock(supplier: JsonObject): string[] {
|
||||
const name = asText(supplier.supplier);
|
||||
return [
|
||||
...(name ? [name] : []),
|
||||
...bulletList(LABEL_STRENGTHS, asList(supplier.strengths)),
|
||||
...bulletList(LABEL_WEAKNESSES, asList(supplier.weaknesses)),
|
||||
];
|
||||
}
|
||||
|
||||
function labeled(label: string, value: unknown): string[] {
|
||||
const text = asText(value);
|
||||
return text ? [`${label} ${text}`] : [];
|
||||
}
|
||||
|
||||
function structuredToText(obj: JsonObject): string {
|
||||
const summary = asText(obj.summary);
|
||||
const blocks: string[][] = [
|
||||
summary ? [summary] : [],
|
||||
...findSupplierList(obj).map(supplierBlock),
|
||||
labeled(LABEL_CONCLUSION, obj.conclusion),
|
||||
labeled(LABEL_RECOMMENDATION, obj.recommendation),
|
||||
];
|
||||
return blocks
|
||||
.filter(block => block.length > 0)
|
||||
.map(block => block.join('\n'))
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
// Ответ потока «Комментарий ИИ» → текст для показа и для SP (из SP его берёт и PDF-отчёт, поэтому
|
||||
// без Markdown-звёздочек: только строки и списки «- »). Поток отвечал по-разному: текстом, JSON
|
||||
// {comment}, а теперь структурой, форму которой модель каждый раз выбирает сама: объект или массив
|
||||
// из одного объекта, список поставщиков под разными именами, плюсы/минусы массивом или строкой.
|
||||
// Всё, что не распознали, возвращаем как есть: лучше показать сырой ответ, чем потерять его.
|
||||
export function aiCommentToText(raw: string): string {
|
||||
const parsed = tryParseJson(raw);
|
||||
const objects: unknown[] = Array.isArray(parsed) ? parsed : [parsed];
|
||||
if (objects.length === 0 || !objects.every(isObject)) return raw;
|
||||
|
||||
const texts = (objects as JsonObject[]).map(obj => {
|
||||
if (typeof obj.comment === 'string') return obj.comment;
|
||||
return isStructured(obj) ? structuredToText(obj) : undefined;
|
||||
});
|
||||
if (texts.some(text => text === undefined)) return raw;
|
||||
return texts.join('\n\n');
|
||||
}
|
||||
Reference in New Issue
Block a user