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 {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: $surface;
|
background: $surface;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { buildOffersFromSpRecords } from './utils/spOffers';
|
|||||||
import { isUserInApprovalChain } from './utils/approvalAccess';
|
import { isUserInApprovalChain } from './utils/approvalAccess';
|
||||||
import { CtAccess, getCtAccess, isCtAuthor } from './utils/ctAccess';
|
import { CtAccess, getCtAccess, isCtAuthor } from './utils/ctAccess';
|
||||||
import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers';
|
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 styles from './CtCreateForm.module.scss';
|
||||||
import '@tnsu/ui-kit/styles.css';
|
import '@tnsu/ui-kit/styles.css';
|
||||||
|
|
||||||
@@ -35,6 +37,11 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
ensureHighestStep,
|
ensureHighestStep,
|
||||||
} = useCtForm();
|
} = useCtForm();
|
||||||
|
|
||||||
|
// Открыта существующая СТ — шаги не показываем, пока она не загрузится из SP. Иначе шаг 1 успевал
|
||||||
|
// появиться с пустой сессией и запоминал пустой выбор: «Выберите позиции», хотя позиции в СТ есть.
|
||||||
|
const [isCtLoading, setIsCtLoading] = useState(!!initialCtId);
|
||||||
|
const [ctLoadError, setCtLoadError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
// Режим формы — по тому, кто открыл и в каком статусе СТ (getCtAccess), а не по ctid/ctId в ссылке.
|
// Режим формы — по тому, кто открыл и в каком статусе СТ (getCtAccess), а не по ctid/ctId в ссылке.
|
||||||
// Считается на каждом рендере из данных СТ — ничего не «прилипает» в сессии: после «Перезапустить
|
// Считается на каждом рендере из данных СТ — ничего не «прилипает» в сессии: после «Перезапустить
|
||||||
// согласование» или возврата на доработку режим меняется сам. astel-k — цены не видит никогда.
|
// согласование» или возврата на доработку режим меняется сам. astel-k — цены не видит никогда.
|
||||||
@@ -185,18 +192,25 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
// Автор, СТ возвращена на доработку — шаг 4: там видно, кто вернул и что исправить.
|
// Автор, СТ возвращена на доработку — шаг 4: там видно, кто вернул и что исправить.
|
||||||
ensureHighestStep(4);
|
ensureHighestStep(4);
|
||||||
goToStep(4);
|
goToStep(4);
|
||||||
} else if (approvalMatrix.length > 0) {
|
} else {
|
||||||
// Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved
|
// Автор открыл свою СТ — открываем шаги, до которых она уже дошла (позиции → 2, КП → 3, маршрут → 4).
|
||||||
ensureHighestStep(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') {
|
if (ct.status === 'approved' || ct.status === 'rejected') {
|
||||||
markStepComplete(4);
|
markStepComplete(4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setIsCtLoading(false);
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
// Раньше ошибка глоталась молча → при сбое любого запроса форма оставалась
|
// Раньше ошибка глоталась молча → при сбое любого запроса форма оставалась
|
||||||
// на устаревшем статусе из session storage (напр. «Сформирована» вместо
|
// на устаревшем статусе из session storage (напр. «Сформирована» вместо
|
||||||
// «На согласовании»). Теперь хотя бы видно, какой запрос упал.
|
// «На согласовании»). Показываем ошибку, а шаги — по данным сессии, если они есть.
|
||||||
console.error("[CtCreateForm] загрузка СТ по ссылке упала:", err);
|
console.error("[CtCreateForm] загрузка СТ по ссылке упала:", err);
|
||||||
|
setCtLoadError(`Не удалось загрузить СТ: ${(err as Error).message ?? 'неизвестная ошибка'}`);
|
||||||
|
setIsCtLoading(false);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -256,8 +270,19 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isCtLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={[styles.root, isDarkTheme ? styles.dark : ''].filter(Boolean).join(' ')}>
|
<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
|
<Breadcrumbs
|
||||||
currentStep={currentStep}
|
currentStep={currentStep}
|
||||||
ctNumber={formData.ct?.number}
|
ctNumber={formData.ct?.number}
|
||||||
|
|||||||
+25
-8
@@ -6,7 +6,14 @@ import { CtService } from '../../services/CtService';
|
|||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
import { useRequests } from '../hooks/useRequests';
|
import { useRequests } from '../hooks/useRequests';
|
||||||
import { RequestsTable } from './RequestsTable';
|
import { RequestsTable } from './RequestsTable';
|
||||||
import { countSelectedInRequest, planStep1Save, resolveSelectedPositions } from './step1Plan';
|
import {
|
||||||
|
buildPinnedRequests,
|
||||||
|
countSelectedInRequest,
|
||||||
|
planStep1Save,
|
||||||
|
resolveProject,
|
||||||
|
resolveSelectedPositions,
|
||||||
|
selectionKey,
|
||||||
|
} from './step1Plan';
|
||||||
import styles from './Step1PositionSelection.module.scss';
|
import styles from './Step1PositionSelection.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -35,13 +42,21 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [showOnlySelected, setShowOnlySelected] = useState(false);
|
const [showOnlySelected, setShowOnlySelected] = useState(false);
|
||||||
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
|
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
|
||||||
new Set(formData.selectedPositions.map(p => p.id))
|
new Set(formData.selectedPositions.map(selectionKey))
|
||||||
);
|
);
|
||||||
const [positionMap, setPositionMap] = useState<Map<string, { position: Position; request: Request }>>(new Map());
|
const [positionMap, setPositionMap] = useState<Map<string, { position: Position; request: Request }>>(new Map());
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } = useRequests(service, days, isReadOnly);
|
// Сохранённые позиции есть только у созданной СТ, поэтому проект для закреплённых потребностей — из её шапки.
|
||||||
|
const ctProjectName = formData.ct?.projectName;
|
||||||
|
const pinnedRequests = useMemo(
|
||||||
|
() => buildPinnedRequests(formData.selectedPositions, resolveProject(undefined, ctProjectName, true)),
|
||||||
|
[formData.selectedPositions, ctProjectName],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } =
|
||||||
|
useRequests(service, days, isReadOnly, pinnedRequests);
|
||||||
|
|
||||||
const readOnlyRequests = useMemo(() => {
|
const readOnlyRequests = useMemo(() => {
|
||||||
if (!isReadOnly) return [];
|
if (!isReadOnly) return [];
|
||||||
@@ -93,7 +108,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
|||||||
setPositionMap(map);
|
setPositionMap(map);
|
||||||
}, [requests]);
|
}, [requests]);
|
||||||
|
|
||||||
const lockedProjectId = formData.selectedProject?.id;
|
const project = resolveProject(formData.selectedProject, ctProjectName, selectedPositionIds.size > 0);
|
||||||
|
const lockedProjectId = project?.id;
|
||||||
|
|
||||||
const handleSelectionChange = useCallback(
|
const handleSelectionChange = useCallback(
|
||||||
(positionId: string, _position: Position, request: Request, checked: boolean) => {
|
(positionId: string, _position: Position, request: Request, checked: boolean) => {
|
||||||
@@ -166,7 +182,7 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectedProject = selectedPositions.length > 0
|
const selectedProject = selectedPositions.length > 0
|
||||||
? positionMap.get(selectedPositions[0].id)?.request.project ?? formData.selectedProject ?? null
|
? positionMap.get(selectedPositions[0].id)?.request.project ?? project ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const canSave = selectedPositions.length > 0;
|
const canSave = selectedPositions.length > 0;
|
||||||
@@ -197,7 +213,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
|||||||
if (ct.projectName !== selectedProject.name) {
|
if (ct.projectName !== selectedProject.name) {
|
||||||
await service.updateCTProject(ct.id, selectedProject.name);
|
await service.updateCTProject(ct.id, selectedProject.name);
|
||||||
}
|
}
|
||||||
const removedIds = new Set(plan.toRemove.map(p => p.id));
|
// Предложения привязаны к позиции либо по id из сессии, либо по SP Id (загружены из SP).
|
||||||
|
const removedIds = new Set(plan.toRemove.flatMap(p => [p.id, String(p.spId)]));
|
||||||
onSave({
|
onSave({
|
||||||
ct: { ...ct, projectName: selectedProject.name },
|
ct: { ...ct, projectName: selectedProject.name },
|
||||||
selectedProject,
|
selectedProject,
|
||||||
@@ -250,9 +267,9 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formData.selectedProject && (
|
{project && (
|
||||||
<Alert variant="info">
|
<Alert variant="info">
|
||||||
Выбран проект: <strong>{formData.selectedProject.name}</strong> — позиции из других проектов недоступны
|
Выбран проект: <strong>{project.name}</strong> — позиции из других проектов недоступны
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+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';
|
import { Position } from '../types/domain';
|
||||||
|
|
||||||
function makePosition(id: string, overrides: Partial<Position> = {}): Position {
|
function makePosition(id: string, overrides: Partial<Position> = {}): Position {
|
||||||
@@ -54,7 +61,7 @@ describe('planStep1Save (СТ уже создана — повторное со
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('countSelectedInRequest', () => {
|
describe('countSelectedInRequest', () => {
|
||||||
// Позиции потребности грузятся только при раскрытии — считаем выбранные по id «потребность__номер».
|
// Позиции потребности грузятся только при раскрытии — считаем выбранные по префиксу id «потребность__».
|
||||||
it('считает выбранные позиции потребности без загрузки её позиций', () => {
|
it('считает выбранные позиции потребности без загрузки её позиций', () => {
|
||||||
const selected = new Set(['req1__0', 'req1__4', 'req2__0']);
|
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 = '__';
|
const POSITION_ID_SEPARATOR = '__';
|
||||||
|
|
||||||
|
// Ключ выбора на шаге 1 — id позиции потребности. У позиции, загруженной из SP (СТ открыта из реестра),
|
||||||
|
// id — это SP Id, а связь с потребностью лежит в requestPositionId. У СТ, созданных до появления
|
||||||
|
// этого поля, связи нет — тогда ключ SP Id, и в таблице потребностей такая позиция не найдётся.
|
||||||
|
export function selectionKey(position: Position): string {
|
||||||
|
return position.requestPositionId || position.id;
|
||||||
|
}
|
||||||
|
|
||||||
// Повторное сохранение шага 1, когда СТ уже создана: что оставить (позиции уже в SP), что дописать,
|
// Повторное сохранение шага 1, когда СТ уже создана: что оставить (позиции уже в SP), что дописать,
|
||||||
// что убрать. Раньше шаг 1 всегда создавал новую СТ — предложения оставались в прежней, а новая
|
// что убрать. Раньше шаг 1 всегда создавал новую СТ — предложения оставались в прежней, а новая
|
||||||
// открывалась по ссылке пустой.
|
// открывалась по ссылке пустой.
|
||||||
@@ -13,13 +20,13 @@ export interface Step1SavePlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function planStep1Save(saved: Position[], selected: Position[]): Step1SavePlan {
|
export function planStep1Save(saved: Position[], selected: Position[]): Step1SavePlan {
|
||||||
const selectedIds = new Set(selected.map(p => p.id));
|
const selectedKeys = new Set(selected.map(selectionKey));
|
||||||
const savedById = new Map(saved.map(p => [p.id, p]));
|
const savedByKey = new Map(saved.map(p => [selectionKey(p), p]));
|
||||||
|
|
||||||
const toKeep: Position[] = [];
|
const toKeep: Position[] = [];
|
||||||
const toCreate: Position[] = [];
|
const toCreate: Position[] = [];
|
||||||
selected.forEach(p => {
|
selected.forEach(p => {
|
||||||
const savedPosition = savedById.get(p.id);
|
const savedPosition = savedByKey.get(selectionKey(p));
|
||||||
if (savedPosition?.spId !== undefined) {
|
if (savedPosition?.spId !== undefined) {
|
||||||
toKeep.push(savedPosition);
|
toKeep.push(savedPosition);
|
||||||
} else {
|
} else {
|
||||||
@@ -30,7 +37,7 @@ export function planStep1Save(saved: Position[], selected: Position[]): Step1Sav
|
|||||||
return {
|
return {
|
||||||
toKeep,
|
toKeep,
|
||||||
toCreate,
|
toCreate,
|
||||||
toRemove: saved.filter(p => p.spId !== undefined && !selectedIds.has(p.id)),
|
toRemove: saved.filter(p => p.spId !== undefined && !selectedKeys.has(selectionKey(p))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +52,51 @@ export function countSelectedInRequest(selectedIds: Set<string>, requestId: stri
|
|||||||
// Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме.
|
// Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме.
|
||||||
// Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна.
|
// Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна.
|
||||||
export function resolveSelectedPositions(
|
export function resolveSelectedPositions(
|
||||||
selectedIds: string[],
|
selectedKeys: string[],
|
||||||
findLoaded: (id: string) => Position | undefined,
|
findLoaded: (key: string) => Position | undefined,
|
||||||
saved: Position[],
|
saved: Position[],
|
||||||
): Position[] {
|
): Position[] {
|
||||||
const savedById = new Map(saved.map(p => [p.id, p]));
|
const savedByKey = new Map(saved.map(p => [selectionKey(p), p]));
|
||||||
return selectedIds
|
return selectedKeys
|
||||||
.map(id => findLoaded(id) ?? savedById.get(id))
|
.map(key => findLoaded(key) ?? savedByKey.get(key))
|
||||||
.filter((p): p is Position => p !== undefined);
|
.filter((p): p is Position => p !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Потребности, позиции которых уже в СТ. Их надо показать в таблице, даже если потребность старше
|
||||||
|
// выбранного периода, — иначе «Выбрано: 3», а увидеть и снять выбор негде. Шапку собираем из позиций
|
||||||
|
// (организацию и дату создания в позициях СТ не храним), сами позиции потребности догружает useRequests.
|
||||||
|
export function buildPinnedRequests(saved: Position[], project: Project | undefined): Request[] {
|
||||||
|
if (!project) return [];
|
||||||
|
const byRequestId = new Map<string, Request>();
|
||||||
|
saved.forEach(p => {
|
||||||
|
if (!p.requestId || byRequestId.has(p.requestId)) return;
|
||||||
|
byRequestId.set(p.requestId, {
|
||||||
|
id: p.requestId,
|
||||||
|
requestNumber: p.requestNumber,
|
||||||
|
project,
|
||||||
|
discipline: p.disciplineCode,
|
||||||
|
// Вид работ потребности в позициях СТ не хранится (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>;
|
loadingPositionIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRequests(service: CtService, days: number, skip?: boolean): UseRequestsResult {
|
// Потребности, позиции которых уже в СТ, но которые не пришли за выбранный период: показываем
|
||||||
|
// их в таблице и сразу грузим позиции — без них неизвестно реальное число позиций потребности.
|
||||||
|
async function loadMissingPinned(service: CtService, pinned: Request[], loaded: Request[]): Promise<Request[]> {
|
||||||
|
const loadedIds = new Set(loaded.map(r => r.id));
|
||||||
|
const missing = pinned.filter(r => !loadedIds.has(r.id));
|
||||||
|
return Promise.all(
|
||||||
|
missing.map(async request => {
|
||||||
|
const positions = await service.loadRequestPositions(request);
|
||||||
|
return { ...request, positions, positionsCount: positions.length };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRequests(
|
||||||
|
service: CtService,
|
||||||
|
days: number,
|
||||||
|
skip: boolean,
|
||||||
|
pinnedRequests: Request[],
|
||||||
|
): UseRequestsResult {
|
||||||
const [requests, setRequests] = useState<Request[]>([]);
|
const [requests, setRequests] = useState<Request[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(!skip);
|
const [isLoading, setIsLoading] = useState(!skip);
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
@@ -34,9 +52,10 @@ export function useRequests(service: CtService, days: number, skip?: boolean): U
|
|||||||
|
|
||||||
service
|
service
|
||||||
.fetchRequests(days)
|
.fetchRequests(days)
|
||||||
.then(data => {
|
.then(async data => {
|
||||||
|
const pinned = await loadMissingPinned(service, pinnedRequests, data);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setRequests(data);
|
setRequests([...data, ...pinned]);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -50,7 +69,7 @@ export function useRequests(service: CtService, days: number, skip?: boolean): U
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [service, days, reloadToken, skip]);
|
}, [service, days, reloadToken, skip, pinnedRequests]);
|
||||||
|
|
||||||
const loadPositions = useCallback(
|
const loadPositions = useCallback(
|
||||||
async (requestId: string): Promise<Position[]> => {
|
async (requestId: string): Promise<Position[]> => {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export interface Project {
|
|||||||
export interface Position {
|
export interface Position {
|
||||||
id: string;
|
id: string;
|
||||||
spId?: number; // SP item ID in Позиции СТ (populated after createCTPositions)
|
spId?: number; // SP item ID in Позиции СТ (populated after createCTPositions)
|
||||||
|
// Позиция потребности, из которой взята позиция СТ: её ключ (см. utils/positionKey) — id позиции
|
||||||
|
// в таблице шага 1. Хранится в SP-поле 'requestPositionId'; у СТ, созданных до его появления, пусто.
|
||||||
|
requestPositionId?: string;
|
||||||
requestId: string; // GUID потребности
|
requestId: string; // GUID потребности
|
||||||
requestNumber: string; // demandNumber — номер потребности (e.g. "081/26-04/0017")
|
requestNumber: string; // demandNumber — номер потребности (e.g. "081/26-04/0017")
|
||||||
materialName: string; // position — наименование позиции
|
materialName: string; // position — наименование позиции
|
||||||
|
|||||||
@@ -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» другой,
|
// КП сохранялись под исходным именем с перезаписью: «КП.pdf» из одной СТ затирал «КП.pdf» другой,
|
||||||
// и согласующий первой СТ открывал чужое КП. Префикс СТ делает имя уникальным.
|
// и согласующий первой СТ открывал чужое КП. Префикс СТ делает имя уникальным.
|
||||||
it('добавляет префикс СТ', () => {
|
it('добавляет префикс СТ', () => {
|
||||||
expect(buildKpStorageName('357', 'КП.pdf')).toBe('357_КП.pdf');
|
expect(buildKpStorageName('357', 'KP.pdf')).toBe('357_KP.pdf');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('не добавляет префикс второй раз', () => {
|
it('не добавляет префикс второй раз', () => {
|
||||||
expect(buildKpStorageName('357', '357_КП.pdf')).toBe('357_КП.pdf');
|
expect(buildKpStorageName('357', '357_KP.pdf')).toBe('357_KP.pdf');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('без СТ — исходное имя', () => {
|
it('без СТ — имя без префикса', () => {
|
||||||
expect(buildKpStorageName(undefined, 'КП.pdf')).toBe('КП.pdf');
|
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» другой, и
|
// и загружались под исходным именем с перезаписью: «КП.pdf» одной СТ затирал «КП.pdf» другой, и
|
||||||
// согласующий открывал чужое КП. Ссылки, выгрузка INCORE и восстановление по ссылке работают по этому имени.
|
// согласующий открывал чужое КП. Ссылки, выгрузка INCORE и восстановление по ссылке работают по этому имени.
|
||||||
export function buildKpStorageName(ctId: string | undefined, originalName: string): string {
|
export function buildKpStorageName(ctId: string | undefined, originalName: string): string {
|
||||||
if (!ctId) return originalName;
|
const safeName = toSafeFileName(originalName);
|
||||||
|
if (!ctId) return safeName;
|
||||||
const prefix = `${ctId}_`;
|
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";
|
} from "../../../shared/offers/offerRecords";
|
||||||
import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria";
|
import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria";
|
||||||
import { pickRowToNotify } from "../components/utils/approvalAccess";
|
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 {
|
import {
|
||||||
NoOfferSupplierEntry,
|
NoOfferSupplierEntry,
|
||||||
NoOfferSupplierRecord,
|
NoOfferSupplierRecord,
|
||||||
@@ -209,8 +212,17 @@ export class CtService {
|
|||||||
});
|
});
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data: ApiPosition[] = await res.json();
|
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) =>
|
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 [];
|
if (positions.length === 0) return [];
|
||||||
// Раньше — по одному POST на позицию в цикле await (последовательно): 100 позиций =
|
// Раньше — по одному POST на позицию в цикле await (последовательно): 100 позиций =
|
||||||
// 100 запросов и ~30-60 сек + троттлинг 429. Теперь одним $batch (до 100 операций/запрос).
|
// 100 запросов и ~30-60 сек + троттлинг 429. Теперь одним $batch (до 100 операций/запрос).
|
||||||
|
// Связь с позицией потребности нужна шагу 1, когда СТ открыта из реестра (см. Position.requestPositionId).
|
||||||
|
// У доп. позиций шага 3 потребности нет — requestId пустой, связь не пишем.
|
||||||
|
const requestPositionIdOf = (pos: Position): string =>
|
||||||
|
pos.requestPositionId || (pos.requestId ? pos.id : "");
|
||||||
const bodies = positions.map((pos) => ({
|
const bodies = positions.map((pos) => ({
|
||||||
Title: pos.materialName,
|
Title: pos.materialName,
|
||||||
CTId: parseInt(ctId, 10),
|
CTId: parseInt(ctId, 10),
|
||||||
|
requestPositionId: requestPositionIdOf(pos),
|
||||||
demandNumber: pos.requestNumber,
|
demandNumber: pos.requestNumber,
|
||||||
position: pos.materialName,
|
position: pos.materialName,
|
||||||
disciplineCode: pos.disciplineCode ?? "",
|
disciplineCode: pos.disciplineCode ?? "",
|
||||||
@@ -357,7 +374,11 @@ export class CtService {
|
|||||||
deadline: pos.deadline ? pos.deadline.replace(/Z?$/, "Z") : undefined,
|
deadline: pos.deadline ? pos.deadline.replace(/Z?$/, "Z") : undefined,
|
||||||
}));
|
}));
|
||||||
const ids = await this.batchCreateItems(LISTS.CT_POSITIONS, bodies);
|
const ids = await this.batchCreateItems(LISTS.CT_POSITIONS, bodies);
|
||||||
return positions.map((pos, i) => ({ ...pos, spId: ids[i] }));
|
return positions.map((pos, i) => ({
|
||||||
|
...pos,
|
||||||
|
spId: ids[i],
|
||||||
|
requestPositionId: requestPositionIdOf(pos) || undefined,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пакетное создание элементов списка через SharePoint OData $batch: вместо N POST —
|
// Пакетное создание элементов списка через SharePoint OData $batch: вместо N POST —
|
||||||
@@ -798,17 +819,21 @@ export class CtService {
|
|||||||
async loadPositionsForCT(ctId: string): Promise<Position[]> {
|
async loadPositionsForCT(ctId: string): Promise<Position[]> {
|
||||||
const url =
|
const url =
|
||||||
`${this._li(LISTS.CT_POSITIONS)}` +
|
`${this._li(LISTS.CT_POSITIONS)}` +
|
||||||
`?$select=Id,Title,unit,quantity,positionType,demandNumber,disciplineCode,SMR,agskCode,uniqueCode,deadline` +
|
`?$select=Id,Title,unit,quantity,positionType,requestPositionId,demandNumber,disciplineCode,SMR,agskCode,uniqueCode,deadline` +
|
||||||
`&$filter=CTId eq ${parseInt(ctId, 10)}` +
|
`&$filter=CTId eq ${parseInt(ctId, 10)}` +
|
||||||
`&$top=500`;
|
`&$top=500`;
|
||||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return (data.value ?? []).map(
|
return (data.value ?? []).map((item: Record<string, unknown>): Position => {
|
||||||
(item: Record<string, unknown>): Position => ({
|
// Ключ позиции потребности (см. utils/positionKey); у СТ, созданных до появления поля, пусто.
|
||||||
|
const requestPositionId = String(item.requestPositionId ?? "");
|
||||||
|
return {
|
||||||
|
// id остаётся SP Id: по нему к позиции привязаны предложения из SP (см. utils/spOffers).
|
||||||
id: String(item["Id"]),
|
id: String(item["Id"]),
|
||||||
spId: Number(item["Id"]),
|
spId: Number(item["Id"]),
|
||||||
requestId: "",
|
requestPositionId: requestPositionId || undefined,
|
||||||
|
requestId: requestPositionId.split("__")[0],
|
||||||
requestNumber: String(item["demandNumber"] ?? ""),
|
requestNumber: String(item["demandNumber"] ?? ""),
|
||||||
materialName: String(item["Title"] ?? ""),
|
materialName: String(item["Title"] ?? ""),
|
||||||
disciplineCode: String(item["disciplineCode"] ?? ""),
|
disciplineCode: String(item["disciplineCode"] ?? ""),
|
||||||
@@ -819,8 +844,8 @@ export class CtService {
|
|||||||
quantity: Number(item["quantity"] ?? 0),
|
quantity: Number(item["quantity"] ?? 0),
|
||||||
positionType: String(item["positionType"] ?? "ТМЦ"),
|
positionType: String(item["positionType"] ?? "ТМЦ"),
|
||||||
deadline: String(item["deadline"] ?? ""),
|
deadline: String(item["deadline"] ?? ""),
|
||||||
}),
|
};
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadFullOffersForPositions(positionSpIds: number[]): Promise<
|
async loadFullOffersForPositions(positionSpIds: number[]): Promise<
|
||||||
@@ -1399,13 +1424,8 @@ export class CtService {
|
|||||||
: ((raw as Record<string, string>)?.comment ??
|
: ((raw as Record<string, string>)?.comment ??
|
||||||
(raw as Record<string, Record<string, string>>)?.body?.comment ??
|
(raw as Record<string, Record<string, string>>)?.body?.comment ??
|
||||||
(typeof raw === "string" ? raw : undefined));
|
(typeof raw === "string" ? raw : undefined));
|
||||||
if (!text) return JSON.stringify(raw);
|
// Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
|
||||||
try {
|
return aiCommentToText(text ?? JSON.stringify(raw));
|
||||||
const parsed = JSON.parse(text);
|
|
||||||
return (parsed as Record<string, string>)?.comment ?? text;
|
|
||||||
} catch {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCtComment(ctId: string): Promise<string | undefined> {
|
async loadCtComment(ctId: string): Promise<string | undefined> {
|
||||||
@@ -1413,7 +1433,8 @@ export class CtService {
|
|||||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||||
if (!res.ok) return undefined;
|
if (!res.ok) return undefined;
|
||||||
const data = await res.json();
|
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> {
|
async updateCtComment(ctId: string, comment: string): Promise<void> {
|
||||||
@@ -1589,35 +1610,7 @@ export class CtService {
|
|||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {
|
return mapFlowResult(parsed);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadOffersForPositions(positionSpIds: number[]): Promise<
|
async loadOffersForPositions(positionSpIds: number[]): Promise<
|
||||||
@@ -1782,11 +1775,11 @@ export class CtService {
|
|||||||
|
|
||||||
private _mapPosition = (
|
private _mapPosition = (
|
||||||
item: ApiPosition,
|
item: ApiPosition,
|
||||||
requestOid = "",
|
positionKey: string,
|
||||||
requestNumber = "",
|
requestOid: string,
|
||||||
idx = 0,
|
requestNumber: string,
|
||||||
): Position => ({
|
): Position => ({
|
||||||
id: `${requestOid}__${idx}`,
|
id: positionKey,
|
||||||
requestId: requestOid,
|
requestId: requestOid,
|
||||||
requestNumber,
|
requestNumber,
|
||||||
materialName: String(item.materialName ?? ""),
|
materialName: String(item.materialName ?? ""),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
IMatrixRow
|
IMatrixRow
|
||||||
} from '../../../shared/approval';
|
} from '../../../shared/approval';
|
||||||
import { keepLatestOfferRecords, parseOfferItemIndex } from '../../../shared/offers/offerRecords';
|
import { keepLatestOfferRecords, parseOfferItemIndex } from '../../../shared/offers/offerRecords';
|
||||||
|
import { aiCommentToText } from '../../../shared/aiComment/aiCommentText';
|
||||||
import type {
|
import type {
|
||||||
CtFormData,
|
CtFormData,
|
||||||
CtStatus,
|
CtStatus,
|
||||||
@@ -1203,14 +1204,8 @@ export class KpsCtFormService {
|
|||||||
: ((asRec?.comment as string | undefined) ??
|
: ((asRec?.comment as string | undefined) ??
|
||||||
(raw as { body?: { comment?: string } })?.body?.comment ??
|
(raw as { body?: { comment?: string } })?.body?.comment ??
|
||||||
(typeof raw === 'string' ? raw : undefined));
|
(typeof raw === 'string' ? raw : undefined));
|
||||||
if (!text) {
|
// Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
|
||||||
return JSON.stringify(raw);
|
return aiCommentToText(text ?? JSON.stringify(raw));
|
||||||
}
|
|
||||||
try {
|
|
||||||
return ((JSON.parse(text) as Record<string, string>)?.comment ?? text);
|
|
||||||
} catch {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Шапка СТ для комментария ИИ: номер/проект (в payload) + сохранённый ранее комментарий (показать/поллинг).
|
// Шапка СТ для комментария ИИ: номер/проект (в payload) + сохранённый ранее комментарий (показать/поллинг).
|
||||||
@@ -1219,7 +1214,9 @@ export class KpsCtFormService {
|
|||||||
LISTS.ct,
|
LISTS.ct,
|
||||||
`$select=${CT_FIELDS.number},${CT_FIELDS.project},${CT_FIELDS.comment}&$filter=Id eq ${ctId}&$top=1`
|
`$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). Пустая строка = очистка.
|
// Сохранение комментария ИИ в колонку `comment` шапки (MERGE — как setWinSupplier). Пустая строка = очистка.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
writeRoute,
|
writeRoute,
|
||||||
} from "../../../shared/approval";
|
} from "../../../shared/approval";
|
||||||
import { findSupplierOfferIds } from "../../../shared/offers/offerRecords";
|
import { findSupplierOfferIds } from "../../../shared/offers/offerRecords";
|
||||||
|
import { aiCommentToText } from "../../../shared/aiComment/aiCommentText";
|
||||||
|
|
||||||
const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed";
|
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, string>)?.comment ??
|
||||||
(raw as Record<string, Record<string, string>>)?.body?.comment ??
|
(raw as Record<string, Record<string, string>>)?.body?.comment ??
|
||||||
(typeof raw === "string" ? raw : undefined));
|
(typeof raw === "string" ? raw : undefined));
|
||||||
if (!text) return JSON.stringify(raw);
|
// Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
|
||||||
try {
|
return aiCommentToText(text ?? JSON.stringify(raw));
|
||||||
const parsed = JSON.parse(text) as Record<string, string>;
|
|
||||||
return parsed?.comment ?? text;
|
|
||||||
} catch {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOidComment(oidId: number, comment: string): Promise<void> {
|
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);
|
const res = await this.sp.get(url, SPHttpClient.configurations.v1);
|
||||||
if (!res.ok) return undefined;
|
if (!res.ok) return undefined;
|
||||||
const data = await res.json() as Record<string, unknown>;
|
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(
|
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