Compare commits

..

9 Commits

Author SHA1 Message Date
Тимур Городецкий 0f9afdba7d fix: комментарий ИИ разбирается при любой форме структурированного ответа
Тот же промпт вернул другую структуру: массив из одного объекта, список
поставщиков suppliers_analysis вместо suppliers_evaluation, плюсы/минусы
строкой вместо массива — и снова показывался сырой JSON. Разбор стал
терпимым: объект или массив объектов, список поставщиков — любое поле-
массив объектов с supplier, плюсы/минусы — строка или массив, плюс
recommendation.
2026-10-23 10:44:45 +05:00
Тимур Городецкий cfbdfd17c9 fix: комментарий ИИ из структурированного ответа потока показывается текстом
Поток «Комментарий ИИ» стал отвечать JSON {summary, suppliers_evaluation,
conclusion} вместо текста — в шаге 3 и в SP (а значит и в PDF) лежал
сырой JSON. aiCommentToText разворачивает структуру в текст (вывод,
плюсы/минусы по поставщикам, итог) без Markdown-разметки; прежние
форматы (текст, {comment}) — как раньше. Подключено в СТ, ОИД и КПС —
при ответе потока и при чтении из SP, чтобы старые записи с JSON тоже
показывались текстом.
2026-10-23 10:35:38 +05:00
Тимур Городецкий a0d9179d9d fix(ct): null из потока КП не превращается в строку «null»
Когда в КП нет поля (например, сертификатов), поток отдаёт null, а
String(null) давал «null» — он показывался в «Сертификатах» шага 3 и
сохранялся в «Критерии». Разбор ответа вынесен в mapFlowResult: null —
«не распознано», как уже сделано в КПС и ОИД.
2026-10-22 10:15:37 +05:00
Тимур Городецкий 48c7999fa0 fix(ct): у закреплённой потребности вид работ не подменяется видом СМР позиции
Шапку потребности старше периода собираем из позиций СТ, а вида работ
потребности там нет — подставлялся smrType позиции («ГКЛ перегородки»
вместо «СТЕНЫ И ПЕРЕГОРОДКИ»). Теперь пусто, в таблице «—».
2026-10-22 10:06:25 +05:00
Тимур Городецкий 1669c1b0e8 fix(ct): степпер черновика открыт до шага, до которого дошла СТ
В новой вкладке (из реестра) сессии нет, и у черновика без маршрута
степпер давал кликнуть только шаг 1 — до таблицы приходилось идти через
«Сохранить и продолжить» на каждом шаге. Теперь доступные шаги — по
данным СТ из SP: позиции → 2, КП → 3, маршрут → 4.
2026-10-22 09:29:51 +05:00
Тимур Городецкий f99732962b fix(ct): СТ по ссылке открывается после загрузки из SP
Шаги рендерились сразу, а СТ подгружалась позже. Шаг 1 запоминал выбор
при появлении — пустой, и при открытии из реестра показывал «Выберите
позиции», пока не обновить страницу. Теперь до загрузки — спиннер,
ошибка загрузки показывается в форме, а не только в консоли.
2026-10-22 09:23:19 +05:00
Тимур Городецкий 62ebcb2f32 fix(ct): id позиции потребности — по её содержимому, а не по месту в ответе API
API /Requests/{oid}/positions отдаёт позиции без id и в разном порядке
(lineNumber — номер после сортировки по наименованию). id вида
GUID__номер указывал на разные позиции: на шаге 1 снимали одну — из СТ
удалялась другая.

Ключ — GUID потребности + хеш наименования, R66, АГСК, ед. изм.,
количества и вида СМР; одинаковые строки получают номер совпадения.
Метка h- отличает ключ от старых номерных — те ни с чем не сопоставятся.
2026-10-22 09:20:58 +05:00
Тимур Городецкий 5382459414 fix(ct): файлы КП сохраняются под латинским именем
Бэкенд extractPDFText теряет расширение у PDF с кириллицей в имени
(OCR 415 «unsupported file type ''») и отвечает 500 — КП падало при
каждой первой загрузке. Повторная загрузка «проходила» лишь потому, что
текст бэкенд успевал записать, и фронт пропускал извлечение.

Имя в библиотеке КП транслитерируется, остальные символы — в «_».
Результат детерминированный: поиск уже загруженного файла и проверка
дублей работают как раньше.
2026-10-22 09:20:56 +05:00
Тимур Городецкий 61913ee79b fix(ct): шаг 1 видит позиции СТ, открытой из реестра
Позиции СТ из SP не были связаны с потребностями (id = SP Id), поэтому
на шаге 1 при «Выбрано: N» ни одна строка не была отмечена, «Только
выбранные» показывал пустой список, а снять выбор было негде.

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

У СТ, созданных до колонки, связи нет — их позиции в таблице не видны.
2026-10-21 12:51:38 +05:00
20 changed files with 794 additions and 101 deletions
@@ -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}
@@ -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>
@@ -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();
});
});
@@ -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');
}