Compare commits
22 Commits
5f5f0e7f81
...
34da9ae165
| Author | SHA1 | Date | |
|---|---|---|---|
| 34da9ae165 | |||
| 90759fc4bc | |||
| 2593021a70 | |||
| 04cd6fe690 | |||
| 56247fc98f | |||
| fa6d88f2ab | |||
| fa92cb4e2e | |||
| 64a377276c | |||
| 2b102b8772 | |||
| f5853b1ad5 | |||
| efedfc7658 | |||
| 6ef39f8fc9 | |||
| 85145e0d6e | |||
| dc88dfe5ff | |||
| 744b603972 | |||
| 3e95f1470f | |||
| 9490d5b83f | |||
| 2bf50884c0 | |||
| 1899a5ab38 | |||
| d6d92ece3f | |||
| 2316f8bd6d | |||
| 76ad9c6242 |
@@ -27,7 +27,7 @@
|
|||||||
"internalName": "status",
|
"internalName": "status",
|
||||||
"title": "Статус",
|
"title": "Статус",
|
||||||
"type": "Choice",
|
"type": "Choice",
|
||||||
"choices": ["Черновик", "Согласовано", "На согласовании", "Отклонено"]
|
"choices": ["Черновик", "Согласовано", "На согласовании", "Отклонено", "На доработке"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"internalName": "winSupplier",
|
"internalName": "winSupplier",
|
||||||
@@ -255,6 +255,11 @@
|
|||||||
"type": "Lookup",
|
"type": "Lookup",
|
||||||
"lookupList": "Поставщики",
|
"lookupList": "Поставщики",
|
||||||
"lookupShowField": "name"
|
"lookupShowField": "name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalName": "criteriaLabel",
|
||||||
|
"title": "Название доп.условия",
|
||||||
|
"type": "Text"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -287,7 +292,8 @@
|
|||||||
"Рекомендую",
|
"Рекомендую",
|
||||||
"Черновик",
|
"Черновик",
|
||||||
"Отклонено",
|
"Отклонено",
|
||||||
"Ждёт очереди"
|
"Ждёт очереди",
|
||||||
|
"На доработке"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
'Черновик': 'Сформирована',
|
'Черновик': 'Сформирована',
|
||||||
'На согласовании': 'На согласовании',
|
'На согласовании': 'На согласовании',
|
||||||
'Согласовано': 'Согласована',
|
'Согласовано': 'Согласована',
|
||||||
'Отклонено': 'Отклонена'
|
'Отклонено': 'Отклонена',
|
||||||
|
'На доработке': 'На доработке'
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, DashboardRow['statusVariant']> = {
|
const STATUS_VARIANT: Record<string, DashboardRow['statusVariant']> = {
|
||||||
'Черновик': 'neutral',
|
'Черновик': 'neutral',
|
||||||
'На согласовании': 'info',
|
'На согласовании': 'info',
|
||||||
'Согласовано': 'success',
|
'Согласовано': 'success',
|
||||||
'Отклонено': 'error'
|
'Отклонено': 'error',
|
||||||
|
// В реестре нет жёлтого варианта — как «На согласовании»: процесс идёт, ждём действий инициатора.
|
||||||
|
'На доработке': 'info'
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createCtDocumentConfig(): DocumentTypeConfig {
|
export function createCtDocumentConfig(): DocumentTypeConfig {
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ const STATUS_LABELS: Record<CTStatus, string> = {
|
|||||||
on_approval: 'На согласовании',
|
on_approval: 'На согласовании',
|
||||||
approved: 'Согласована',
|
approved: 'Согласована',
|
||||||
rejected: 'Отклонена',
|
rejected: 'Отклонена',
|
||||||
|
rework: 'На доработке',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<CTStatus, 'neutral' | 'info' | 'success' | 'danger'> = {
|
const STATUS_VARIANT: Record<CTStatus, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||||
draft: 'neutral',
|
draft: 'neutral',
|
||||||
kp_loading: 'neutral',
|
kp_loading: 'neutral',
|
||||||
kp_loaded: 'neutral',
|
kp_loaded: 'neutral',
|
||||||
@@ -31,6 +32,7 @@ const STATUS_VARIANT: Record<CTStatus, 'neutral' | 'info' | 'success' | 'danger'
|
|||||||
on_approval: 'info',
|
on_approval: 'info',
|
||||||
approved: 'success',
|
approved: 'success',
|
||||||
rejected: 'danger',
|
rejected: 'danger',
|
||||||
|
rework: 'warning',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Адаптер над Stepper/FormHeader из @tnsu/ui-kit (по образцу KPS Breadcrumbs): контракт props формы
|
// Адаптер над Stepper/FormHeader из @tnsu/ui-kit (по образцу KPS Breadcrumbs): контракт props формы
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe
|
|||||||
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
||||||
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
|
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
|
||||||
import { Step4Approval } from './Step4Approval/Step4Approval';
|
import { Step4Approval } from './Step4Approval/Step4Approval';
|
||||||
|
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
|
||||||
|
import { buildOffersFromSpRecords } from './utils/spOffers';
|
||||||
|
import { isUserInApprovalChain } from './utils/approvalAccess';
|
||||||
|
import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers';
|
||||||
import styles from './CtCreateForm.module.scss';
|
import styles from './CtCreateForm.module.scss';
|
||||||
import '@tnsu/ui-kit/styles.css';
|
import '@tnsu/ui-kit/styles.css';
|
||||||
|
|
||||||
@@ -30,12 +34,14 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
ensureHighestStep,
|
ensureHighestStep,
|
||||||
} = useCtForm();
|
} = useCtForm();
|
||||||
|
|
||||||
// Цены показываем только участникам согласования: рекомендующему (Инициатор) и согласующим.
|
// Цены и КП показываем только участникам согласования: рекомендующему (Инициатор) и согласующим.
|
||||||
// Автор/инициатор при создании и редактировании СТ (до отправки на согласование) видит цены всегда.
|
// Автор/инициатор при создании и редактировании СТ (до отправки на согласование) видит цены всегда.
|
||||||
// astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут,
|
// astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут,
|
||||||
// правило по цепочке согласования останется. Сопоставление участника — по отображаемому имени
|
// правило по цепочке согласования останется. Участника ищем по почте (ФИО — запасной вариант).
|
||||||
// (пока тестируем; при необходимости переключим на ID).
|
const userInApprovalChain = isUserInApprovalChain(
|
||||||
const userInApprovalChain = (formData.approvalMatrix ?? []).some(m => m.who === userDisplayName);
|
formData.approvalMatrix ?? [],
|
||||||
|
{ email: service.currentUserEmail, displayName: userDisplayName },
|
||||||
|
);
|
||||||
const hidePrices = isRestrictedView || (formData.approvalSent === true && !userInApprovalChain);
|
const hidePrices = isRestrictedView || (formData.approvalSent === true && !userInApprovalChain);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,6 +69,13 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
]).then(async ([ct, approvalMatrix, positions]) => {
|
]).then(async ([ct, approvalMatrix, positions]) => {
|
||||||
const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
|
const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
|
||||||
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
|
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
|
||||||
|
// Поставщики без КП этой СТ — для списка под таблицей сравнения (согласующий по ссылке).
|
||||||
|
const noOfferFromSp = noOfferRecordsToSuppliers(
|
||||||
|
await service.loadNoOfferSuppliers(initialCtId).catch(err => {
|
||||||
|
console.warn('[CtCreateForm] не удалось загрузить поставщиков без КП:', err);
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Build supplierSpId → offerId map from session uploadedKpFiles.
|
// Build supplierSpId → offerId map from session uploadedKpFiles.
|
||||||
// Лукап offerId через REST не читается (500), поэтому сначала берём из сессии, а без сессии —
|
// Лукап offerId через REST не читается (500), поэтому сначала берём из сессии, а без сессии —
|
||||||
@@ -85,17 +98,23 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
const rateById = new Map(loadedRates.map(r => [Number(r.id), r]));
|
const rateById = new Map(loadedRates.map(r => [Number(r.id), r]));
|
||||||
const spCurrencyRates = loadedRates;
|
const spCurrencyRates = loadedRates;
|
||||||
|
|
||||||
// Build unique suppliers from offers (used only as fallback when session has none)
|
// Колонки из офферов SP — по одной на (поставщик, файл КП): у поставщика может быть несколько
|
||||||
const supplierCurrencyMap = new Map<number, string>();
|
// КП в разных валютах. Используются, только когда в сессии колонок нет.
|
||||||
rawOffers.forEach(o => {
|
const currencyCodeOf = (valutaSpId?: number): string =>
|
||||||
supplierCurrencyMap.set(o.supplierSpId, o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT');
|
valutaSpId ? (rateById.get(valutaSpId)?.code ?? 'KZT') : 'KZT';
|
||||||
});
|
const spSuppliers = buildColumnsFromSpOffers(rawOffers.map(o => ({
|
||||||
|
supplierSpId: o.supplierSpId,
|
||||||
|
supplierName: o.supplierName,
|
||||||
|
offerFileSpId: o.offerFileSpId,
|
||||||
|
currencyCode: currencyCodeOf(o.valutaSpId),
|
||||||
|
})));
|
||||||
const supplierMap = new Map<number, string>();
|
const supplierMap = new Map<number, string>();
|
||||||
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
|
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
|
||||||
const spSuppliers = Array.from(supplierMap.entries()).map(([spId, name]) => ({
|
// Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP.
|
||||||
id: String(spId), spId, name, hasOffer: true as const,
|
const visibleColumns = formData.suppliers.length > 0 ? formData.suppliers : spSuppliers;
|
||||||
currency: supplierCurrencyMap.get(spId) ?? 'KZT',
|
const columnIdOf = (supplierSpId: number, offerFileSpId?: number): string =>
|
||||||
}));
|
findColumnForRecord(visibleColumns, supplierSpId, offerFileSpId)?.id
|
||||||
|
?? buildSupplierColumnId(supplierSpId, offerFileSpId);
|
||||||
|
|
||||||
// Build step3DeliveryKm from SP offers. Step 3 reads km by React-id key
|
// Build step3DeliveryKm from SP offers. Step 3 reads km by React-id key
|
||||||
// (`${pos.id}::${s.id}`), so map SP ids → React ids; for delivery the position is an
|
// (`${pos.id}::${s.id}`), so map SP ids → React ids; for delivery the position is an
|
||||||
@@ -106,36 +125,21 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
[...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])].forEach(p => {
|
[...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])].forEach(p => {
|
||||||
if (p.spId !== undefined) posReactIdBySpId.set(p.spId, p.id);
|
if (p.spId !== undefined) posReactIdBySpId.set(p.spId, p.id);
|
||||||
});
|
});
|
||||||
const supReactIdBySpId = new Map<number, string>();
|
|
||||||
formData.suppliers.forEach(s => {
|
|
||||||
if (s.spId !== undefined) supReactIdBySpId.set(s.spId, s.id);
|
|
||||||
});
|
|
||||||
const step3DeliveryKm: Record<string, number> = {};
|
const step3DeliveryKm: Record<string, number> = {};
|
||||||
rawOffers.forEach(o => {
|
rawOffers.forEach(o => {
|
||||||
if (o.deliveryDistanceKm !== undefined) {
|
if (o.deliveryDistanceKm !== undefined) {
|
||||||
const posKey = posReactIdBySpId.get(o.positionSpId) ?? String(o.positionSpId);
|
const posKey = posReactIdBySpId.get(o.positionSpId) ?? String(o.positionSpId);
|
||||||
const supKey = supReactIdBySpId.get(o.supplierSpId) ?? String(o.supplierSpId);
|
const supKey = columnIdOf(o.supplierSpId, o.offerFileSpId);
|
||||||
step3DeliveryKm[`${posKey}::${supKey}`] = o.deliveryDistanceKm;
|
step3DeliveryKm[`${posKey}::${supKey}`] = o.deliveryDistanceKm;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build offers in domain format, restoring offerId from session
|
// Build offers in domain format. Файл КП берём из самой записи (offerFileId) — карта из сессии
|
||||||
const offers = rawOffers.map(o => {
|
// знает один файл на поставщика и при нескольких КП отдала бы обеим колонкам один и тот же.
|
||||||
const offerIdNum = offerIdBySupplierSpId.get(o.supplierSpId) ?? o.offerFileSpId;
|
const offers = buildOffersFromSpRecords(rawOffers, {
|
||||||
const currencyCode = o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT';
|
columnIdOf,
|
||||||
return {
|
currencyCodeOf,
|
||||||
id: '',
|
fallbackOfferFileId: supplierSpId => offerIdBySupplierSpId.get(supplierSpId),
|
||||||
supplierId: String(o.supplierSpId),
|
|
||||||
positionId: String(o.positionSpId),
|
|
||||||
unit: o.unit,
|
|
||||||
pricePerUnit: o.price,
|
|
||||||
quantity: o.quantity,
|
|
||||||
currencyCode,
|
|
||||||
pricePerUnitKzt: o.priceTenge || o.price,
|
|
||||||
totalPriceKzt: o.summaTenge,
|
|
||||||
totalWithExtraKzt: o.summaTenge,
|
|
||||||
offerId: offerIdNum !== undefined ? String(offerIdNum) : undefined,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reconstruct uploadedKpFiles for users without session (other users opening CT via link)
|
// Reconstruct uploadedKpFiles for users without session (other users opening CT via link)
|
||||||
@@ -159,7 +163,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
|
...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
|
||||||
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits.
|
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits.
|
||||||
// Only use SP-derived data when session has none, or when opened via notification link (always fresh).
|
// Only use SP-derived data when session has none, or when opened via notification link (always fresh).
|
||||||
...(formData.suppliers.length === 0 ? { suppliers: spSuppliers } : {}),
|
suppliers: withNoOfferSuppliers(formData.suppliers.length === 0 ? spSuppliers : formData.suppliers, noOfferFromSp),
|
||||||
...(isNotificationLink || formData.offers.length === 0 ? { offers } : {}),
|
...(isNotificationLink || formData.offers.length === 0 ? { offers } : {}),
|
||||||
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
|
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
|
||||||
...(isNotificationLink ? { approvalSent: true } : {}),
|
...(isNotificationLink ? { approvalSent: true } : {}),
|
||||||
|
|||||||
+3
-1
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Checkbox } from '@tnsu/ui-kit';
|
import { Checkbox } from '@tnsu/ui-kit';
|
||||||
import { Request, Position } from '../types/domain';
|
import { Request, Position } from '../types/domain';
|
||||||
|
import { countSelectedInRequest } from './step1Plan';
|
||||||
import styles from './RequestsTable.module.scss';
|
import styles from './RequestsTable.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -45,8 +46,9 @@ export const RequestsTable: React.FC<Props> = ({
|
|||||||
if (willExpand && onExpand) onExpand(id);
|
if (willExpand && onExpand) onExpand(id);
|
||||||
}, [expandedRequestIds, onExpand]);
|
}, [expandedRequestIds, onExpand]);
|
||||||
|
|
||||||
|
// По id позиций, а не по загруженным: позиции нераскрытой потребности ещё не загружены.
|
||||||
const getRequestSelectionCount = (request: Request): number =>
|
const getRequestSelectionCount = (request: Request): number =>
|
||||||
request.positions.filter(p => selectedPositionIds.has(p.id)).length;
|
countSelectedInRequest(selectedPositionIds, request.id);
|
||||||
|
|
||||||
const isProjectLocked = (request: Request): boolean =>
|
const isProjectLocked = (request: Request): boolean =>
|
||||||
lockedProjectId !== undefined && request.project.id !== lockedProjectId;
|
lockedProjectId !== undefined && request.project.id !== lockedProjectId;
|
||||||
|
|||||||
+41
-14
@@ -6,6 +6,7 @@ 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 styles from './Step1PositionSelection.module.scss';
|
import styles from './Step1PositionSelection.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -68,14 +69,13 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
|||||||
let filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests;
|
let filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests;
|
||||||
// Режим «Только выбранные» — оставляем заявки, где есть хотя бы одна выбранная позиция,
|
// Режим «Только выбранные» — оставляем заявки, где есть хотя бы одна выбранная позиция,
|
||||||
// чтобы можно было свериться со своим выбором с любой страницы, не листая назад.
|
// чтобы можно было свериться со своим выбором с любой страницы, не листая назад.
|
||||||
|
// Выбор считаем по id позиций, а не по загруженным позициям: они грузятся только при раскрытии,
|
||||||
|
// и после возврата на шаг 1 «Только выбранные» иначе показывал пустой список.
|
||||||
|
const hasSelected = (r: Request): boolean => countSelectedInRequest(selectedPositionIds, r.id) > 0;
|
||||||
if (showOnlySelected) {
|
if (showOnlySelected) {
|
||||||
filtered = filtered.filter(r => r.positions.some(p => selectedPositionIds.has(p.id)));
|
filtered = filtered.filter(hasSelected);
|
||||||
}
|
}
|
||||||
return [...filtered].sort((a, b) => {
|
return [...filtered].sort((a, b) => (hasSelected(a) ? 0 : 1) - (hasSelected(b) ? 0 : 1));
|
||||||
const aSelected = a.positions.some(p => selectedPositionIds.has(p.id)) ? 0 : 1;
|
|
||||||
const bSelected = b.positions.some(p => selectedPositionIds.has(p.id)) ? 0 : 1;
|
|
||||||
return aSelected - bSelected;
|
|
||||||
});
|
|
||||||
}, [requests, searchQuery, selectedPositionIds, showOnlySelected]);
|
}, [requests, searchQuery, selectedPositionIds, showOnlySelected]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredRequests.length / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(filteredRequests.length / PAGE_SIZE));
|
||||||
@@ -157,12 +157,16 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedPositions: Position[] = Array.from(selectedPositionIds)
|
// Позиции нераскрытых потребностей берём из сохранённых в форме — иначе после возврата на шаг 1
|
||||||
.map(id => positionMap.get(id)?.position)
|
// выбор есть, а «Сохранить» неактивна.
|
||||||
.filter((p): p is Position => p !== undefined);
|
const selectedPositions: Position[] = resolveSelectedPositions(
|
||||||
|
Array.from(selectedPositionIds),
|
||||||
|
id => positionMap.get(id)?.position,
|
||||||
|
formData.selectedPositions,
|
||||||
|
);
|
||||||
|
|
||||||
const selectedProject = selectedPositions.length > 0
|
const selectedProject = selectedPositions.length > 0
|
||||||
? positionMap.get(selectedPositions[0].id)?.request.project ?? null
|
? positionMap.get(selectedPositions[0].id)?.request.project ?? formData.selectedProject ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const canSave = selectedPositions.length > 0;
|
const canSave = selectedPositions.length > 0;
|
||||||
@@ -174,16 +178,39 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
|||||||
setSaveError(undefined);
|
setSaveError(undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ctNumber = await service.generateCtNumber(selectedProject.name);
|
|
||||||
const ct = await service.createCT(selectedProject.name, ctNumber);
|
|
||||||
const enrichedPositions = selectedPositions.map(p => ({
|
const enrichedPositions = selectedPositions.map(p => ({
|
||||||
...p,
|
...p,
|
||||||
disciplineCode: p.disciplineCode || (positionMap.get(p.id)?.request.discipline ?? ''),
|
disciplineCode: p.disciplineCode || (positionMap.get(p.id)?.request.discipline ?? ''),
|
||||||
}));
|
}));
|
||||||
const savedPositions = await service.createCTPositions(ct.id, enrichedPositions);
|
const initiatorCompany = positionMap.get(selectedPositions[0].id)?.request.organization
|
||||||
|
?? formData.initiatorCompany ?? '';
|
||||||
|
|
||||||
const initiatorCompany = positionMap.get(selectedPositions[0].id)?.request.organization ?? '';
|
if (formData.ct) {
|
||||||
|
// СТ уже создана (вернулись на шаг 1) — обновляем её позиции, а не создаём новую СТ:
|
||||||
|
// иначе предложения остаются в прежней СТ, а новая по ссылке открывается пустой.
|
||||||
|
const ct = formData.ct;
|
||||||
|
const plan = planStep1Save(formData.selectedPositions, enrichedPositions);
|
||||||
|
await service.removeCTPositions(
|
||||||
|
plan.toRemove.map(p => p.spId).filter((id): id is number => id !== undefined),
|
||||||
|
);
|
||||||
|
const created = plan.toCreate.length > 0 ? await service.createCTPositions(ct.id, plan.toCreate) : [];
|
||||||
|
if (ct.projectName !== selectedProject.name) {
|
||||||
|
await service.updateCTProject(ct.id, selectedProject.name);
|
||||||
|
}
|
||||||
|
const removedIds = new Set(plan.toRemove.map(p => p.id));
|
||||||
|
onSave({
|
||||||
|
ct: { ...ct, projectName: selectedProject.name },
|
||||||
|
selectedProject,
|
||||||
|
selectedPositions: [...plan.toKeep, ...created],
|
||||||
|
initiatorCompany,
|
||||||
|
offers: formData.offers.filter(o => !removedIds.has(o.positionId)),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const ctNumber = await service.generateCtNumber(selectedProject.name);
|
||||||
|
const ct = await service.createCT(selectedProject.name, ctNumber);
|
||||||
|
const savedPositions = await service.createCTPositions(ct.id, enrichedPositions);
|
||||||
onSave({ ct, selectedProject, selectedPositions: savedPositions, initiatorCompany });
|
onSave({ ct, selectedProject, selectedPositions: savedPositions, initiatorCompany });
|
||||||
|
}
|
||||||
onStepComplete(1);
|
onStepComplete(1);
|
||||||
onNext();
|
onNext();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
import { countSelectedInRequest, planStep1Save, resolveSelectedPositions } from './step1Plan';
|
||||||
|
import { Position } from '../types/domain';
|
||||||
|
|
||||||
|
function makePosition(id: string, overrides: Partial<Position> = {}): Position {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
requestId: id.split('__')[0],
|
||||||
|
requestNumber: '051/26-08/0015',
|
||||||
|
materialName: `Позиция ${id}`,
|
||||||
|
disciplineCode: '',
|
||||||
|
smrType: '',
|
||||||
|
unit: 'шт',
|
||||||
|
agskCode: '',
|
||||||
|
uniqueCode: '',
|
||||||
|
quantity: 1,
|
||||||
|
positionType: 'ТМЦ',
|
||||||
|
deadline: '',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('planStep1Save (СТ уже создана — повторное сохранение шага 1)', () => {
|
||||||
|
// СТ №10 → вернулись на шаг 1 → «Сохранить» создавало СТ №11 без предложений.
|
||||||
|
// Теперь позиции существующей СТ обновляются: оставленные — как есть, новые — дописать, снятые — убрать.
|
||||||
|
const saved = [
|
||||||
|
makePosition('req1__0', { spId: 1598 }),
|
||||||
|
makePosition('req1__1', { spId: 1599 }),
|
||||||
|
makePosition('req1__2', { spId: 1600 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
it('тот же выбор — ничего не создаём и не удаляем', () => {
|
||||||
|
const plan = planStep1Save(saved, saved.map(p => ({ ...p, spId: undefined })));
|
||||||
|
|
||||||
|
expect(plan.toCreate).toEqual([]);
|
||||||
|
expect(plan.toRemove).toEqual([]);
|
||||||
|
expect(plan.toKeep.map(p => p.spId)).toEqual([1598, 1599, 1600]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('добавленная позиция — создать, снятая — убрать', () => {
|
||||||
|
const selected = [makePosition('req1__0'), makePosition('req1__2'), makePosition('req1__3')];
|
||||||
|
|
||||||
|
const plan = planStep1Save(saved, selected);
|
||||||
|
|
||||||
|
expect(plan.toKeep.map(p => p.spId)).toEqual([1598, 1600]);
|
||||||
|
expect(plan.toCreate.map(p => p.id)).toEqual(['req1__3']);
|
||||||
|
expect(plan.toRemove.map(p => p.spId)).toEqual([1599]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('у оставленной позиции берём сохранённую версию (с SP Id)', () => {
|
||||||
|
const plan = planStep1Save(saved, [makePosition('req1__1')]);
|
||||||
|
|
||||||
|
expect(plan.toKeep).toEqual([saved[1]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('countSelectedInRequest', () => {
|
||||||
|
// Позиции потребности грузятся только при раскрытии — считаем выбранные по id «потребность__номер».
|
||||||
|
it('считает выбранные позиции потребности без загрузки её позиций', () => {
|
||||||
|
const selected = new Set(['req1__0', 'req1__4', 'req2__0']);
|
||||||
|
|
||||||
|
expect(countSelectedInRequest(selected, 'req1')).toBe(2);
|
||||||
|
expect(countSelectedInRequest(selected, 'req2')).toBe(1);
|
||||||
|
expect(countSelectedInRequest(selected, 'req3')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не путает потребность с похожим id', () => {
|
||||||
|
expect(countSelectedInRequest(new Set(['req10__0']), 'req1')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveSelectedPositions', () => {
|
||||||
|
// Вернулись на шаг 1: потребность не раскрыта — выбранные позиции берём из сохранённых,
|
||||||
|
// иначе «Сохранить» неактивна, хотя выбор есть.
|
||||||
|
it('берёт загруженную позицию, а если её нет — сохранённую', () => {
|
||||||
|
const loaded = new Map([['req1__1', makePosition('req1__1', { quantity: 7 })]]);
|
||||||
|
const saved = [makePosition('req1__0', { spId: 1598 }), makePosition('req1__1', { spId: 1599 })];
|
||||||
|
|
||||||
|
const result = resolveSelectedPositions(['req1__0', 'req1__1', 'req9__0'], id => loaded.get(id), saved);
|
||||||
|
|
||||||
|
expect(result.map(p => ({ id: p.id, quantity: p.quantity }))).toEqual([
|
||||||
|
{ id: 'req1__0', quantity: 1 },
|
||||||
|
{ id: 'req1__1', quantity: 7 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Position } from '../types/domain';
|
||||||
|
|
||||||
|
// id позиции заявки — `${id потребности}__${номер}` (см. CtService._mapPosition).
|
||||||
|
const POSITION_ID_SEPARATOR = '__';
|
||||||
|
|
||||||
|
// Повторное сохранение шага 1, когда СТ уже создана: что оставить (позиции уже в SP), что дописать,
|
||||||
|
// что убрать. Раньше шаг 1 всегда создавал новую СТ — предложения оставались в прежней, а новая
|
||||||
|
// открывалась по ссылке пустой.
|
||||||
|
export interface Step1SavePlan {
|
||||||
|
toKeep: Position[]; // сохранённые версии (с SP Id)
|
||||||
|
toCreate: Position[];
|
||||||
|
toRemove: Position[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planStep1Save(saved: Position[], selected: Position[]): Step1SavePlan {
|
||||||
|
const selectedIds = new Set(selected.map(p => p.id));
|
||||||
|
const savedById = new Map(saved.map(p => [p.id, p]));
|
||||||
|
|
||||||
|
const toKeep: Position[] = [];
|
||||||
|
const toCreate: Position[] = [];
|
||||||
|
selected.forEach(p => {
|
||||||
|
const savedPosition = savedById.get(p.id);
|
||||||
|
if (savedPosition?.spId !== undefined) {
|
||||||
|
toKeep.push(savedPosition);
|
||||||
|
} else {
|
||||||
|
toCreate.push(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
toKeep,
|
||||||
|
toCreate,
|
||||||
|
toRemove: saved.filter(p => p.spId !== undefined && !selectedIds.has(p.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сколько позиций потребности выбрано — по id, без загрузки её позиций (они грузятся при раскрытии).
|
||||||
|
export function countSelectedInRequest(selectedIds: Set<string>, requestId: string): number {
|
||||||
|
const prefix = `${requestId}${POSITION_ID_SEPARATOR}`;
|
||||||
|
let count = 0;
|
||||||
|
selectedIds.forEach(id => { if (id.startsWith(prefix)) count++; });
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме.
|
||||||
|
// Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна.
|
||||||
|
export function resolveSelectedPositions(
|
||||||
|
selectedIds: string[],
|
||||||
|
findLoaded: (id: string) => Position | undefined,
|
||||||
|
saved: Position[],
|
||||||
|
): Position[] {
|
||||||
|
const savedById = new Map(saved.map(p => [p.id, p]));
|
||||||
|
return selectedIds
|
||||||
|
.map(id => findLoaded(id) ?? savedById.get(id))
|
||||||
|
.filter((p): p is Position => p !== undefined);
|
||||||
|
}
|
||||||
+18
@@ -206,3 +206,21 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// «Загруженные КП»: файлы одного поставщика сгруппированы, у нескольких КП — пометка.
|
||||||
|
.kpGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpGroupHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpGroupNote {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|||||||
+52
-7
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Alert, Spinner } from '@tnsu/ui-kit';
|
import { Button, Alert, Spinner, confirmModal } from '@tnsu/ui-kit';
|
||||||
import { CurrencyRate, FormStep, Supplier } from '../types/domain';
|
import { CurrencyRate, FormStep, Supplier } from '../types/domain';
|
||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
@@ -11,6 +11,7 @@ import { useKpProcessing } from './useKpProcessing';
|
|||||||
import { persistStep2 } from './step2Persist';
|
import { persistStep2 } from './step2Persist';
|
||||||
import { SuppliersWithoutKp } from './SuppliersWithoutKp';
|
import { SuppliersWithoutKp } from './SuppliersWithoutKp';
|
||||||
import { UploadedKpList } from './UploadedKpList';
|
import { UploadedKpList } from './UploadedKpList';
|
||||||
|
import { removeKpFileFromComparison } from './kpRemoval';
|
||||||
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
|
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
|
||||||
import styles from './Step2CommercialOffers.module.scss';
|
import styles from './Step2CommercialOffers.module.scss';
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
const [noOfferSuppliers, setNoOfferSuppliers] = useState<NoOfferEntry[]>(() =>
|
const [noOfferSuppliers, setNoOfferSuppliers] = useState<NoOfferEntry[]>(() =>
|
||||||
(formData.suppliers ?? [])
|
(formData.suppliers ?? [])
|
||||||
.filter(s => !s.hasOffer)
|
.filter(s => !s.hasOffer)
|
||||||
.map(s => ({ localId: s.id, name: s.name, comment: '' })),
|
.map(s => ({ localId: s.id, name: s.name, comment: s.comment ?? '' })),
|
||||||
);
|
);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
||||||
@@ -69,27 +70,71 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
id: s.localId,
|
id: s.localId,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
hasOffer: false,
|
hasOffer: false,
|
||||||
|
comment: s.comment,
|
||||||
}));
|
}));
|
||||||
onSave({ suppliers: [...withOffer, ...withoutOffer] });
|
onSave({ suppliers: [...withOffer, ...withoutOffer] });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Смена валюты уже загруженного КП (список «Загруженные КП»). Если файл ещё в очереди этой сессии,
|
// Смена валюты уже загруженного КП (список «Загруженные КП»). Если файл ещё в очереди этой сессии,
|
||||||
// синхронизируем и её: иначе persistStep2 пересоберёт поставщика из kpFiles со старой валютой.
|
// синхронизируем и её: иначе persistStep2 пересоберёт поставщика из kpFiles со старой валютой.
|
||||||
|
// Валюта — у конкретного файла: у поставщика может быть несколько КП в разных валютах.
|
||||||
const handleUploadedCurrencyChange = (fileSpId: number, currencyCode: string): void => {
|
const handleUploadedCurrencyChange = (fileSpId: number, currencyCode: string): void => {
|
||||||
const uploaded = formData.uploadedKpFiles.find(u => u.spId === fileSpId);
|
const uploaded = formData.uploadedKpFiles.find(u => u.spId === fileSpId);
|
||||||
if (!uploaded?.supplierName) return;
|
if (!uploaded?.supplierName) return;
|
||||||
onSave(applySupplierCurrency(
|
onSave(applySupplierCurrency(
|
||||||
formData.suppliers ?? [],
|
formData.suppliers ?? [],
|
||||||
formData.offers ?? [],
|
formData.offers ?? [],
|
||||||
uploaded.supplierName,
|
uploaded,
|
||||||
currencyCode,
|
currencyCode,
|
||||||
currencyRates,
|
currencyRates,
|
||||||
));
|
));
|
||||||
kp.kpFiles
|
kp.kpFiles
|
||||||
.filter(f => f.supplierName === uploaded.supplierName)
|
.filter(f => f.spId === fileSpId)
|
||||||
.forEach(f => kp.handleCurrencyChange(f.localId, currencyCode));
|
.forEach(f => kp.handleCurrencyChange(f.localId, currencyCode));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Убрать КП из сравнения (корзина в «Загруженные КП» или крестик у обработанного файла): так
|
||||||
|
// заменяют исправленный КП — загружают новый, старый убирают. Колонка и цены уходят сразу,
|
||||||
|
// записи этого файла в SP — в корзину. Сам файл остаётся в библиотеке.
|
||||||
|
const handleRemoveKpFile = async (fileSpId: number): Promise<void> => {
|
||||||
|
const file = formData.uploadedKpFiles.find(u => u.spId === fileSpId);
|
||||||
|
if (!file) {
|
||||||
|
kp.removeFileBySpId(fileSpId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmed = await confirmModal({
|
||||||
|
title: 'Убрать КП из сравнения?',
|
||||||
|
message: `${file.name}${file.supplierName ? ` (${file.supplierName})` : ''} — его колонка и цены уйдут из таблицы сравнения. Файл останется в библиотеке КП.`,
|
||||||
|
confirmText: 'Убрать',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
kp.removeFileBySpId(fileSpId);
|
||||||
|
onSave(removeKpFileFromComparison(
|
||||||
|
{ suppliers: formData.suppliers ?? [], offers: formData.offers ?? [], uploadedKpFiles: formData.uploadedKpFiles },
|
||||||
|
file,
|
||||||
|
));
|
||||||
|
const positionSpIds = [...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])]
|
||||||
|
.map(p => p.spId)
|
||||||
|
.filter((id): id is number => id !== undefined);
|
||||||
|
service.recycleKpFileOffers(fileSpId, positionSpIds).catch(err => {
|
||||||
|
// Не критично: «Сохранить» на Шаге 2/3 перезапишет предложения позиций по текущей таблице.
|
||||||
|
console.warn('[КП] не удалось убрать предложения КП из SP:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Крестик в очереди загрузки: обработанный КП уже в сравнении — убираем его так же, как корзиной;
|
||||||
|
// файл в процессе или с ошибкой — просто убираем из очереди.
|
||||||
|
const handleQueueFileRemove = (localId: string): void => {
|
||||||
|
const file = kp.kpFiles.find(f => f.localId === localId);
|
||||||
|
if (file?.status === 'done' && file.spId !== undefined) {
|
||||||
|
handleRemoveKpFile(file.spId).catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
kp.handleFileRemove(localId);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
const handleSave = async (): Promise<void> => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveError(undefined);
|
setSaveError(undefined);
|
||||||
@@ -128,7 +173,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
<FileUploadArea
|
<FileUploadArea
|
||||||
files={uploadedFiles}
|
files={uploadedFiles}
|
||||||
onFilesAdded={files => { kp.handleFilesAdded(files).catch(() => undefined); }}
|
onFilesAdded={files => { kp.handleFilesAdded(files).catch(() => undefined); }}
|
||||||
onFileRemove={kp.handleFileRemove}
|
onFileRemove={handleQueueFileRemove}
|
||||||
currencyOptions={currencyOptions}
|
currencyOptions={currencyOptions}
|
||||||
onCurrencyChange={kp.handleCurrencyChange}
|
onCurrencyChange={kp.handleCurrencyChange}
|
||||||
disabled={isNotificationLink || isReadOnly}
|
disabled={isNotificationLink || isReadOnly}
|
||||||
@@ -152,9 +197,9 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
files={formData.uploadedKpFiles}
|
files={formData.uploadedKpFiles}
|
||||||
service={service}
|
service={service}
|
||||||
currencyOptions={currencyOptions}
|
currencyOptions={currencyOptions}
|
||||||
getCurrency={supplierName => getSupplierCurrency(formData.suppliers ?? [], supplierName)}
|
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
|
||||||
onCurrencyChange={handleUploadedCurrencyChange}
|
onCurrencyChange={handleUploadedCurrencyChange}
|
||||||
onRemove={(spId) => onSave({ uploadedKpFiles: formData.uploadedKpFiles.filter(u => u.spId !== spId) })}
|
onRemove={spId => { handleRemoveKpFile(spId).catch(() => undefined); }}
|
||||||
disabled={isNotificationLink || isReadOnly}
|
disabled={isNotificationLink || isReadOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+26
-45
@@ -1,67 +1,48 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { IconButton } from '@tnsu/ui-kit';
|
|
||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
|
import { KpFileRef } from './kpCurrency';
|
||||||
|
import { groupUploadedKpFiles } from './kpRemoval';
|
||||||
|
import { UploadedKpRow } from './UploadedKpRow';
|
||||||
import styles from './Step2CommercialOffers.module.scss';
|
import styles from './Step2CommercialOffers.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
files: CTFormData['uploadedKpFiles'];
|
files: CTFormData['uploadedKpFiles'];
|
||||||
service: CtService;
|
service: CtService;
|
||||||
currencyOptions: string[];
|
currencyOptions: string[];
|
||||||
getCurrency: (supplierName: string | undefined) => string;
|
getCurrency: (file: KpFileRef) => string;
|
||||||
onCurrencyChange: (spId: number, currencyCode: string) => void;
|
onCurrencyChange: (spId: number, currencyCode: string) => void;
|
||||||
onRemove: (spId: number) => void;
|
onRemove: (spId: number) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Секция «Загруженные КП» (постоянный список из formData): ссылка на файл, валюта КП и удаление.
|
// Секция «Загруженные КП» (постоянный список из formData), сгруппированная по поставщику. Каждый КП —
|
||||||
// Валюту здесь можно поправить и после возврата с Шага 3 — очередь загрузки к этому моменту уже пуста.
|
// отдельная колонка сравнения; несколько КП одного поставщика стоят рядом с пометкой, лишний
|
||||||
export const UploadedKpList: React.FC<Props> = ({
|
// (напр. старая версия КП) убирают корзиной. Валюту можно поправить и после возврата с Шага 3.
|
||||||
files,
|
export const UploadedKpList: React.FC<Props> = ({ files, ...rowProps }) => (
|
||||||
service,
|
|
||||||
currencyOptions,
|
|
||||||
getCurrency,
|
|
||||||
onCurrencyChange,
|
|
||||||
onRemove,
|
|
||||||
disabled,
|
|
||||||
}) => (
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h3 className={styles.sectionTitle}>Загруженные КП</h3>
|
<h3 className={styles.sectionTitle}>Загруженные КП</h3>
|
||||||
<ul className={styles.noOfferList}>
|
<ul className={styles.noOfferList}>
|
||||||
{files.map(f => (
|
{groupUploadedKpFiles(files).map(group => {
|
||||||
<li key={f.spId} className={styles.noOfferItem}>
|
const isMultiple = group.files.length > 1;
|
||||||
{f.supplierName && <span className={styles.noOfferName}>{f.supplierName}</span>}
|
return (
|
||||||
<a
|
<li key={group.files[0].spId} className={styles.kpGroup}>
|
||||||
href={service.getKpFileUrl(f.name)}
|
{isMultiple && (
|
||||||
target="_blank"
|
<div className={styles.kpGroupHeader}>
|
||||||
rel="noopener noreferrer"
|
<span className={styles.noOfferName}>{group.supplierName}</span>
|
||||||
className={styles.kpFileLink}
|
<span className={styles.kpGroupNote}>
|
||||||
>
|
{group.files.length} КП — сравниваются отдельными колонками; лишний уберите корзиной
|
||||||
{f.name}
|
</span>
|
||||||
</a>
|
</div>
|
||||||
{disabled ? (
|
|
||||||
<span className={styles.kpCurrencyCode}>{getCurrency(f.supplierName)}</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<select
|
|
||||||
className={styles.kpCurrencySelect}
|
|
||||||
value={getCurrency(f.supplierName)}
|
|
||||||
onChange={e => onCurrencyChange(f.spId, e.target.value)}
|
|
||||||
// Валюта хранится на поставщике — без него менять нечего.
|
|
||||||
disabled={!f.supplierName}
|
|
||||||
aria-label={`Валюта КП ${f.name}`}
|
|
||||||
>
|
|
||||||
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
|
|
||||||
</select>
|
|
||||||
<IconButton
|
|
||||||
icon="trash"
|
|
||||||
title="Удалить КП из списка"
|
|
||||||
onClick={() => onRemove(f.spId)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</li>
|
<ul className={styles.noOfferList}>
|
||||||
|
{group.files.map(file => (
|
||||||
|
<UploadedKpRow key={file.spId} file={file} showSupplierName={!isMultiple} {...rowProps} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { IconButton } from '@tnsu/ui-kit';
|
||||||
|
import { CtService } from '../../services/CtService';
|
||||||
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
|
import { KpFileRef } from './kpCurrency';
|
||||||
|
import styles from './Step2CommercialOffers.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: CTFormData['uploadedKpFiles'][number];
|
||||||
|
// Название поставщика в строке — когда у него один КП (в группе из нескольких оно в заголовке группы).
|
||||||
|
showSupplierName: boolean;
|
||||||
|
service: CtService;
|
||||||
|
currencyOptions: string[];
|
||||||
|
getCurrency: (file: KpFileRef) => string;
|
||||||
|
onCurrencyChange: (spId: number, currencyCode: string) => void;
|
||||||
|
onRemove: (spId: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка «Загруженные КП»: ссылка на файл, валюта КП и «убрать из сравнения».
|
||||||
|
export const UploadedKpRow: React.FC<Props> = ({
|
||||||
|
file,
|
||||||
|
showSupplierName,
|
||||||
|
service,
|
||||||
|
currencyOptions,
|
||||||
|
getCurrency,
|
||||||
|
onCurrencyChange,
|
||||||
|
onRemove,
|
||||||
|
disabled,
|
||||||
|
}) => (
|
||||||
|
<li className={styles.noOfferItem}>
|
||||||
|
{showSupplierName && file.supplierName && <span className={styles.noOfferName}>{file.supplierName}</span>}
|
||||||
|
<a
|
||||||
|
href={service.getKpFileUrl(file.name)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.kpFileLink}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</a>
|
||||||
|
{disabled ? (
|
||||||
|
<span className={styles.kpCurrencyCode}>{getCurrency(file)}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className={styles.kpCurrencySelect}
|
||||||
|
value={getCurrency(file)}
|
||||||
|
onChange={e => onCurrencyChange(file.spId, e.target.value)}
|
||||||
|
// Валюта хранится на колонке поставщика — без него менять нечего.
|
||||||
|
disabled={!file.supplierName}
|
||||||
|
aria-label={`Валюта КП ${file.name}`}
|
||||||
|
>
|
||||||
|
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
<IconButton
|
||||||
|
icon="trash"
|
||||||
|
title="Убрать КП из сравнения"
|
||||||
|
onClick={() => onRemove(file.spId)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
|
||||||
|
import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
|
||||||
|
|
||||||
|
const USD: CurrencyRate = { code: 'USD', name: 'Доллар США', rateToKzt: 440 };
|
||||||
|
|
||||||
|
const KZT_COLUMN: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан»', hasOffer: true, currency: 'KZT' };
|
||||||
|
const USD_COLUMN: Supplier = { id: '263_290', spId: 263, kpFileSpId: 290, name: 'ТОО «Арлан»', hasOffer: true, currency: 'KZT' };
|
||||||
|
const LEGACY: Supplier = { id: '264', spId: 264, name: 'ТОО «Кала»', hasOffer: true, currency: 'KZT' };
|
||||||
|
|
||||||
|
function makeOffer(supplierId: string): SupplierOffer {
|
||||||
|
return {
|
||||||
|
id: '', supplierId, positionId: 'p1', pricePerUnit: 10, quantity: 2, currencyCode: 'KZT',
|
||||||
|
pricePerUnitKzt: 10, totalPriceKzt: 20, totalWithExtraKzt: 20,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('applySupplierCurrency', () => {
|
||||||
|
// Два КП одного поставщика: смена валюты одного файла не должна пересчитать цены другого.
|
||||||
|
it('меняет валюту только у колонки этого файла КП', () => {
|
||||||
|
const { suppliers, offers } = applySupplierCurrency(
|
||||||
|
[KZT_COLUMN, USD_COLUMN],
|
||||||
|
[makeOffer('263_277'), makeOffer('263_290')],
|
||||||
|
{ spId: 290, supplierName: 'ТОО «Арлан»' },
|
||||||
|
'USD',
|
||||||
|
[USD],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(suppliers.map(s => s.currency)).toEqual(['KZT', 'USD']);
|
||||||
|
expect(offers[0].pricePerUnitKzt).toBe(10);
|
||||||
|
expect(offers[1]).toMatchObject({ currencyCode: 'USD', pricePerUnitKzt: 4400, totalPriceKzt: 8800 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('старая колонка без файла находится по имени поставщика', () => {
|
||||||
|
const { suppliers } = applySupplierCurrency([LEGACY], [], { spId: 278, supplierName: 'ТОО «Кала»' }, 'USD', [USD]);
|
||||||
|
|
||||||
|
expect(suppliers[0].currency).toBe('USD');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSupplierCurrency', () => {
|
||||||
|
it('валюта колонки этого файла КП', () => {
|
||||||
|
const columns = [KZT_COLUMN, { ...USD_COLUMN, currency: 'USD' }];
|
||||||
|
|
||||||
|
expect(getSupplierCurrency(columns, { spId: 290, supplierName: 'ТОО «Арлан»' })).toBe('USD');
|
||||||
|
expect(getSupplierCurrency(columns, { spId: 277, supplierName: 'ТОО «Арлан»' })).toBe('KZT');
|
||||||
|
});
|
||||||
|
});
|
||||||
+21
-10
@@ -2,28 +2,40 @@ import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
|
|||||||
|
|
||||||
const BASE_CURRENCY = 'KZT';
|
const BASE_CURRENCY = 'KZT';
|
||||||
|
|
||||||
// Валюта хранится на поставщике (Шаг 3 читает supplier.currency), а офферы несут цену в валюте КП
|
// Файл из списка «Загруженные КП», чью валюту меняют/читают.
|
||||||
|
export interface KpFileRef {
|
||||||
|
spId: number;
|
||||||
|
supplierName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонки этого файла КП. У поставщика может быть несколько КП (напр. KZT и USD) — валюта у каждой
|
||||||
|
// колонки своя. Старые колонки без файла (kpFileSpId) ищем по имени поставщика, как раньше.
|
||||||
|
function findFileColumns(suppliers: Supplier[], file: KpFileRef): Supplier[] {
|
||||||
|
const byFile = suppliers.filter(s => s.hasOffer && s.kpFileSpId === file.spId);
|
||||||
|
if (byFile.length > 0) return byFile;
|
||||||
|
return suppliers.filter(s => s.hasOffer && s.kpFileSpId === undefined && s.name === file.supplierName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валюта хранится на колонке поставщика (Шаг 3 читает supplier.currency), а офферы несут цену в валюте КП
|
||||||
// и её пересчёт в тенге. При смене валюты уже загруженного КП обновляем и то и другое, чтобы
|
// и её пересчёт в тенге. При смене валюты уже загруженного КП обновляем и то и другое, чтобы
|
||||||
// таблица сравнения и сохранение офферов в SP (valutaId) видели одну и ту же валюту.
|
// таблица сравнения и сохранение офферов в SP (valutaId) видели одну и ту же валюту.
|
||||||
export function applySupplierCurrency(
|
export function applySupplierCurrency(
|
||||||
suppliers: Supplier[],
|
suppliers: Supplier[],
|
||||||
offers: SupplierOffer[],
|
offers: SupplierOffer[],
|
||||||
supplierName: string,
|
file: KpFileRef,
|
||||||
currencyCode: string,
|
currencyCode: string,
|
||||||
currencyRates: CurrencyRate[],
|
currencyRates: CurrencyRate[],
|
||||||
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
|
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
|
||||||
const supplierIds = new Set(
|
const columnIds = new Set(findFileColumns(suppliers, file).map(s => s.id));
|
||||||
suppliers.filter(s => s.hasOffer && s.name === supplierName).map(s => s.id),
|
|
||||||
);
|
|
||||||
const rate = currencyCode === BASE_CURRENCY
|
const rate = currencyCode === BASE_CURRENCY
|
||||||
? 1
|
? 1
|
||||||
: (currencyRates.find(r => r.code === currencyCode)?.rateToKzt ?? 1);
|
: (currencyRates.find(r => r.code === currencyCode)?.rateToKzt ?? 1);
|
||||||
|
|
||||||
const nextSuppliers = suppliers.map(s =>
|
const nextSuppliers = suppliers.map(s =>
|
||||||
supplierIds.has(s.id) ? { ...s, currency: currencyCode } : s,
|
columnIds.has(s.id) ? { ...s, currency: currencyCode } : s,
|
||||||
);
|
);
|
||||||
const nextOffers = offers.map(o => {
|
const nextOffers = offers.map(o => {
|
||||||
if (!supplierIds.has(o.supplierId)) return o;
|
if (!columnIds.has(o.supplierId)) return o;
|
||||||
const pricePerUnitKzt = o.pricePerUnit * rate;
|
const pricePerUnitKzt = o.pricePerUnit * rate;
|
||||||
const totalPriceKzt = pricePerUnitKzt * o.quantity;
|
const totalPriceKzt = pricePerUnitKzt * o.quantity;
|
||||||
return { ...o, currencyCode, pricePerUnitKzt, totalPriceKzt, totalWithExtraKzt: totalPriceKzt };
|
return { ...o, currencyCode, pricePerUnitKzt, totalPriceKzt, totalWithExtraKzt: totalPriceKzt };
|
||||||
@@ -32,7 +44,6 @@ export function applySupplierCurrency(
|
|||||||
return { suppliers: nextSuppliers, offers: nextOffers };
|
return { suppliers: nextSuppliers, offers: nextOffers };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSupplierCurrency(suppliers: Supplier[], supplierName: string | undefined): string {
|
export function getSupplierCurrency(suppliers: Supplier[], file: KpFileRef): string {
|
||||||
const supplier = suppliers.find(s => s.hasOffer && s.name === supplierName);
|
return findFileColumns(suppliers, file)[0]?.currency ?? BASE_CURRENCY;
|
||||||
return supplier?.currency ?? BASE_CURRENCY;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
import { buildKpOffersToSave } from './kpOfferRecords';
|
||||||
|
import { FlowPositionResult } from './kpTypes';
|
||||||
|
import { Position } from '../types/domain';
|
||||||
|
|
||||||
|
function makePosition(overrides: Partial<Position>): Position {
|
||||||
|
return {
|
||||||
|
id: 'pos_1',
|
||||||
|
spId: 1543,
|
||||||
|
requestId: 'req_1',
|
||||||
|
requestNumber: '051/26-08/0015',
|
||||||
|
materialName: 'Доска пробковая',
|
||||||
|
disciplineCode: '',
|
||||||
|
smrType: '',
|
||||||
|
unit: 'шт',
|
||||||
|
agskCode: '',
|
||||||
|
uniqueCode: '',
|
||||||
|
quantity: 4,
|
||||||
|
positionType: 'ТМЦ',
|
||||||
|
deadline: '',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const POSITIONS = [
|
||||||
|
makePosition({}),
|
||||||
|
makePosition({ id: 'pos_2', spId: 1544, materialName: 'Флипчарт', quantity: 3 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESULTS: FlowPositionResult[] = [
|
||||||
|
{ positionId: 'pos_1', positionSpId: 1543, flowResult: { position: 'Доска в раме', unit: 'шт.', quantity: 4, price: 28500, cost: 114000 } },
|
||||||
|
{ positionId: 'pos_2', positionSpId: 1544, flowResult: { price: 98000, cost: 294000 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('buildKpOffersToSave', () => {
|
||||||
|
// Syntex отключён: поставщика называет поток, и узнаём его только после разбора всех позиций.
|
||||||
|
// Раньше записи строились внутри цикла и без поставщика от Syntex не строились вовсе.
|
||||||
|
it('строит записи по поставщику, определённому после разбора', () => {
|
||||||
|
const offers = buildKpOffersToSave(RESULTS, POSITIONS, 264, 283);
|
||||||
|
|
||||||
|
expect(offers).toHaveLength(2);
|
||||||
|
expect(offers[0]).toEqual({
|
||||||
|
positionSpId: 1543,
|
||||||
|
supplierSpId: 264,
|
||||||
|
offerSpId: 283,
|
||||||
|
supplierPosition: 'Доска в раме',
|
||||||
|
unit: 'шт.',
|
||||||
|
quantity: 4,
|
||||||
|
price: 28500,
|
||||||
|
summa: 114000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустые поля потока берёт из позиции СТ', () => {
|
||||||
|
const offers = buildKpOffersToSave(RESULTS, POSITIONS, 264, 283);
|
||||||
|
|
||||||
|
expect(offers[1]).toMatchObject({ supplierPosition: 'Флипчарт', unit: 'шт', quantity: 3, price: 98000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без поставщика записей нет', () => {
|
||||||
|
expect(buildKpOffersToSave(RESULTS, POSITIONS, undefined, 283)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пропускает позицию без SP Id', () => {
|
||||||
|
const results: FlowPositionResult[] = [{ positionId: 'pos_x', flowResult: { price: 1 } }];
|
||||||
|
|
||||||
|
expect(buildKpOffersToSave(results, POSITIONS, 264, 283)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('берёт SP Id из позиции, если в результате его нет', () => {
|
||||||
|
const results: FlowPositionResult[] = [{ positionId: 'pos_2', flowResult: { price: 5 } }];
|
||||||
|
|
||||||
|
expect(buildKpOffersToSave(results, POSITIONS, 264, 283)[0].positionSpId).toBe(1544);
|
||||||
|
});
|
||||||
|
});
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import { FlowPositionResult } from './kpTypes';
|
||||||
|
import { Position } from '../types/domain';
|
||||||
|
|
||||||
|
// Запись «Оценка поставщиков» по одной позиции из разобранного КП (вход CtService.saveKpOffer).
|
||||||
|
export interface KpOfferToSave {
|
||||||
|
positionSpId: number;
|
||||||
|
supplierSpId: number;
|
||||||
|
offerSpId: number;
|
||||||
|
supplierPosition: string;
|
||||||
|
unit: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
summa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строит записи после разбора всего КП. Поставщик может прийти из ответа потока (Syntex отключён),
|
||||||
|
// а его узнаём только когда разобраны все позиции — поэтому записи не строим внутри цикла по позициям.
|
||||||
|
// Пустые поля потока добираем из позиции СТ.
|
||||||
|
export function buildKpOffersToSave(
|
||||||
|
results: FlowPositionResult[],
|
||||||
|
positions: Position[],
|
||||||
|
supplierSpId: number | undefined,
|
||||||
|
fileSpId: number,
|
||||||
|
): KpOfferToSave[] {
|
||||||
|
if (!supplierSpId) return [];
|
||||||
|
|
||||||
|
const offers: KpOfferToSave[] = [];
|
||||||
|
for (const { positionId, positionSpId, flowResult } of results) {
|
||||||
|
const position = positions.find(p => p.id === positionId);
|
||||||
|
const spId = positionSpId ?? position?.spId;
|
||||||
|
if (!spId) continue;
|
||||||
|
|
||||||
|
offers.push({
|
||||||
|
positionSpId: spId,
|
||||||
|
supplierSpId,
|
||||||
|
offerSpId: fileSpId,
|
||||||
|
supplierPosition: flowResult.position ?? position?.materialName ?? '',
|
||||||
|
unit: flowResult.unit ?? position?.unit ?? '',
|
||||||
|
quantity: flowResult.quantity ?? position?.quantity ?? 0,
|
||||||
|
price: flowResult.price ?? 0,
|
||||||
|
summa: flowResult.cost ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return offers;
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
import { groupUploadedKpFiles, removeKpFileFromComparison } from './kpRemoval';
|
||||||
|
import { Supplier, SupplierOffer } from '../types/domain';
|
||||||
|
|
||||||
|
const ARLAN = 'ТОО «Арлан Офис Снаб»';
|
||||||
|
const KALA = 'ТОО «Кала Саплай»';
|
||||||
|
const ARLAN_KZT: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: ARLAN, hasOffer: true };
|
||||||
|
const ARLAN_USD: Supplier = { id: '263_285', spId: 263, kpFileSpId: 285, name: ARLAN, hasOffer: true };
|
||||||
|
const KALA_KZT: Supplier = { id: '264_278', spId: 264, kpFileSpId: 278, name: KALA, hasOffer: true };
|
||||||
|
const UPLOADED = [
|
||||||
|
{ spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf', supplierName: ARLAN },
|
||||||
|
{ spId: 278, name: 'KP_02_Qala_Supply_KZT.pdf', supplierName: KALA },
|
||||||
|
{ spId: 285, name: 'KP_01_Arlan_Office_USD.pdf', supplierName: ARLAN },
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeOffer(supplierId: string): SupplierOffer {
|
||||||
|
return {
|
||||||
|
id: '', supplierId, positionId: 'p1', pricePerUnit: 1, quantity: 1, currencyCode: 'KZT',
|
||||||
|
pricePerUnitKzt: 1, totalPriceKzt: 1, totalWithExtraKzt: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('removeKpFileFromComparison', () => {
|
||||||
|
// Загрузили исправленный КП Арлан — старый убирают корзиной: уходит только его колонка.
|
||||||
|
it('убирает колонку, предложения и строку списка только этого КП', () => {
|
||||||
|
const result = removeKpFileFromComparison(
|
||||||
|
{
|
||||||
|
suppliers: [ARLAN_KZT, KALA_KZT, ARLAN_USD],
|
||||||
|
offers: [makeOffer('263_277'), makeOffer('264_278'), makeOffer('263_285')],
|
||||||
|
uploadedKpFiles: UPLOADED,
|
||||||
|
},
|
||||||
|
UPLOADED[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.suppliers.map(s => s.id)).toEqual(['264_278', '263_285']);
|
||||||
|
expect(result.offers.map(o => o.supplierId)).toEqual(['264_278', '263_285']);
|
||||||
|
expect(result.uploadedKpFiles.map(f => f.spId)).toEqual([278, 285]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('старую колонку без файла убирает, когда это последний КП поставщика', () => {
|
||||||
|
const legacy: Supplier = { id: '264', spId: 264, name: KALA, hasOffer: true };
|
||||||
|
|
||||||
|
const result = removeKpFileFromComparison(
|
||||||
|
{ suppliers: [legacy], offers: [makeOffer('264')], uploadedKpFiles: [UPLOADED[1]] },
|
||||||
|
UPLOADED[1],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.suppliers).toEqual([]);
|
||||||
|
expect(result.offers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('старую колонку без файла оставляет, пока у поставщика есть другие КП', () => {
|
||||||
|
const legacy: Supplier = { id: '263', spId: 263, name: ARLAN, hasOffer: true };
|
||||||
|
|
||||||
|
const result = removeKpFileFromComparison(
|
||||||
|
{ suppliers: [legacy], offers: [], uploadedKpFiles: [UPLOADED[0], UPLOADED[2]] },
|
||||||
|
UPLOADED[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.suppliers).toEqual([legacy]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupUploadedKpFiles', () => {
|
||||||
|
it('группирует КП по поставщику в порядке появления', () => {
|
||||||
|
const groups = groupUploadedKpFiles(UPLOADED);
|
||||||
|
|
||||||
|
expect(groups.map(g => ({ supplierName: g.supplierName, spIds: g.files.map(f => f.spId) }))).toEqual([
|
||||||
|
{ supplierName: ARLAN, spIds: [277, 285] },
|
||||||
|
{ supplierName: KALA, spIds: [278] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('файл без поставщика — отдельной группой', () => {
|
||||||
|
const groups = groupUploadedKpFiles([{ spId: 1, name: 'a.pdf' }, { spId: 2, name: 'b.pdf' }]);
|
||||||
|
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
expect(groups[0].supplierName).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
|
|
||||||
|
type UploadedKpFile = CTFormData['uploadedKpFiles'][number];
|
||||||
|
type ComparisonData = Pick<CTFormData, 'suppliers' | 'offers' | 'uploadedKpFiles'>;
|
||||||
|
|
||||||
|
// Убрать КП из сравнения (корзина в «Загруженные КП»): его колонку, предложения и строку списка.
|
||||||
|
// Так заменяют исправленный КП — загружают новый, старый убирают. Сам файл остаётся в библиотеке.
|
||||||
|
// Старая колонка без файла (kpFileSpId) общая на все КП поставщика — убираем её, только когда
|
||||||
|
// у поставщика не остаётся других КП.
|
||||||
|
export function removeKpFileFromComparison(data: ComparisonData, file: UploadedKpFile): ComparisonData {
|
||||||
|
const remainingFiles = data.uploadedKpFiles.filter(f => f.spId !== file.spId);
|
||||||
|
const hasFileColumn = data.suppliers.some(s => s.kpFileSpId === file.spId);
|
||||||
|
const supplierHasOtherFiles = remainingFiles.some(f => f.supplierName === file.supplierName);
|
||||||
|
|
||||||
|
const isRemoved = (s: CTFormData['suppliers'][number]): boolean => {
|
||||||
|
if (!s.hasOffer) return false;
|
||||||
|
if (hasFileColumn) return s.kpFileSpId === file.spId;
|
||||||
|
return s.kpFileSpId === undefined && s.name === file.supplierName && !supplierHasOtherFiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removedColumnIds = new Set(data.suppliers.filter(isRemoved).map(s => s.id));
|
||||||
|
return {
|
||||||
|
suppliers: data.suppliers.filter(s => !removedColumnIds.has(s.id)),
|
||||||
|
offers: data.offers.filter(o => !removedColumnIds.has(o.supplierId)),
|
||||||
|
uploadedKpFiles: remainingFiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadedKpGroup {
|
||||||
|
supplierName?: string;
|
||||||
|
files: UploadedKpFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группы «поставщик → его КП» для списка «Загруженные КП», в порядке первого появления. Несколько КП
|
||||||
|
// одного поставщика стоят рядом — видно, что они сравниваются отдельными колонками.
|
||||||
|
export function groupUploadedKpFiles(files: UploadedKpFile[]): UploadedKpGroup[] {
|
||||||
|
const groups: UploadedKpGroup[] = [];
|
||||||
|
files.forEach(file => {
|
||||||
|
const group = file.supplierName ? groups.find(g => g.supplierName === file.supplierName) : undefined;
|
||||||
|
if (group) {
|
||||||
|
group.files.push(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
groups.push({ supplierName: file.supplierName, files: [file] });
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export interface KpFile {
|
|||||||
file: File;
|
file: File;
|
||||||
status: 'uploading' | 'extracting' | 'flowing' | 'done' | 'error';
|
status: 'uploading' | 'extracting' | 'flowing' | 'done' | 'error';
|
||||||
spId?: number;
|
spId?: number;
|
||||||
|
storedName?: string; // имя в библиотеке КП (с префиксом СТ) — по нему ссылка на файл
|
||||||
supplierName?: string;
|
supplierName?: string;
|
||||||
supplierSpId?: number;
|
supplierSpId?: number;
|
||||||
supplierBIN?: string;
|
supplierBIN?: string;
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
import { buildKpSuppliersAndOffers } from './step2Persist';
|
||||||
|
import { KpFile } from './kpTypes';
|
||||||
|
import { CurrencyRate } from '../types/domain';
|
||||||
|
|
||||||
|
const USD_RATE: CurrencyRate = { code: 'USD', name: 'Доллар США', rateToKzt: 500 };
|
||||||
|
|
||||||
|
// Минимальный файл КП: обработан потоком, одна позиция. Нужные поля переопределяются в тесте.
|
||||||
|
function makeKpFile(overrides: Partial<KpFile>): KpFile {
|
||||||
|
return {
|
||||||
|
localId: 'local_1',
|
||||||
|
file: new File([''], 'kp.pdf'),
|
||||||
|
status: 'done',
|
||||||
|
spId: 100,
|
||||||
|
supplierName: 'ТОО Поставщик',
|
||||||
|
supplierSpId: 10,
|
||||||
|
flowDone: 1,
|
||||||
|
flowTotal: 1,
|
||||||
|
currencyCode: 'KZT',
|
||||||
|
flowResults: [
|
||||||
|
{ positionId: 'pos_1', flowResult: { position: 'Кабель', unit: 'м', price: 200, quantity: 5 } },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildKpSuppliersAndOffers', () => {
|
||||||
|
it('строит поставщика и оффер с суммой в тенге', () => {
|
||||||
|
const { suppliers, offers } = buildKpSuppliersAndOffers([makeKpFile({})], []);
|
||||||
|
|
||||||
|
expect(suppliers).toEqual([
|
||||||
|
{ id: '10_100', spId: 10, kpFileSpId: 100, name: 'ТОО Поставщик', hasOffer: true, currency: 'KZT' },
|
||||||
|
]);
|
||||||
|
expect(offers).toHaveLength(1);
|
||||||
|
expect(offers[0]).toMatchObject({
|
||||||
|
supplierId: '10_100',
|
||||||
|
positionId: 'pos_1',
|
||||||
|
supplierPosition: 'Кабель',
|
||||||
|
pricePerUnit: 200,
|
||||||
|
quantity: 5,
|
||||||
|
pricePerUnitKzt: 200,
|
||||||
|
totalPriceKzt: 1000,
|
||||||
|
offerId: '100',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Два КП одного поставщика (напр. KZT и USD) — два отдельных предложения: по колонке на файл.
|
||||||
|
// Раньше брался только последний файл (5f5f0e7), и второе предложение пропадало.
|
||||||
|
it('каждый КП поставщика — отдельная колонка со своими предложениями', () => {
|
||||||
|
const kzt = makeKpFile({ localId: 'a', spId: 100 });
|
||||||
|
const usd = makeKpFile({
|
||||||
|
localId: 'b',
|
||||||
|
spId: 101,
|
||||||
|
currencyCode: 'USD',
|
||||||
|
flowResults: [{ positionId: 'pos_1', flowResult: { price: 3, quantity: 5 } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { suppliers, offers } = buildKpSuppliersAndOffers([kzt, usd], [USD_RATE]);
|
||||||
|
|
||||||
|
expect(suppliers.map(s => ({ id: s.id, kpFileSpId: s.kpFileSpId, currency: s.currency }))).toEqual([
|
||||||
|
{ id: '10_100', kpFileSpId: 100, currency: 'KZT' },
|
||||||
|
{ id: '10_101', kpFileSpId: 101, currency: 'USD' },
|
||||||
|
]);
|
||||||
|
expect(offers.map(o => ({ supplierId: o.supplierId, offerId: o.offerId, pricePerUnit: o.pricePerUnit }))).toEqual([
|
||||||
|
{ supplierId: '10_100', offerId: '100', pricePerUnit: 200 },
|
||||||
|
{ supplierId: '10_101', offerId: '101', pricePerUnit: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('разных поставщиков оставляет отдельными колонками', () => {
|
||||||
|
const first = makeKpFile({ localId: 'a', supplierSpId: 10, supplierName: 'ТОО Альфа' });
|
||||||
|
const second = makeKpFile({ localId: 'b', supplierSpId: 20, supplierName: 'ТОО Бета' });
|
||||||
|
|
||||||
|
const { suppliers } = buildKpSuppliersAndOffers([first, second], []);
|
||||||
|
|
||||||
|
expect(suppliers.map(s => s.id)).toEqual(['10_100', '20_100']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пропускает файл, у которого поток ещё не определил поставщика', () => {
|
||||||
|
const { suppliers, offers } = buildKpSuppliersAndOffers([makeKpFile({ supplierName: undefined })], []);
|
||||||
|
|
||||||
|
expect(suppliers).toEqual([]);
|
||||||
|
expect(offers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('поставщик без записи в SP получает локальный id и не даёт офферов', () => {
|
||||||
|
const file = makeKpFile({ localId: 'local_7', supplierSpId: undefined });
|
||||||
|
|
||||||
|
const { suppliers, offers } = buildKpSuppliersAndOffers([file], []);
|
||||||
|
|
||||||
|
expect(suppliers[0].id).toBe('local_7');
|
||||||
|
expect(offers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пересчитывает валютный КП в тенге по курсу', () => {
|
||||||
|
const file = makeKpFile({ currencyCode: 'USD' });
|
||||||
|
|
||||||
|
const { offers } = buildKpSuppliersAndOffers([file], [USD_RATE]);
|
||||||
|
|
||||||
|
expect(offers[0]).toMatchObject({
|
||||||
|
currencyCode: 'USD',
|
||||||
|
pricePerUnit: 200,
|
||||||
|
pricePerUnitKzt: 100000,
|
||||||
|
totalPriceKzt: 500000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без курса валюты берёт курс 1', () => {
|
||||||
|
const file = makeKpFile({ currencyCode: 'EUR' });
|
||||||
|
|
||||||
|
const { offers } = buildKpSuppliersAndOffers([file], [USD_RATE]);
|
||||||
|
|
||||||
|
expect(offers[0].pricePerUnitKzt).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
+27
-32
@@ -2,6 +2,8 @@ import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
|
|||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
import { KpFile, NoOfferEntry } from './kpTypes';
|
import { KpFile, NoOfferEntry } from './kpTypes';
|
||||||
|
import { buildSupplierColumnId } from '../utils/supplierColumns';
|
||||||
|
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
||||||
|
|
||||||
export interface PersistStep2Args {
|
export interface PersistStep2Args {
|
||||||
service: CtService;
|
service: CtService;
|
||||||
@@ -11,35 +13,23 @@ export interface PersistStep2Args {
|
|||||||
noOfferSuppliers: NoOfferEntry[];
|
noOfferSuppliers: NoOfferEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Несколько КП одного поставщика (повторная загрузка, тот же КП под другим именем файла) получают
|
// Строит колонки поставщиков и их офферы из результатов потока. Вынесено из persistStep2,
|
||||||
// один и тот же supplierSpId — saveSupplierWithOffer находит поставщика по имени. Без отбора
|
|
||||||
// в таблице сравнения появляется дубль колонки с тем же id, а офферы обоих файлов складываются
|
|
||||||
// в одну ячейку (задвоенные строки позиций). Оставляем последний загруженный файл поставщика,
|
|
||||||
// но обработанный файл не вытесняем тем, что ещё в процессе.
|
|
||||||
function pickLatestFilePerSupplier(kpFiles: KpFile[]): KpFile[] {
|
|
||||||
const fileBySupplier = new Map<string, KpFile>();
|
|
||||||
kpFiles.forEach(f => {
|
|
||||||
if (!f.supplierName) return;
|
|
||||||
const supplierKey = f.supplierSpId !== undefined ? String(f.supplierSpId) : f.supplierName;
|
|
||||||
const existing = fileBySupplier.get(supplierKey);
|
|
||||||
const canReplace = !existing || f.status === 'done' || existing.status !== 'done';
|
|
||||||
if (canReplace) fileBySupplier.set(supplierKey, f);
|
|
||||||
});
|
|
||||||
return Array.from(fileBySupplier.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Строит поставщиков с КП и их офферы из результатов потока (Syntex). Вынесено из persistStep2,
|
|
||||||
// чтобы ту же логику использовал инкрементальный persist в useKpProcessing (поставщик должен
|
// чтобы ту же логику использовал инкрементальный persist в useKpProcessing (поставщик должен
|
||||||
// доходить до Шага 3 даже без кнопки «Сохранить и продолжить»).
|
// доходить до Шага 3 даже без кнопки «Сохранить и продолжить»).
|
||||||
|
// Колонка — на каждый файл КП: несколько КП одного поставщика (напр. KZT и USD) — отдельные
|
||||||
|
// предложения. Раньше брался только последний файл поставщика, и второе предложение пропадало.
|
||||||
export function buildKpSuppliersAndOffers(
|
export function buildKpSuppliersAndOffers(
|
||||||
allKpFiles: KpFile[],
|
allKpFiles: KpFile[],
|
||||||
currencyRates: CurrencyRate[],
|
currencyRates: CurrencyRate[],
|
||||||
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
|
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
|
||||||
const kpFiles = pickLatestFilePerSupplier(allKpFiles);
|
const kpFiles = allKpFiles.filter(f => f.supplierName);
|
||||||
|
const columnIdOf = (f: KpFile): string =>
|
||||||
|
f.supplierSpId ? buildSupplierColumnId(f.supplierSpId, f.spId) : f.localId;
|
||||||
const suppliers: Supplier[] = kpFiles
|
const suppliers: Supplier[] = kpFiles
|
||||||
.map(f => ({
|
.map(f => ({
|
||||||
id: f.supplierSpId ? String(f.supplierSpId) : f.localId,
|
id: columnIdOf(f),
|
||||||
spId: f.supplierSpId,
|
spId: f.supplierSpId,
|
||||||
|
kpFileSpId: f.spId,
|
||||||
name: f.supplierName as string,
|
name: f.supplierName as string,
|
||||||
hasOffer: true,
|
hasOffer: true,
|
||||||
currency: f.currencyCode,
|
currency: f.currencyCode,
|
||||||
@@ -59,7 +49,7 @@ export function buildKpSuppliersAndOffers(
|
|||||||
const totalPriceKzt = pricePerUnitKzt * quantity;
|
const totalPriceKzt = pricePerUnitKzt * quantity;
|
||||||
offers.push({
|
offers.push({
|
||||||
id: '',
|
id: '',
|
||||||
supplierId: String(f.supplierSpId),
|
supplierId: columnIdOf(f),
|
||||||
positionId: fr.positionId,
|
positionId: fr.positionId,
|
||||||
supplierPosition: fr.flowResult.position,
|
supplierPosition: fr.flowResult.position,
|
||||||
unit: fr.flowResult.unit,
|
unit: fr.flowResult.unit,
|
||||||
@@ -87,39 +77,38 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
|
|||||||
savedRates = await service.saveCurrencyRates(currencyRates);
|
savedRates = await service.saveCurrencyRates(currencyRates);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const s of noOfferSuppliers) {
|
|
||||||
await service.saveNoOfferSupplierItem(s.name, s.comment);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { suppliers: newSuppliersWithOffer, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates);
|
const { suppliers: newSuppliersWithOffer, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates);
|
||||||
|
|
||||||
const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
|
const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
|
||||||
id: s.localId,
|
id: s.localId,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
hasOffer: false,
|
hasOffer: false,
|
||||||
|
comment: s.comment,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Persist offers with currency data to SP so shared links show correct currency columns.
|
// Persist offers with currency data to SP so shared links show correct currency columns.
|
||||||
// saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here
|
// saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here
|
||||||
// before proceeding to Step 3.
|
// before proceeding to Step 3.
|
||||||
if (newOffers.length > 0) {
|
if (newOffers.length > 0) {
|
||||||
|
// id колонки («поставщик_файл») в SP не пишем — туда идут SP Id поставщика и позиции.
|
||||||
const offersForSP = newOffers
|
const offersForSP = newOffers
|
||||||
.map(o => {
|
.map(o => {
|
||||||
const pos = formData.selectedPositions.find(p => p.id === o.positionId);
|
const pos = formData.selectedPositions.find(p => p.id === o.positionId);
|
||||||
return pos?.spId !== undefined ? { ...o, positionId: String(pos.spId) } : null;
|
const supplierSpId = newSuppliersWithOffer.find(s => s.id === o.supplierId)?.spId;
|
||||||
|
if (pos?.spId === undefined || supplierSpId === undefined) return null;
|
||||||
|
return { ...o, positionId: String(pos.spId), supplierId: String(supplierSpId) };
|
||||||
})
|
})
|
||||||
.filter((o): o is SupplierOffer => o !== null && !isNaN(parseInt(o.supplierId, 10)));
|
.filter((o): o is SupplierOffer => o !== null);
|
||||||
if (offersForSP.length > 0) {
|
if (offersForSP.length > 0) {
|
||||||
await service.saveOffers(offersForSP, savedRates);
|
await service.saveOffers(offersForSP, savedRates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Мержим с поставщиками из предыдущих сессий на шаге 2
|
// Мержим с колонками из предыдущих сессий на шаге 2 — по id колонки (поставщик + файл КП):
|
||||||
// (повторная загрузка КП того же поставщика — заменяем по имени)
|
// другой КП того же поставщика остаётся отдельной колонкой.
|
||||||
const newSupplierNames = new Set(newSuppliersWithOffer.map(s => s.name));
|
|
||||||
const newSupplierIds = new Set(newSuppliersWithOffer.map(s => s.id));
|
const newSupplierIds = new Set(newSuppliersWithOffer.map(s => s.id));
|
||||||
const mergedSuppliersWithOffer: Supplier[] = [
|
const mergedSuppliersWithOffer: Supplier[] = [
|
||||||
...(formData.suppliers ?? []).filter(s => s.hasOffer && !newSupplierNames.has(s.name)),
|
...(formData.suppliers ?? []).filter(s => s.hasOffer && !newSupplierIds.has(s.id)),
|
||||||
...newSuppliersWithOffer,
|
...newSuppliersWithOffer,
|
||||||
];
|
];
|
||||||
const mergedOffers: SupplierOffer[] = [
|
const mergedOffers: SupplierOffer[] = [
|
||||||
@@ -137,9 +126,15 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
|
|||||||
// API, см. план миграции согласования) маршрут — это планируемый resolve, который Step4Approval
|
// API, см. план миграции согласования) маршрут — это планируемый resolve, который Step4Approval
|
||||||
// резолвит «на лету» при открытии шага (CtService.getPlannedApprovalMatrix), писать в SP заранее не нужно.
|
// резолвит «на лету» при открытии шага (CtService.getPlannedApprovalMatrix), писать в SP заранее не нужно.
|
||||||
|
|
||||||
|
// «Не получены КП» — с привязкой к СТ: по ссылке согласующий видит список под таблицей сравнения.
|
||||||
|
const allNoOfferSuppliers = [...preservedNoOfferSuppliers, ...suppliersWithoutOffer];
|
||||||
|
if (formData.ct) {
|
||||||
|
await service.syncNoOfferSuppliers(formData.ct.id, getNoOfferSuppliers(allNoOfferSuppliers));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currencyRates: savedRates,
|
currencyRates: savedRates,
|
||||||
suppliers: [...mergedSuppliersWithOffer, ...preservedNoOfferSuppliers, ...suppliersWithoutOffer],
|
suppliers: [...mergedSuppliersWithOffer, ...allNoOfferSuppliers],
|
||||||
offers: mergedOffers,
|
offers: mergedOffers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-24
@@ -3,6 +3,8 @@ import { CtService } from '../../services/CtService';
|
|||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
|
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
|
||||||
import { buildKpSuppliersAndOffers } from './step2Persist';
|
import { buildKpSuppliersAndOffers } from './step2Persist';
|
||||||
|
import { buildKpOffersToSave } from './kpOfferRecords';
|
||||||
|
import { buildKpStorageName } from '../utils/kpFileName';
|
||||||
|
|
||||||
let localIdCounter = 0;
|
let localIdCounter = 0;
|
||||||
|
|
||||||
@@ -13,6 +15,8 @@ export interface UseKpProcessingResult {
|
|||||||
handleFilesAdded: (files: File[]) => Promise<void>;
|
handleFilesAdded: (files: File[]) => Promise<void>;
|
||||||
handleFileRemove: (localId: string) => void;
|
handleFileRemove: (localId: string) => void;
|
||||||
handleCurrencyChange: (localId: string, currency: string) => void;
|
handleCurrencyChange: (localId: string, currency: string) => void;
|
||||||
|
// Убрать файл из очереди загрузки по SP Id (КП убрали из сравнения): иначе он снова соберётся в колонку.
|
||||||
|
removeFileBySpId: (fileSpId: number) => void;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,15 +52,16 @@ export function useKpProcessing(
|
|||||||
const currentUploaded = fd.uploadedKpFiles ?? [];
|
const currentUploaded = fd.uploadedKpFiles ?? [];
|
||||||
const newEntries = doneFiles
|
const newEntries = doneFiles
|
||||||
.filter(f => !currentUploaded.some(u => u.spId === f.spId))
|
.filter(f => !currentUploaded.some(u => u.spId === f.spId))
|
||||||
.map(f => ({ name: f.file.name, spId: f.spId as number, supplierName: f.supplierName }));
|
.map(f => ({ name: f.storedName ?? f.file.name, spId: f.spId as number, supplierName: f.supplierName }));
|
||||||
if (newEntries.length > 0) {
|
if (newEntries.length > 0) {
|
||||||
patch.uploadedKpFiles = [...currentUploaded, ...newEntries];
|
patch.uploadedKpFiles = [...currentUploaded, ...newEntries];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { suppliers: kpSuppliers, offers: kpOffers } = buildKpSuppliersAndOffers(doneFiles, fd.currencyRates);
|
const { suppliers: kpSuppliers, offers: kpOffers } = buildKpSuppliersAndOffers(doneFiles, fd.currencyRates);
|
||||||
if (kpSuppliers.length > 0) {
|
if (kpSuppliers.length > 0) {
|
||||||
// Заменяем поставщика с тем же именем (повторная загрузка КП), остальных — сохраняем.
|
// Заменяем колонки этих же КП (поставщик + файл), остальные — сохраняем: другой КП того же
|
||||||
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.name === s.name));
|
// поставщика — отдельное предложение, своя колонка.
|
||||||
|
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.id === s.id));
|
||||||
patch.suppliers = [...others, ...kpSuppliers];
|
patch.suppliers = [...others, ...kpSuppliers];
|
||||||
const kpSupplierIds = new Set(kpSuppliers.map(s => s.id));
|
const kpSupplierIds = new Set(kpSuppliers.map(s => s.id));
|
||||||
const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId));
|
const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId));
|
||||||
@@ -91,20 +96,9 @@ export function useKpProcessing(
|
|||||||
try {
|
try {
|
||||||
const result = await service.triggerFlow(fileName, fileSpId, pos.materialName);
|
const result = await service.triggerFlow(fileName, fileSpId, pos.materialName);
|
||||||
collectedResults.push({ positionId: pos.id, positionSpId: pos.spId, flowResult: result });
|
collectedResults.push({ positionId: pos.id, positionSpId: pos.spId, flowResult: result });
|
||||||
if (pos.spId && supplierSpId && result) {
|
} catch (err) {
|
||||||
await service.saveKpOffer({
|
// Сбой одной позиции не останавливает остальные, но и не теряется молча.
|
||||||
positionSpId: pos.spId,
|
console.warn(`[КП] поток не разобрал позицию «${pos.materialName}»:`, err);
|
||||||
supplierSpId,
|
|
||||||
offerSpId: fileSpId,
|
|
||||||
supplierPosition: result.position ?? pos.materialName,
|
|
||||||
unit: result.unit ?? pos.unit,
|
|
||||||
quantity: result.quantity ?? pos.quantity,
|
|
||||||
price: result.price ?? 0,
|
|
||||||
summa: result.cost ?? 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// individual position failure doesn't stop others
|
|
||||||
}
|
}
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
setKpFiles(prev => prev.map(f =>
|
setKpFiles(prev => prev.map(f =>
|
||||||
@@ -128,6 +122,16 @@ export function useKpProcessing(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Предложения пишем после того, как поставщик определён: Syntex отключён, и поставщика чаще
|
||||||
|
// называет сам поток — внутри цикла по позициям его ещё нет, и записи раньше не создавались вовсе.
|
||||||
|
const offersToSave = buildKpOffersToSave(collectedResults, positions, resolvedSupplierSpId, fileSpId);
|
||||||
|
await Promise.all(offersToSave.map(offer =>
|
||||||
|
service.saveKpOffer(offer).catch(err => {
|
||||||
|
// Не критично: «Сохранить» на Шаге 2/3 перезапишет предложения позиции целиком.
|
||||||
|
console.warn(`[КП] не удалось записать предложение по позиции ${offer.positionSpId}:`, err);
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
// БИН поставщика (для Adata) — берём первый результат из позиций, где он вообще пришёл
|
// БИН поставщика (для Adata) — берём первый результат из позиций, где он вообще пришёл
|
||||||
const supplierBIN = collectedResults.find(r => r.flowResult.supplierBIN)?.flowResult.supplierBIN;
|
const supplierBIN = collectedResults.find(r => r.flowResult.supplierBIN)?.flowResult.supplierBIN;
|
||||||
if (supplierBIN && resolvedSupplierSpId) {
|
if (supplierBIN && resolvedSupplierSpId) {
|
||||||
@@ -190,13 +194,17 @@ export function useKpProcessing(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFilesAdded = useCallback(async (files: File[]): Promise<void> => {
|
const handleFilesAdded = useCallback(async (files: File[]): Promise<void> => {
|
||||||
|
const ctId = formDataRef.current.ct?.id;
|
||||||
|
// В «Загруженных КП» имена хранения (с префиксом СТ), в очереди — исходные: сверяем с обоими.
|
||||||
const existingNames = new Set([
|
const existingNames = new Set([
|
||||||
...kpFilesRef.current.map(f => f.file.name),
|
...kpFilesRef.current.map(f => f.file.name),
|
||||||
...(formDataRef.current.uploadedKpFiles ?? []).map(f => f.name),
|
...(formDataRef.current.uploadedKpFiles ?? []).map(f => f.name),
|
||||||
]);
|
]);
|
||||||
|
const isUploaded = (f: File): boolean =>
|
||||||
|
existingNames.has(f.name) || existingNames.has(buildKpStorageName(ctId, f.name));
|
||||||
|
|
||||||
const newFiles = files.filter(f => !existingNames.has(f.name));
|
const newFiles = files.filter(f => !isUploaded(f));
|
||||||
const skipped = files.filter(f => existingNames.has(f.name));
|
const skipped = files.filter(isUploaded);
|
||||||
|
|
||||||
if (skipped.length > 0) {
|
if (skipped.length > 0) {
|
||||||
setDuplicateWarning(`Файл(ы) уже загружен(ы) в рамках этой СТ: ${skipped.map(f => f.name).join(', ')}`);
|
setDuplicateWarning(`Файл(ы) уже загружен(ы) в рамках этой СТ: ${skipped.map(f => f.name).join(', ')}`);
|
||||||
@@ -219,25 +227,26 @@ export function useKpProcessing(
|
|||||||
if (mountedRef.current) setKpFiles(prev => [...prev, ...entries]);
|
if (mountedRef.current) setKpFiles(prev => [...prev, ...entries]);
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
const storageName = buildKpStorageName(ctId, entry.file.name);
|
||||||
try {
|
try {
|
||||||
let cachedSpId: number | undefined;
|
let cachedSpId: number | undefined;
|
||||||
try {
|
try {
|
||||||
const existing = await service.findExistingKpFile(entry.file.name, entry.file.size);
|
const existing = await service.findExistingKpFile(storageName, entry.file.size);
|
||||||
if (existing) cachedSpId = existing.spId;
|
if (existing) cachedSpId = existing.spId;
|
||||||
} catch {
|
} catch {
|
||||||
// lookup failed — proceed with normal upload
|
// lookup failed — proceed with normal upload
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cachedSpId !== undefined) {
|
if (cachedSpId !== undefined) {
|
||||||
patchFile(entry.localId, { spId: cachedSpId, status: 'extracting' });
|
patchFile(entry.localId, { spId: cachedSpId, storedName: storageName, status: 'extracting' });
|
||||||
pollSyntex(entry.localId, cachedSpId, entry.file.name, 0).catch(err => {
|
pollSyntex(entry.localId, cachedSpId, storageName, 0).catch(err => {
|
||||||
patchFile(entry.localId, { status: 'error', error: (err as Error).message });
|
patchFile(entry.localId, { status: 'error', error: (err as Error).message });
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { spId, name } = await service.uploadKpFile(entry.file);
|
const { spId, name } = await service.uploadKpFile(entry.file, storageName);
|
||||||
patchFile(entry.localId, { spId, status: 'extracting' });
|
patchFile(entry.localId, { spId, storedName: name, status: 'extracting' });
|
||||||
|
|
||||||
const isWord = /\.(doc|docx|xls|xlsx)$/i.test(entry.file.name);
|
const isWord = /\.(doc|docx|xls|xlsx)$/i.test(entry.file.name);
|
||||||
const isPdf = /\.pdf$/i.test(entry.file.name);
|
const isPdf = /\.pdf$/i.test(entry.file.name);
|
||||||
@@ -270,6 +279,10 @@ export function useKpProcessing(
|
|||||||
setKpFiles(prev => prev.filter(f => f.localId !== localId));
|
setKpFiles(prev => prev.filter(f => f.localId !== localId));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeFileBySpId = (fileSpId: number): void => {
|
||||||
|
setKpFiles(prev => prev.filter(f => f.spId !== fileSpId));
|
||||||
|
};
|
||||||
|
|
||||||
const handleCurrencyChange = (localId: string, currency: string): void => {
|
const handleCurrencyChange = (localId: string, currency: string): void => {
|
||||||
setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, currencyCode: currency } : f));
|
setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, currencyCode: currency } : f));
|
||||||
};
|
};
|
||||||
@@ -283,6 +296,7 @@ export function useKpProcessing(
|
|||||||
handleFilesAdded,
|
handleFilesAdded,
|
||||||
handleFileRemove,
|
handleFileRemove,
|
||||||
handleCurrencyChange,
|
handleCurrencyChange,
|
||||||
|
removeFileBySpId,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -78,6 +78,11 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
|||||||
<span className={styles.supplierName}>{s.name}</span>
|
<span className={styles.supplierName}>{s.name}</span>
|
||||||
{isRec && <span className={styles.recommendedBadge}>Рекомендуется</span>}
|
{isRec && <span className={styles.recommendedBadge}>Рекомендуется</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{matrix.columnLabels[s.id] && (
|
||||||
|
<div className={styles.supplierFileLabel} title={matrix.columnLabels[s.id]}>
|
||||||
|
{matrix.columnLabels[s.id]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={styles.supplierMetaRow}>
|
<div className={styles.supplierMetaRow}>
|
||||||
<AdataIndicator supplier={s} state={adata.adataMap[s.id]} onCheck={() => void adata.checkSupplier(s.id)} />
|
<AdataIndicator supplier={s} state={adata.adataMap[s.id]} onCheck={() => void adata.checkSupplier(s.id)} />
|
||||||
{currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>}
|
{currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>}
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
|
export interface CriteriaChoiceOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
options: CriteriaChoiceOption[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ячейка-выбор «Дополнительной информации» (условия поставки, сертификаты). Значение не из списка
|
||||||
|
// (напр. свободный текст от ИИ) показываем отдельным пунктом — чтобы не потерялось и его можно было сменить.
|
||||||
|
export const CriteriaChoiceCell: React.FC<Props> = ({ value, options, onChange, ariaLabel }) => {
|
||||||
|
const current = value.trim();
|
||||||
|
const isKnown = current === '' || options.some(o => o.value === current);
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className={styles.criteriaSelect}
|
||||||
|
value={current}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{!isKnown && <option value={current}>{current}</option>}
|
||||||
|
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
};
|
||||||
+27
-21
@@ -2,18 +2,26 @@ import * as React from 'react';
|
|||||||
import { Button, IconButton, TextInput, TextArea, Icon } from '@tnsu/ui-kit';
|
import { Button, IconButton, TextInput, TextArea, Icon } from '@tnsu/ui-kit';
|
||||||
import { Step3Matrix } from './useStep3Matrix';
|
import { Step3Matrix } from './useStep3Matrix';
|
||||||
import { CriteriaEntry } from './matrixTypes';
|
import { CriteriaEntry } from './matrixTypes';
|
||||||
import { DELIVERY_TYPE_OPTIONS } from './matrixHelpers';
|
import { CERTIFICATE_OPTIONS, DELIVERY_TYPE_OPTIONS } from './matrixHelpers';
|
||||||
|
import { CriteriaChoiceCell, CriteriaChoiceOption } from './CriteriaChoiceCell';
|
||||||
|
import { PaymentTypeCell } from './PaymentTypeCell';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
matrix: Step3Matrix;
|
matrix: Step3Matrix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Поля-выбор: условия поставки (Incoterms — в списке описание, храним код) и сертификаты Да/Нет.
|
||||||
|
const CHOICE_OPTIONS: Partial<Record<keyof CriteriaEntry, CriteriaChoiceOption[]>> = {
|
||||||
|
deliveryType: DELIVERY_TYPE_OPTIONS.map(o => ({ value: o.code, label: o.label })),
|
||||||
|
sertificates: CERTIFICATE_OPTIONS.map(o => ({ value: o, label: o })),
|
||||||
|
};
|
||||||
|
|
||||||
// Блок «Дополнительная информация» (условия оплаты/поставки/сроки/сертификаты + произвольные условия).
|
// Блок «Дополнительная информация» (условия оплаты/поставки/сроки/сертификаты + произвольные условия).
|
||||||
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
|
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
|
||||||
export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
||||||
const {
|
const {
|
||||||
isReadOnly, suppliersWithOffer, criteriaMap, customCriteria, newCriteriaLabel,
|
isReadOnly, suppliersWithOffer, columnDisplayNames, criteriaMap, customCriteria, newCriteriaLabel,
|
||||||
setNewCriteriaLabel, updateCriteriaValue, updateCustomCriteriaValue,
|
setNewCriteriaLabel, updateCriteriaValue, updateCustomCriteriaValue,
|
||||||
handleAddCustomCriteria, handleRemoveCustomCriteria,
|
handleAddCustomCriteria, handleRemoveCustomCriteria,
|
||||||
} = matrix;
|
} = matrix;
|
||||||
@@ -38,7 +46,7 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className={styles.criteriaLabelHeader} />
|
<th className={styles.criteriaLabelHeader} />
|
||||||
{suppliersWithOffer.map(s => (
|
{suppliersWithOffer.map(s => (
|
||||||
<th key={s.id} className={styles.criteriaSupplierHeader}>{s.name}</th>
|
<th key={s.id} className={styles.criteriaSupplierHeader}>{columnDisplayNames[s.id] ?? s.name}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -54,24 +62,22 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
|||||||
return (
|
return (
|
||||||
<td key={s.id} className={styles.criteriaValue}>
|
<td key={s.id} className={styles.criteriaValue}>
|
||||||
{!isReadOnly ? (
|
{!isReadOnly ? (
|
||||||
row.key === 'deliveryType' ? (
|
CHOICE_OPTIONS[row.key] ? (
|
||||||
(criteriaMap[s.id]?.deliveryType ?? '').trim() ? (
|
// Выбор из списка — всегда, даже если поле уже заполнено (в т.ч. ИИ): раньше
|
||||||
// Уже заполнено (в т.ч. ИИ) — показываем как текст, без селекта.
|
// заполненное условие поставки показывалось текстом и его нельзя было сменить.
|
||||||
criteriaMap[s.id]?.deliveryType
|
<CriteriaChoiceCell
|
||||||
) : (
|
value={criteriaMap[s.id]?.[row.key] ?? ''}
|
||||||
// Пусто — даём выбрать условие поставки из списка.
|
options={CHOICE_OPTIONS[row.key] ?? []}
|
||||||
<select
|
onChange={val => updateCriteriaValue(s.id, row.key, val)}
|
||||||
className={styles.criteriaSelect}
|
ariaLabel={`${row.label} — ${s.name}`}
|
||||||
value=""
|
/>
|
||||||
onChange={e => updateCriteriaValue(s.id, 'deliveryType', e.target.value)}
|
) : row.key === 'paymentType' ? (
|
||||||
aria-label={`${row.label} — ${s.name}`}
|
<PaymentTypeCell
|
||||||
>
|
value={criteriaMap[s.id]?.paymentType ?? ''}
|
||||||
<option value="">—</option>
|
onChange={val => updateCriteriaValue(s.id, 'paymentType', val)}
|
||||||
{DELIVERY_TYPE_OPTIONS.map(o => (
|
ariaLabel={`${row.label} — ${s.name}`}
|
||||||
<option key={o.code} value={o.code}>{o.label}</option>
|
isRequiredEmpty={Boolean(row.required) && isEmpty}
|
||||||
))}
|
/>
|
||||||
</select>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<div className={[styles.criteriaInput, row.required && isEmpty ? styles.criteriaInputRequired : ''].filter(Boolean).join(' ')}>
|
<div className={[styles.criteriaInput, row.required && isEmpty ? styles.criteriaInputRequired : ''].filter(Boolean).join(' ')}>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
|||||||
+8
-7
@@ -3,8 +3,9 @@ import { IconButton, TextInput } from '@tnsu/ui-kit';
|
|||||||
import { CurrencyRate } from '../types/domain';
|
import { CurrencyRate } from '../types/domain';
|
||||||
import { PositionRow } from './matrixTypes';
|
import { PositionRow } from './matrixTypes';
|
||||||
import { Step3Matrix } from './useStep3Matrix';
|
import { Step3Matrix } from './useStep3Matrix';
|
||||||
import { convertQuantity, unitsEquivalent } from '../utils/unitConversion';
|
import { getUnitStatus } from '../utils/unitConversion';
|
||||||
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey } from './matrixHelpers';
|
import { unitStatusClass } from './unitStatusStyles';
|
||||||
|
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -60,15 +61,15 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
const priceKzt = (price ?? 0) * rate;
|
const priceKzt = (price ?? 0) * rate;
|
||||||
const priceKztEffective = item?.priceKztOverride !== undefined ? item.priceKztOverride : priceKzt;
|
const priceKztEffective = item?.priceKztOverride !== undefined ? item.priceKztOverride : priceKzt;
|
||||||
const kztTotal = posOffers.reduce((sum, o) => sum + (o.totalWithExtraKzt ?? 0), 0);
|
const kztTotal = posOffers.reduce((sum, o) => sum + (o.totalWithExtraKzt ?? 0), 0);
|
||||||
const isMin = minTotal !== undefined && kztTotal === minTotal && kztTotal > 0;
|
const isMin = isMinPositionTotal(kztTotal, minTotal);
|
||||||
const isSel = s.id === selectedSupplierId;
|
const isSel = s.id === selectedSupplierId;
|
||||||
const selCls = isSel ? styles.colSelected : '';
|
const selCls = isSel ? styles.colSelected : '';
|
||||||
|
|
||||||
// Перевод в ед. изм. заявки (тумблер) — как в обычных позициях.
|
// Перевод в ед. изм. заявки (тумблер) — как в обычных позициях.
|
||||||
const reqUnit = pos.unit;
|
const reqUnit = pos.unit;
|
||||||
const supUnit = item?.unit || pos.unit;
|
const supUnit = item?.unit || pos.unit;
|
||||||
const tRatio = translateUnits && !unitsEquivalent(supUnit, reqUnit) ? convertQuantity(1, supUnit, reqUnit) : null;
|
const unitStatus = getUnitStatus(supUnit, reqUnit, translateUnits);
|
||||||
const cannotTranslate = translateUnits && !unitsEquivalent(supUnit, reqUnit) && tRatio === null;
|
const tRatio = unitStatus.ratio ?? null;
|
||||||
const dispUnit = tRatio !== null ? reqUnit : (item?.unit || pos.unit);
|
const dispUnit = tRatio !== null ? reqUnit : (item?.unit || pos.unit);
|
||||||
const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty;
|
const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty;
|
||||||
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
|
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
|
||||||
@@ -91,7 +92,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
<span className={styles.computedValue}>{item?.supplierProductName || pos.materialName}</span>
|
<span className={styles.computedValue}>{item?.supplierProductName || pos.materialName}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
<td className={[styles.dataCell, unitStatusClass(unitStatus.status), selCls].filter(Boolean).join(' ')} title={unitStatus.hint}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -102,7 +103,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.computedValue}>{(dispUnit || '—') + (cannotTranslate ? ' *' : '')}</span>
|
<span className={styles.computedValue}>{dispUnit || '—'}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className={[styles.dataCell, ep?.isDelivery ? styles.deliveryQtyCell : '', selCls].filter(Boolean).join(' ')}>
|
<td className={[styles.dataCell, ep?.isDelivery ? styles.deliveryQtyCell : '', selCls].filter(Boolean).join(' ')}>
|
||||||
|
|||||||
+2
@@ -17,6 +17,7 @@ const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
|
|||||||
Отклонено: 'rejected',
|
Отклонено: 'rejected',
|
||||||
Согласовано: 'approved',
|
Согласовано: 'approved',
|
||||||
'На согласовании': 'on_approval',
|
'На согласовании': 'on_approval',
|
||||||
|
'На доработке': 'rework',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ по Teams-ссылке (?ctid=),
|
// Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ по Teams-ссылке (?ctid=),
|
||||||
@@ -56,6 +57,7 @@ export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave
|
|||||||
onDecided={handleDecided}
|
onDecided={handleDecided}
|
||||||
search={(q) => service.searchUsers(q)}
|
search={(q) => service.searchUsers(q)}
|
||||||
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||||
|
allowRework
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CtService } from '../../services/CtService';
|
||||||
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
files: CTFormData['uploadedKpFiles'];
|
||||||
|
service: CtService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ссылки на файлы КП под таблицей сравнения. Согласующий по ссылке из Teams попадает сразу на шаг 3,
|
||||||
|
// а список КП раньше был только на шаге 2 — открыть КП при согласовании было негде.
|
||||||
|
export const KpFilesList: React.FC<Props> = ({ files, service }) => {
|
||||||
|
if (files.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className={styles.criteriaSection}>
|
||||||
|
<h4 className={styles.criteriaSectionTitle}>Коммерческие предложения</h4>
|
||||||
|
<ul className={styles.noOfferSupplierList}>
|
||||||
|
{files.map(file => (
|
||||||
|
<li key={file.spId} className={styles.noOfferSupplierItem}>
|
||||||
|
{file.supplierName && <span className={styles.noOfferSupplierName}>{file.supplierName}</span>}
|
||||||
|
<a href={service.getKpFileUrl(file.name)} target="_blank" rel="noopener noreferrer">
|
||||||
|
{file.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+25
-16
@@ -3,8 +3,9 @@ import { TextInput, Icon } from '@tnsu/ui-kit';
|
|||||||
import { CurrencyRate } from '../types/domain';
|
import { CurrencyRate } from '../types/domain';
|
||||||
import { PositionRow } from './matrixTypes';
|
import { PositionRow } from './matrixTypes';
|
||||||
import { Step3Matrix } from './useStep3Matrix';
|
import { Step3Matrix } from './useStep3Matrix';
|
||||||
import { convertQuantity, unitsEquivalent } from '../utils/unitConversion';
|
import { getUnitStatus } from '../utils/unitConversion';
|
||||||
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey } from './matrixHelpers';
|
import { unitStatusClass } from './unitStatusStyles';
|
||||||
|
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,7 +21,7 @@ interface Props {
|
|||||||
export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrictedView }: Props): JSX.Element {
|
export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrictedView }: Props): JSX.Element {
|
||||||
const {
|
const {
|
||||||
isReadOnly, edits, translateUnits, rawValues, posQtyOverrides, selectedSupplierId, nameColWidth,
|
isReadOnly, edits, translateUnits, rawValues, posQtyOverrides, selectedSupplierId, nameColWidth,
|
||||||
suppliersWithOffer, supplierCurrencies, minTotalByPosition,
|
suppliersWithOffer, supplierCurrencies, minTotalByPosition, positionTotals,
|
||||||
updatePosQty, updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt,
|
updatePosQty, updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt,
|
||||||
removeBundleItem, addBundleItem,
|
removeBundleItem, addBundleItem,
|
||||||
} = matrix;
|
} = matrix;
|
||||||
@@ -85,8 +86,12 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
// null — единицы совпадают или пересчёт невозможен.
|
// null — единицы совпадают или пересчёт невозможен.
|
||||||
const reqUnit = pos.unit;
|
const reqUnit = pos.unit;
|
||||||
const supUnit = item.unit || pos.unit;
|
const supUnit = item.unit || pos.unit;
|
||||||
const tRatio = translateUnits && !unitsEquivalent(supUnit, reqUnit) ? convertQuantity(1, supUnit, reqUnit) : null;
|
const unitStatus = getUnitStatus(supUnit, reqUnit, translateUnits);
|
||||||
const cannotTranslate = translateUnits && !unitsEquivalent(supUnit, reqUnit) && tRatio === null;
|
const tRatio = unitStatus.ratio ?? null;
|
||||||
|
// Подсветка: «Ед. изм.» — при любом несоответствии заявке; кол-во и цены — когда тумблер включён
|
||||||
|
// (они показаны пересчитанными или их нельзя сравнивать напрямую).
|
||||||
|
const unitCls = unitStatusClass(unitStatus.status);
|
||||||
|
const valueCls = translateUnits ? unitCls : '';
|
||||||
const dispUnit = tRatio !== null ? reqUnit : (item.unit || pos.unit);
|
const dispUnit = tRatio !== null ? reqUnit : (item.unit || pos.unit);
|
||||||
const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty;
|
const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty;
|
||||||
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
|
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
|
||||||
@@ -123,7 +128,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
<td className={[styles.dataCell, unitCls, selCls].filter(Boolean).join(' ')} title={unitStatus.hint}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -134,10 +139,10 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.computedValue}>{(dispUnit || '—') + (cannotTranslate ? ' *' : '')}</span>
|
<span className={styles.computedValue}>{dispUnit || '—'}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
<td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')} title={translateUnits ? unitStatus.hint : undefined}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -154,7 +159,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
</td>
|
</td>
|
||||||
{!isRestrictedView && isForeign && (
|
{!isRestrictedView && isForeign && (
|
||||||
<>
|
<>
|
||||||
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
<td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -174,7 +179,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isRestrictedView && <td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
{!isRestrictedView && <td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -198,7 +203,12 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>}
|
</td>}
|
||||||
{!isRestrictedView && <td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
{!isRestrictedView && <td className={[
|
||||||
|
styles.dataCell,
|
||||||
|
// Минимум по позиции — зелёным в каждой строке (у комплекта — в строке «Итого по позиции»).
|
||||||
|
!hasBundle && isMinPositionTotal(positionTotals.get(buildOfferKey(s.id, pos.id)), minTotal) ? styles.minPriceCell : '',
|
||||||
|
selCls,
|
||||||
|
].filter(Boolean).join(' ')}>
|
||||||
<span className={styles.totalValue}>{kztTotal > 0 ? formatKzt(kztTotal) : '—'}</span>
|
<span className={styles.totalValue}>{kztTotal > 0 ? formatKzt(kztTotal) : '—'}</span>
|
||||||
</td>}
|
</td>}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@@ -214,15 +224,14 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
<tr key="subtotal" className={styles.bundleSubtotalRow}>
|
<tr key="subtotal" className={styles.bundleSubtotalRow}>
|
||||||
{suppliersWithOffer.map(s => {
|
{suppliersWithOffer.map(s => {
|
||||||
const key = buildOfferKey(s.id, pos.id);
|
const key = buildOfferKey(s.id, pos.id);
|
||||||
const items = edits[key] ?? [];
|
|
||||||
const currency = supplierCurrencies[s.id] ?? 'KZT';
|
const currency = supplierCurrencies[s.id] ?? 'KZT';
|
||||||
const isForeign = currency !== 'KZT';
|
|
||||||
const isSel = s.id === selectedSupplierId;
|
const isSel = s.id === selectedSupplierId;
|
||||||
const selCls = isSel ? styles.colSelected : '';
|
const selCls = isSel ? styles.colSelected : '';
|
||||||
const subCols = getSubCols(currency, isRestrictedView);
|
const subCols = getSubCols(currency, isRestrictedView);
|
||||||
const rate = isForeign ? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) : 1;
|
// Из того же источника, что и минимум: отдельный пересчёт (без override цены в ₸ и без кол-ва
|
||||||
const totalKzt = items.reduce((sum, it) => sum + (it.pricePerUnit ?? 0) * rate * (it.quantity ?? 0), 0);
|
// позиции по умолчанию) расходился на копейки, и строгое сравнение не находило минимум.
|
||||||
const isMin = minTotal !== undefined && totalKzt === minTotal && totalKzt > 0;
|
const totalKzt = positionTotals.get(key) ?? 0;
|
||||||
|
const isMin = isMinPositionTotal(totalKzt, minTotal);
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={s.id}>
|
<React.Fragment key={s.id}>
|
||||||
<td colSpan={isRestrictedView ? subCols : subCols - 1} className={[styles.bundleSubtotalCell, selCls].filter(Boolean).join(' ')}>
|
<td colSpan={isRestrictedView ? subCols : subCols - 1} className={[styles.bundleSubtotalCell, selCls].filter(Boolean).join(' ')}>
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { NoOfferSupplierEntry } from '../utils/noOfferSuppliers';
|
||||||
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entries: NoOfferSupplierEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поставщики, не предоставившие КП (Шаг 2), — списком под таблицей сравнения. Колонками их не
|
||||||
|
// показываем: сравнивать у них нечего, а список нужен согласующим — к кому обращались.
|
||||||
|
export const NoOfferSuppliersList: React.FC<Props> = ({ entries }) => {
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className={styles.criteriaSection}>
|
||||||
|
<h4 className={styles.criteriaSectionTitle}>Поставщики, не предоставившие КП</h4>
|
||||||
|
<ul className={styles.noOfferSupplierList}>
|
||||||
|
{entries.map(entry => (
|
||||||
|
<li key={entry.name} className={styles.noOfferSupplierItem}>
|
||||||
|
<span className={styles.noOfferSupplierName}>{entry.name}</span>
|
||||||
|
{entry.comment && <span className={styles.noOfferSupplierComment}>{entry.comment}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { TextArea } from '@tnsu/ui-kit';
|
||||||
|
import { PAYMENT_TYPE_OPTIONS } from './matrixHelpers';
|
||||||
|
import { isPaymentTypeOption } from '../utils/criteria';
|
||||||
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
|
const CUSTOM_OPTION = '__custom';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
isRequiredEmpty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Условия оплаты: готовые варианты или «Свой вариант…» с полем ввода. Значение не из списка
|
||||||
|
// (вписанное вручную или распознанное ИИ) открывается сразу как свой вариант.
|
||||||
|
export const PaymentTypeCell: React.FC<Props> = ({ value, onChange, ariaLabel, isRequiredEmpty }) => {
|
||||||
|
// Выбран «Свой вариант…» вручную (поле ещё пустое) — или значение уже не из списка. Второе считаем
|
||||||
|
// от значения, а не один раз при монтировании: условия из SP приходят уже после первого рендера.
|
||||||
|
const [isCustomChosen, setIsCustomChosen] = useState(false);
|
||||||
|
const isCustom = isCustomChosen || (value.trim() !== '' && !isPaymentTypeOption(value));
|
||||||
|
|
||||||
|
const handleSelect = (selected: string): void => {
|
||||||
|
if (selected === CUSTOM_OPTION) {
|
||||||
|
setIsCustomChosen(true);
|
||||||
|
if (isPaymentTypeOption(value)) onChange('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsCustomChosen(false);
|
||||||
|
onChange(selected);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[styles.criteriaInput, isRequiredEmpty ? styles.criteriaInputRequired : ''].filter(Boolean).join(' ')}>
|
||||||
|
<select
|
||||||
|
className={styles.criteriaSelect}
|
||||||
|
value={isCustom ? CUSTOM_OPTION : value.trim()}
|
||||||
|
onChange={e => handleSelect(e.target.value)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{PAYMENT_TYPE_OPTIONS.map(o => <option key={o} value={o}>{o}</option>)}
|
||||||
|
<option value={CUSTOM_OPTION}>Свой вариант…</option>
|
||||||
|
</select>
|
||||||
|
{isCustom && (
|
||||||
|
<TextArea
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder="Впишите условия оплаты"
|
||||||
|
borderless
|
||||||
|
rows={1}
|
||||||
|
ariaLabel={`${ariaLabel} — свой вариант`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+66
@@ -170,6 +170,17 @@
|
|||||||
color: $text;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Файл КП под названием — только у поставщика с несколькими КП (несколько колонок). Длинное имя
|
||||||
|
// файла обрезаем многоточием, полное — в title.
|
||||||
|
.supplierFileLabel {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
.recommendedBadge {
|
.recommendedBadge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -482,6 +493,35 @@
|
|||||||
color: $muted;
|
color: $muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подсветка ед. изм. поставщика (unitStatusStyles): !important — иначе фон перебивает подсветка строки
|
||||||
|
// при наведении и выбранной колонки, и несоответствие пропадает из виду.
|
||||||
|
.dataCell.unitMismatch {
|
||||||
|
background: $warn-bg !important;
|
||||||
|
color: $warn-fg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataCell.unitConverted {
|
||||||
|
background: $accent-bg !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataCell.unitImpossible {
|
||||||
|
background: $danger-bg !important;
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translateLegendConverted,
|
||||||
|
.translateLegendImpossible {
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translateLegendConverted { background: $accent-bg; }
|
||||||
|
|
||||||
|
.translateLegendImpossible {
|
||||||
|
background: $danger-bg;
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
.selectedTotalCell {
|
.selectedTotalCell {
|
||||||
background: $accent-bg !important;
|
background: $accent-bg !important;
|
||||||
|
|
||||||
@@ -581,6 +621,32 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// «Поставщики, не предоставившие КП» под таблицей (NoOfferSuppliersList) — в рамке как «Доп. информация».
|
||||||
|
.noOfferSupplierList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noOfferSupplierItem {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noOfferSupplierName {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noOfferSupplierComment {
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
.criteriaTable {
|
.criteriaTable {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
+15
-4
@@ -12,6 +12,9 @@ import { AiCommentBlock } from './AiCommentBlock';
|
|||||||
import { useStep3Matrix } from './useStep3Matrix';
|
import { useStep3Matrix } from './useStep3Matrix';
|
||||||
import { ComparisonMatrix } from './ComparisonMatrix';
|
import { ComparisonMatrix } from './ComparisonMatrix';
|
||||||
import { CriteriaTable } from './CriteriaTable';
|
import { CriteriaTable } from './CriteriaTable';
|
||||||
|
import { NoOfferSuppliersList } from './NoOfferSuppliersList';
|
||||||
|
import { KpFilesList } from './KpFilesList';
|
||||||
|
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
||||||
import { useAdataCheck } from './useAdataCheck';
|
import { useAdataCheck } from './useAdataCheck';
|
||||||
import { useRecommendedPick } from './useRecommendedPick';
|
import { useRecommendedPick } from './useRecommendedPick';
|
||||||
import { VendorPickerDialog } from './VendorPickerDialog';
|
import { VendorPickerDialog } from './VendorPickerDialog';
|
||||||
@@ -46,7 +49,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
const {
|
const {
|
||||||
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
|
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
|
||||||
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
|
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
|
||||||
extraPositions, suppliersWithOffer, allPositions, computedOffers, selectedSupplierId,
|
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
|
||||||
} = matrix;
|
} = matrix;
|
||||||
|
|
||||||
// Проверка контрагентов в Adata вынесена в хук.
|
// Проверка контрагентов в Adata вынесена в хук.
|
||||||
@@ -75,14 +78,15 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
],
|
],
|
||||||
suppliers: suppliersWithOffer
|
suppliers: suppliersWithOffer
|
||||||
.map(s => ({
|
.map(s => ({
|
||||||
name: s.name,
|
// Различимое имя: у поставщика с несколькими КП иначе в тексте будут два одинаковых.
|
||||||
|
name: columnDisplayNames[s.id] ?? s.name,
|
||||||
grandTotal: allPositions.reduce((sum, pos) => {
|
grandTotal: allPositions.reduce((sum, pos) => {
|
||||||
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
|
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
|
||||||
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
|
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
|
||||||
}, 0),
|
}, 0),
|
||||||
}))
|
}))
|
||||||
.filter(s => s.grandTotal > 0),
|
.filter(s => s.grandTotal > 0),
|
||||||
recommendedSupplier: suppliersWithOffer.find(s => s.id === selectedSupplierId)?.name,
|
recommendedSupplier: selectedSupplierId ? (columnDisplayNames[selectedSupplierId] ?? undefined) : undefined,
|
||||||
}),
|
}),
|
||||||
onPdfError: setSaveError,
|
onPdfError: setSaveError,
|
||||||
});
|
});
|
||||||
@@ -139,7 +143,9 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
</label>
|
</label>
|
||||||
{translateUnits && (
|
{translateUnits && (
|
||||||
<span className={styles.translateHint}>
|
<span className={styles.translateHint}>
|
||||||
кол-во и цена показаны в ед. изм. заявки; «*» — пересчёт невозможен (ред. недоступно)
|
кол-во и цена — в ед. изм. заявки (ред. недоступно):{' '}
|
||||||
|
<span className={styles.translateLegendConverted}>пересчитано</span>,{' '}
|
||||||
|
<span className={styles.translateLegendImpossible}>пересчёт невозможен</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className={styles.adataCheckAllGroup}>
|
<div className={styles.adataCheckAllGroup}>
|
||||||
@@ -170,6 +176,11 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
|
|
||||||
<CriteriaTable matrix={matrix} />
|
<CriteriaTable matrix={matrix} />
|
||||||
|
|
||||||
|
<NoOfferSuppliersList entries={getNoOfferSuppliers(formData.suppliers)} />
|
||||||
|
|
||||||
|
{/* В КП — цены: показываем тем же, кому видны цены (участникам согласования и автору). */}
|
||||||
|
{!isRestrictedView && <KpFilesList files={formData.uploadedKpFiles ?? []} service={service} />}
|
||||||
|
|
||||||
<AiCommentBlock
|
<AiCommentBlock
|
||||||
comment={ai.aiComment}
|
comment={ai.aiComment}
|
||||||
decodedComment={ai.decodedAiComment}
|
decodedComment={ai.decodedAiComment}
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
import { applyCreatedIds, buildOfferKey, computeOffer, getSubCols, isMinPositionTotal, round2Str, sumPositionTotals } from './matrixHelpers';
|
||||||
|
import { ExtraPosition } from './matrixTypes';
|
||||||
|
import { SupplierOffer } from '../types/domain';
|
||||||
|
|
||||||
|
describe('computeOffer', () => {
|
||||||
|
it('считает сумму в тенге для КП в тенге', () => {
|
||||||
|
const offer = computeOffer(
|
||||||
|
{ supplierId: 's1', positionId: 'p1', itemIndex: 0, pricePerUnit: 150, quantity: 4 },
|
||||||
|
'KZT',
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(offer).toMatchObject({
|
||||||
|
supplierId: 's1',
|
||||||
|
positionId: 'p1',
|
||||||
|
currencyCode: 'KZT',
|
||||||
|
pricePerUnit: 150,
|
||||||
|
quantity: 4,
|
||||||
|
pricePerUnitKzt: 150,
|
||||||
|
totalPriceKzt: 600,
|
||||||
|
totalWithExtraKzt: 600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пересчитывает валютную цену по курсу', () => {
|
||||||
|
const offer = computeOffer(
|
||||||
|
{ supplierId: 's1', positionId: 'p1', itemIndex: 0, pricePerUnit: 10, quantity: 3 },
|
||||||
|
'USD',
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(offer.pricePerUnitKzt).toBe(5000);
|
||||||
|
expect(offer.totalPriceKzt).toBe(15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ручная цена в тенге важнее пересчёта по курсу', () => {
|
||||||
|
const offer = computeOffer(
|
||||||
|
{ supplierId: 's1', positionId: 'p1', itemIndex: 0, pricePerUnit: 10, priceKztOverride: 4800, quantity: 2 },
|
||||||
|
'USD',
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(offer.pricePerUnitKzt).toBe(4800);
|
||||||
|
expect(offer.totalPriceKzt).toBe(9600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустая ячейка даёт нули, а не NaN', () => {
|
||||||
|
const offer = computeOffer({ supplierId: 's1', positionId: 'p1', itemIndex: 0 }, 'KZT', 1);
|
||||||
|
|
||||||
|
expect(offer.pricePerUnit).toBe(0);
|
||||||
|
expect(offer.quantity).toBe(0);
|
||||||
|
expect(offer.totalPriceKzt).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildOfferKey', () => {
|
||||||
|
it('склеивает поставщика и позицию через ::', () => {
|
||||||
|
expect(buildOfferKey('s1', 'p1')).toBe('s1::p1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSubCols', () => {
|
||||||
|
it('для тенге 5 колонок, для валюты 7, в урезанном режиме 3', () => {
|
||||||
|
expect(getSubCols('KZT')).toBe(5);
|
||||||
|
expect(getSubCols('USD')).toBe(7);
|
||||||
|
expect(getSubCols('USD', true)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('round2Str', () => {
|
||||||
|
it('отрезает хвост плавающей точки', () => {
|
||||||
|
expect(round2Str(275620.94999)).toBe('275620.95');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyCreatedIds', () => {
|
||||||
|
// Доп. строку создали в SP, а следующий шаг сохранения упал: id должен остаться в стейте,
|
||||||
|
// иначе повторное «Сохранить» создаст строку «Логистика» ещё раз.
|
||||||
|
it('проставляет созданный SP Id', () => {
|
||||||
|
const extras: ExtraPosition[] = [{ id: 'extra_pos_1', materialName: 'Логистика', unit: 'рейс' }];
|
||||||
|
|
||||||
|
expect(applyCreatedIds(extras, { extra_pos_1: { spId: 1601 } })).toEqual([
|
||||||
|
{ id: 'extra_pos_1', materialName: 'Логистика', unit: 'рейс', spId: 1601 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не трогает строки, которых нет среди созданных', () => {
|
||||||
|
const extras: Array<{ id: string; spId?: number }> = [{ id: 'extra_pos_1', spId: 1500 }, { id: 'extra_pos_2' }];
|
||||||
|
|
||||||
|
expect(applyCreatedIds(extras, { extra_pos_2: { spId: 1601 } })).toEqual([
|
||||||
|
{ id: 'extra_pos_1', spId: 1500 },
|
||||||
|
{ id: 'extra_pos_2', spId: 1601 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не мутирует исходный массив', () => {
|
||||||
|
const extras = [{ id: 'extra_sup_1' as string, spId: undefined as number | undefined }];
|
||||||
|
|
||||||
|
applyCreatedIds(extras, { extra_sup_1: { spId: 7 } });
|
||||||
|
|
||||||
|
expect(extras[0].spId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('подсветка минимальной суммы по позиции (п.13)', () => {
|
||||||
|
const offer = (supplierId: string, positionId: string, total: number): SupplierOffer => ({
|
||||||
|
id: '', supplierId, positionId, pricePerUnit: 0, quantity: 0, currencyCode: 'KZT',
|
||||||
|
pricePerUnitKzt: 0, totalPriceKzt: total, totalWithExtraKzt: total,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Строки комплекта одной позиции у поставщика складываются в одну сумму.
|
||||||
|
it('sumPositionTotals: сумма по поставщику и позиции, строки комплекта складываются', () => {
|
||||||
|
const totals = sumPositionTotals([
|
||||||
|
offer('s1', 'p1', 100), offer('s1', 'p1', 50), offer('s2', 'p1', 120), offer('s1', 'p2', 10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(totals.get(buildOfferKey('s1', 'p1'))).toBe(150);
|
||||||
|
expect(totals.get(buildOfferKey('s2', 'p1'))).toBe(120);
|
||||||
|
expect(totals.get(buildOfferKey('s1', 'p2'))).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Раньше сравнивали строгим ===, и итог комплекта, посчитанный отдельно, расходился на копейки — подсветка пропадала.
|
||||||
|
it('isMinPositionTotal: совпадение с минимумом с допуском до полкопейки', () => {
|
||||||
|
expect(isMinPositionTotal(114000, 114000)).toBe(true);
|
||||||
|
expect(isMinPositionTotal(103823.48 + 0.000001, 103823.48)).toBe(true);
|
||||||
|
expect(isMinPositionTotal(128800, 114000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isMinPositionTotal: нет цены или нет минимума — не подсвечиваем', () => {
|
||||||
|
expect(isMinPositionTotal(0, 0)).toBe(false);
|
||||||
|
expect(isMinPositionTotal(undefined, 100)).toBe(false);
|
||||||
|
expect(isMinPositionTotal(100, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,19 @@ export const DELIVERY_TYPE_OPTIONS: Array<{ code: string; label: string }> = [
|
|||||||
{ code: 'DDP', label: 'Поставка до объекта с оплатой всех расходов (DDP) — доставка силами поставщика с включением транспортных и таможенных расходов' },
|
{ code: 'DDP', label: 'Поставка до объекта с оплатой всех расходов (DDP) — доставка силами поставщика с включением транспортных и таможенных расходов' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Условия оплаты — готовые варианты (самые частые формулировки в SP «Критерии»); можно вписать свой.
|
||||||
|
export const PAYMENT_TYPE_OPTIONS: string[] = [
|
||||||
|
'100% предоплата',
|
||||||
|
'50% предоплата, 50% по факту поставки',
|
||||||
|
'30% предоплата, 70% по факту поставки',
|
||||||
|
'40% предоплата, 60% перед отгрузкой',
|
||||||
|
'Оплата по факту поставки',
|
||||||
|
'100% оплата в течение 5 рабочих дней после поставки',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Сертификаты — как Choice-колонка sertificates в SP «Критерии».
|
||||||
|
export const CERTIFICATE_OPTIONS: string[] = ['Да', 'Нет'];
|
||||||
|
|
||||||
// Foreign currency = 6 sub-cols (Ед. изм. | Кол-во | Цена_curr | Сумма_curr | Цена ₸ | Сумма ₸)
|
// Foreign currency = 6 sub-cols (Ед. изм. | Кол-во | Цена_curr | Сумма_curr | Цена ₸ | Сумма ₸)
|
||||||
// KZT = 4 sub-cols (Ед. изм. | Кол-во | Цена ₸ | Сумма ₸)
|
// KZT = 4 sub-cols (Ед. изм. | Кол-во | Цена ₸ | Сумма ₸)
|
||||||
// Restricted = 3 sub-cols (Наименование ТМЦ | Ед. изм. | Кол-во)
|
// Restricted = 3 sub-cols (Наименование ТМЦ | Ед. изм. | Кол-во)
|
||||||
@@ -73,3 +86,33 @@ export function computeOffer(draft: CellEdit, currencyCode: string, rate: number
|
|||||||
export function buildOfferKey(supplierId: string, positionId: string): string {
|
export function buildOfferKey(supplierId: string, positionId: string): string {
|
||||||
return `${supplierId}::${positionId}`;
|
return `${supplierId}::${positionId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проставляет id, полученные при создании в SP (доп. строки / поставщики Шага 3), в элементы стейта.
|
||||||
|
// Результат сохраняем сразу после создания: если дальше сохранение упадёт, повторное «Сохранить»
|
||||||
|
// увидит spId и не создаст ту же строку ещё раз.
|
||||||
|
export function applyCreatedIds<T extends { id: string }>(
|
||||||
|
items: T[],
|
||||||
|
createdById: Record<string, Partial<T>>,
|
||||||
|
): T[] {
|
||||||
|
return items.map(item => (createdById[item.id] ? { ...item, ...createdById[item.id] } : item));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Допуск сравнения сумм с минимумом — полкопейки: суммы с плавающей точкой расходятся в хвосте.
|
||||||
|
const MIN_TOTAL_EPSILON = 0.005;
|
||||||
|
|
||||||
|
// Сумма в ₸ по каждой паре (поставщик, позиция): строки комплекта складываются. Один источник и для
|
||||||
|
// минимума, и для подсветки ячеек — иначе отдельно посчитанный итог комплекта расходился на копейки.
|
||||||
|
export function sumPositionTotals(offers: SupplierOffer[]): Map<string, number> {
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
offers.forEach(o => {
|
||||||
|
const key = buildOfferKey(o.supplierId, o.positionId);
|
||||||
|
totals.set(key, (totals.get(key) ?? 0) + (o.totalWithExtraKzt ?? 0));
|
||||||
|
});
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сумма поставщика по позиции — минимальная среди поставщиков (подсветка зелёным по каждой позиции).
|
||||||
|
export function isMinPositionTotal(total: number | undefined, minTotal: number | undefined): boolean {
|
||||||
|
if (total === undefined || minTotal === undefined || total <= 0) return false;
|
||||||
|
return Math.abs(total - minTotal) < MIN_TOTAL_EPSILON;
|
||||||
|
}
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import { UnitStatusKind } from '../utils/unitConversion';
|
||||||
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
|
// Подсветка ячеек по статусу ед. изм. поставщика (см. getUnitStatus): жёлтым — единица отличается
|
||||||
|
// от заявки, голубым — пересчитано в ед. заявки, красным — пересчитать нельзя.
|
||||||
|
const UNIT_STATUS_CLASS: Record<UnitStatusKind, string> = {
|
||||||
|
same: '',
|
||||||
|
mismatch: styles.unitMismatch,
|
||||||
|
converted: styles.unitConverted,
|
||||||
|
impossible: styles.unitImpossible,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function unitStatusClass(status: UnitStatusKind): string {
|
||||||
|
return UNIT_STATUS_CLASS[status];
|
||||||
|
}
|
||||||
+28
-6
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { AdataCompanyInfo, Supplier } from '../types/domain';
|
import { AdataCompanyInfo, Supplier } from '../types/domain';
|
||||||
|
import { findSiblingColumnIds } from '../utils/supplierColumns';
|
||||||
|
|
||||||
export interface AdataSupplierState {
|
export interface AdataSupplierState {
|
||||||
bin?: string;
|
bin?: string;
|
||||||
@@ -38,9 +39,11 @@ export function useAdataCheck(service: CtService, suppliers: Supplier[]): UseAda
|
|||||||
.then(rows => {
|
.then(rows => {
|
||||||
setAdataMap(prev => {
|
setAdataMap(prev => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
|
// Кэш — на компанию: у поставщика с несколькими КП он нужен каждой его колонке.
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
const sup = suppliersRef.current.find(s => s.spId === r.supplierSpId);
|
suppliersRef.current
|
||||||
if (!sup) return;
|
.filter(s => s.spId === r.supplierSpId)
|
||||||
|
.forEach(sup => {
|
||||||
next[sup.id] = {
|
next[sup.id] = {
|
||||||
...next[sup.id],
|
...next[sup.id],
|
||||||
bin: r.bin,
|
bin: r.bin,
|
||||||
@@ -49,6 +52,7 @@ export function useAdataCheck(service: CtService, suppliers: Supplier[]): UseAda
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -63,22 +67,40 @@ export function useAdataCheck(service: CtService, suppliers: Supplier[]): UseAda
|
|||||||
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], isLoading: false, error: 'БИН поставщика неизвестен — проверка недоступна' } }));
|
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], isLoading: false, error: 'БИН поставщика неизвестен — проверка недоступна' } }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], bin, isLoading: true, error: undefined } }));
|
// Adata проверяет компанию — результат раздаём всем колонкам этого поставщика (несколько КП).
|
||||||
|
const columnIds = findSiblingColumnIds(suppliersRef.current, supplierId);
|
||||||
|
const patchColumns = (state: (prevState: AdataSupplierState | undefined) => AdataSupplierState): void => {
|
||||||
|
setAdataMap(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
columnIds.forEach(id => { next[id] = state(prev[id]); });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
patchColumns(prevState => ({ ...prevState, bin, isLoading: true, error: undefined }));
|
||||||
try {
|
try {
|
||||||
const data = await service.fetchAdataInfo(bin);
|
const data = await service.fetchAdataInfo(bin);
|
||||||
const checkedAt = new Date().toISOString();
|
const checkedAt = new Date().toISOString();
|
||||||
setAdataMap(prev => ({ ...prev, [supplierId]: { bin, data, checkedAt, isLoading: false } }));
|
patchColumns(() => ({ bin, data, checkedAt, isLoading: false }));
|
||||||
if (sup.spId !== undefined) {
|
if (sup.spId !== undefined) {
|
||||||
service.saveAdataResult(sup.spId, data).catch(() => undefined);
|
service.saveAdataResult(sup.spId, data).catch(() => undefined);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], bin, isLoading: false, error: (err as Error).message } }));
|
patchColumns(prevState => ({ ...prevState, bin, isLoading: false, error: (err as Error).message }));
|
||||||
}
|
}
|
||||||
}, [service]);
|
}, [service]);
|
||||||
|
|
||||||
const checkAllSuppliers = useCallback(async (): Promise<void> => {
|
const checkAllSuppliers = useCallback(async (): Promise<void> => {
|
||||||
setIsCheckingAll(true);
|
setIsCheckingAll(true);
|
||||||
const targets = suppliersRef.current.filter(s => s.hasOffer && (adataMapRef.current[s.id]?.bin ?? s.bin));
|
// Одна проверка на компанию: колонки того же поставщика получат результат через checkSupplier.
|
||||||
|
const checkedSpIds = new Set<number>();
|
||||||
|
const targets = suppliersRef.current
|
||||||
|
.filter(s => s.hasOffer && (adataMapRef.current[s.id]?.bin ?? s.bin))
|
||||||
|
.filter(s => {
|
||||||
|
if (s.spId === undefined) return true;
|
||||||
|
if (checkedSpIds.has(s.spId)) return false;
|
||||||
|
checkedSpIds.add(s.spId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
await Promise.all(targets.map(s => checkSupplier(s.id)));
|
await Promise.all(targets.map(s => checkSupplier(s.id)));
|
||||||
setIsCheckingAll(false);
|
setIsCheckingAll(false);
|
||||||
}, [checkSupplier]);
|
}, [checkSupplier]);
|
||||||
|
|||||||
+4
-3
@@ -30,7 +30,7 @@ export function useRecommendedPick(service: CtService, matrix: Step3Matrix): Use
|
|||||||
const [pickError, setPickError] = useState<string | undefined>(undefined);
|
const [pickError, setPickError] = useState<string | undefined>(undefined);
|
||||||
const [recommendedId, setRecommendedId] = useState<string | undefined>(undefined);
|
const [recommendedId, setRecommendedId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const { suppliersWithOffer, criteriaMap, computedOffers, allPositions, setSelectedSupplierId } = matrix;
|
const { suppliersWithOffer, columnDisplayNames, criteriaMap, computedOffers, allPositions, setSelectedSupplierId } = matrix;
|
||||||
|
|
||||||
const allPaymentTermsFilled = suppliersWithOffer.length > 0
|
const allPaymentTermsFilled = suppliersWithOffer.length > 0
|
||||||
&& suppliersWithOffer.every(s => (criteriaMap[s.id]?.paymentType ?? '').trim().length > 0);
|
&& suppliersWithOffer.every(s => (criteriaMap[s.id]?.paymentType ?? '').trim().length > 0);
|
||||||
@@ -43,7 +43,8 @@ export function useRecommendedPick(service: CtService, matrix: Step3Matrix): Use
|
|||||||
const totals = suppliersWithOffer
|
const totals = suppliersWithOffer
|
||||||
.map(s => ({
|
.map(s => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
// Различимое имя: ответ ИИ сопоставляется по имени, у поставщика с несколькими КП оно повторяется.
|
||||||
|
name: columnDisplayNames[s.id] ?? s.name,
|
||||||
paymentType: criteriaMap[s.id]?.paymentType ?? '',
|
paymentType: criteriaMap[s.id]?.paymentType ?? '',
|
||||||
total: allPositions.reduce((sum, pos) => {
|
total: allPositions.reduce((sum, pos) => {
|
||||||
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
|
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
|
||||||
@@ -81,7 +82,7 @@ export function useRecommendedPick(service: CtService, matrix: Step3Matrix): Use
|
|||||||
} finally {
|
} finally {
|
||||||
setIsPicking(false);
|
setIsPicking(false);
|
||||||
}
|
}
|
||||||
}, [allPaymentTermsFilled, suppliersWithOffer, criteriaMap, computedOffers, allPositions, service, setSelectedSupplierId]);
|
}, [allPaymentTermsFilled, suppliersWithOffer, columnDisplayNames, criteriaMap, computedOffers, allPositions, service, setSelectedSupplierId]);
|
||||||
|
|
||||||
return { isPicking, pickError, allPaymentTermsFilled, recommendedId, pickRecommendedSupplier };
|
return { isPicking, pickError, allPaymentTermsFilled, recommendedId, pickRecommendedSupplier };
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-25
@@ -4,8 +4,11 @@ import { SupplierOffer, CurrencyRate, FormStep } from '../types/domain';
|
|||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
|
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
|
||||||
import { buildOfferKey, computeOffer } from './matrixHelpers';
|
import { applyCreatedIds, buildOfferKey, computeOffer, sumPositionTotals } from './matrixHelpers';
|
||||||
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
||||||
|
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
|
||||||
|
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
||||||
|
import { buildCriteriaRecords, restoreCriteria } from '../utils/criteria';
|
||||||
|
|
||||||
let extraIdCounter = 0;
|
let extraIdCounter = 0;
|
||||||
|
|
||||||
@@ -129,23 +132,18 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
const criteriaMapRef = useRef(criteriaMap);
|
const criteriaMapRef = useRef(criteriaMap);
|
||||||
const customCriteriaRef = useRef(customCriteria);
|
const customCriteriaRef = useRef(customCriteria);
|
||||||
|
|
||||||
// Load criteria from SP "Критерии" list by supplier SP IDs
|
// «Дополнительная информация» из SP «Критерии» этой СТ: условия поставщиков и свои строки. Правки
|
||||||
|
// из сессии важнее — SP подставляем только туда, где в сессии пусто.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const supplierSpIds = suppliers
|
if (!ct?.id) return;
|
||||||
.filter(s => s.hasOffer && s.spId !== undefined)
|
const columns = suppliers.filter(s => s.hasOffer !== false);
|
||||||
.map(s => s.spId as number);
|
service.loadCriteriaForCt(ct.id)
|
||||||
if (supplierSpIds.length === 0) return;
|
.then(records => {
|
||||||
service.loadCriteria(supplierSpIds)
|
const restored = restoreCriteria(records, columns);
|
||||||
.then(rawCriteria => {
|
setCriteriaMap(prev => ({ ...restored.criteriaMap, ...prev }));
|
||||||
const spMap: Record<string, CriteriaEntry> = {};
|
setCustomCriteria(prev => (prev.length > 0 ? prev : restored.customCriteria));
|
||||||
rawCriteria.forEach(c => {
|
|
||||||
const sup = suppliers.find(s => s.spId === c.supplierSpId);
|
|
||||||
if (sup) spMap[sup.id] = { paymentType: c.paymentType, deliveryType: c.deliveryType, deadline: c.deadline, sertificates: c.sertificates };
|
|
||||||
});
|
|
||||||
// Merge SP data with any user edits from formData — user edits take priority
|
|
||||||
setCriteriaMap(prev => ({ ...spMap, ...prev }));
|
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(err => console.warn('[Шаг 3] не удалось загрузить доп. информацию:', err));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -165,7 +163,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
const newEdits: EditableOffers = {};
|
const newEdits: EditableOffers = {};
|
||||||
rawOffers.forEach(raw => {
|
rawOffers.forEach(raw => {
|
||||||
const pos = allLoadPositions.find(p => p.spId === raw.positionSpId);
|
const pos = allLoadPositions.find(p => p.spId === raw.positionSpId);
|
||||||
const sup = suppliers.find(s => s.spId === raw.supplierSpId);
|
const sup = findColumnForRecord(suppliers, raw.supplierSpId, raw.offerFileSpId);
|
||||||
if (!pos || !sup) return;
|
if (!pos || !sup) return;
|
||||||
const key = buildOfferKey(sup.id, pos.id);
|
const key = buildOfferKey(sup.id, pos.id);
|
||||||
const item: CellEdit = {
|
const item: CellEdit = {
|
||||||
@@ -221,7 +219,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
const merged = currentOffers.map((o: SupplierOffer) => {
|
const merged = currentOffers.map((o: SupplierOffer) => {
|
||||||
const sup = suppliersRef.current.find(s => s.id === o.supplierId);
|
const sup = suppliersRef.current.find(s => s.id === o.supplierId);
|
||||||
const pos = selectedPositionsRef.current.find(p => p.id === o.positionId);
|
const pos = selectedPositionsRef.current.find(p => p.id === o.positionId);
|
||||||
const supSpId = sup?.spId !== undefined ? String(sup.spId) : undefined;
|
// Сверка по SP Id поставщика — только для старых колонок без файла КП: у поставщика с
|
||||||
|
// несколькими КП она подхватила бы оффер чужой колонки.
|
||||||
|
const supSpId = sup?.spId !== undefined && sup.kpFileSpId === undefined ? String(sup.spId) : undefined;
|
||||||
const posSpId = pos?.spId !== undefined ? String(pos.spId) : undefined;
|
const posSpId = pos?.spId !== undefined ? String(pos.spId) : undefined;
|
||||||
const prev = prevOffers.find((p: SupplierOffer) =>
|
const prev = prevOffers.find((p: SupplierOffer) =>
|
||||||
(p.supplierId === o.supplierId || (supSpId !== undefined && p.supplierId === supSpId)) &&
|
(p.supplierId === o.supplierId || (supSpId !== undefined && p.supplierId === supSpId)) &&
|
||||||
@@ -303,6 +303,17 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
})),
|
})),
|
||||||
], [suppliers, extraSuppliers]);
|
], [suppliers, extraSuppliers]);
|
||||||
|
|
||||||
|
// У поставщика может быть несколько КП (колонок) — подписываем их файлом КП, а в текстах и ИИ
|
||||||
|
// используем различимые имена «Название (файл)».
|
||||||
|
const columnLabels = useMemo(
|
||||||
|
() => buildColumnLabels(suppliersWithOffer, formData.uploadedKpFiles ?? []),
|
||||||
|
[suppliersWithOffer, formData.uploadedKpFiles],
|
||||||
|
);
|
||||||
|
const columnDisplayNames = useMemo(
|
||||||
|
() => buildColumnDisplayNames(suppliersWithOffer, columnLabels),
|
||||||
|
[suppliersWithOffer, columnLabels],
|
||||||
|
);
|
||||||
|
|
||||||
const allPositions = useMemo((): PositionRow[] => {
|
const allPositions = useMemo((): PositionRow[] => {
|
||||||
// Extra positions take priority: if a selected position shares a name with an extra, hide it from the regular section
|
// Extra positions take priority: if a selected position shares a name with an extra, hide it from the regular section
|
||||||
const extraNames = new Set(extraPositions.map(ep => ep.materialName.toLowerCase().trim()));
|
const extraNames = new Set(extraPositions.map(ep => ep.materialName.toLowerCase().trim()));
|
||||||
@@ -367,17 +378,19 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
|
|
||||||
React.useEffect(() => { computedOffersRef.current = computedOffers; }, [computedOffers]);
|
React.useEffect(() => { computedOffersRef.current = computedOffers; }, [computedOffers]);
|
||||||
|
|
||||||
|
// Суммы по (поставщик, позиция) — один источник и для минимума, и для подсветки ячеек (п.13).
|
||||||
|
const positionTotals = useMemo(() => sumPositionTotals(computedOffers), [computedOffers]);
|
||||||
|
|
||||||
const minTotalByPosition = useMemo((): Map<string, number> => {
|
const minTotalByPosition = useMemo((): Map<string, number> => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
allPositions.forEach(pos => {
|
allPositions.forEach(pos => {
|
||||||
const values = suppliersWithOffer
|
const values = suppliersWithOffer
|
||||||
.map(s => computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id)
|
.map(s => positionTotals.get(buildOfferKey(s.id, pos.id)) ?? 0)
|
||||||
.reduce((sum, o) => sum + (o.totalWithExtraKzt ?? 0), 0))
|
|
||||||
.filter(v => v > 0);
|
.filter(v => v > 0);
|
||||||
if (values.length > 0) map.set(pos.id, Math.min(...values));
|
if (values.length > 0) map.set(pos.id, Math.min(...values));
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [computedOffers, allPositions, suppliersWithOffer]);
|
}, [positionTotals, allPositions, suppliersWithOffer]);
|
||||||
|
|
||||||
const updateEdit = useCallback(
|
const updateEdit = useCallback(
|
||||||
(supplierId: string, positionId: string, rowIdx: number, field: 'pricePerUnit' | 'quantity', value: string) => {
|
(supplierId: string, positionId: string, rowIdx: number, field: 'pricePerUnit' | 'quantity', value: string) => {
|
||||||
@@ -670,6 +683,26 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
onSave({ step3ExtraPositions: updated });
|
onSave({ step3ExtraPositions: updated });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Созданные на «Сохранить» поставщиков и доп. строки запоминаем сразу — в стейт и сессию. Раньше их
|
||||||
|
// spId попадали в стейт только в самом конце handleSave: упади saveOffers — повторное «Сохранить»
|
||||||
|
// создало бы в SP ещё одну строку «Логистика» / ещё одного поставщика.
|
||||||
|
const rememberCreatedExtras = (
|
||||||
|
supSpIds: Record<string, number>,
|
||||||
|
offerIds: Record<string, number>,
|
||||||
|
posSpIds: Record<string, number>,
|
||||||
|
): void => {
|
||||||
|
const supplierPatches: Record<string, Partial<ExtraSupplier>> = {};
|
||||||
|
Object.keys(supSpIds).forEach(id => { supplierPatches[id] = { spId: supSpIds[id], offerId: offerIds[id] }; });
|
||||||
|
const positionPatches: Record<string, Partial<ExtraPosition>> = {};
|
||||||
|
Object.keys(posSpIds).forEach(id => { positionPatches[id] = { spId: posSpIds[id] }; });
|
||||||
|
|
||||||
|
const nextSuppliers = applyCreatedIds(extraSuppliers, supplierPatches);
|
||||||
|
const nextPositions = applyCreatedIds(extraPositions, positionPatches);
|
||||||
|
setExtraSuppliers(nextSuppliers);
|
||||||
|
setExtraPositions(nextPositions);
|
||||||
|
onSave({ step3ExtraSuppliers: nextSuppliers, step3ExtraPositions: nextPositions });
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
const handleSave = async (): Promise<void> => {
|
||||||
if (!ct) return;
|
if (!ct) return;
|
||||||
const deliveryMissingKm = extraPositions.filter(ep =>
|
const deliveryMissingKm = extraPositions.filter(ep =>
|
||||||
@@ -685,6 +718,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
try {
|
try {
|
||||||
const extraSupSpIds: Record<string, number> = {};
|
const extraSupSpIds: Record<string, number> = {};
|
||||||
const extraOfferIds: Record<string, number> = {};
|
const extraOfferIds: Record<string, number> = {};
|
||||||
|
const extraPosSpIds: Record<string, number> = {};
|
||||||
|
try {
|
||||||
for (const es of extraSuppliers) {
|
for (const es of extraSuppliers) {
|
||||||
if (es.spId) {
|
if (es.spId) {
|
||||||
// Already created in SP — either on a previous save, or eagerly by
|
// Already created in SP — either on a previous save, or eagerly by
|
||||||
@@ -698,7 +733,6 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
extraOfferIds[es.id] = offerId;
|
extraOfferIds[es.id] = offerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extraPosSpIds: Record<string, number> = {};
|
|
||||||
for (const ep of extraPositions) {
|
for (const ep of extraPositions) {
|
||||||
if (ep.spId) {
|
if (ep.spId) {
|
||||||
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
|
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
|
||||||
@@ -721,6 +755,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
}]);
|
}]);
|
||||||
if (saved[0]?.spId) extraPosSpIds[ep.id] = saved[0].spId;
|
if (saved[0]?.spId) extraPosSpIds[ep.id] = saved[0].spId;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
rememberCreatedExtras(extraSupSpIds, extraOfferIds, extraPosSpIds);
|
||||||
|
}
|
||||||
|
|
||||||
const offersToSave = computedOffers
|
const offersToSave = computedOffers
|
||||||
.filter(o => o.totalWithExtraKzt > 0)
|
.filter(o => o.totalWithExtraKzt > 0)
|
||||||
@@ -729,16 +766,22 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
const posSpId = extraPosSpIds[o.positionId];
|
const posSpId = extraPosSpIds[o.positionId];
|
||||||
const existingSup = suppliers.find(s => s.id === o.supplierId);
|
const existingSup = suppliers.find(s => s.id === o.supplierId);
|
||||||
const existingPos = selectedPositions.find(p => p.id === o.positionId);
|
const existingPos = selectedPositions.find(p => p.id === o.positionId);
|
||||||
const supSpIdStr = existingSup?.spId !== undefined ? String(existingSup.spId) : undefined;
|
// Сверка по SP Id поставщика — только для старых колонок без файла КП (см. beforeunload выше).
|
||||||
|
const supSpIdStr = existingSup?.spId !== undefined && existingSup.kpFileSpId === undefined
|
||||||
|
? String(existingSup.spId)
|
||||||
|
: undefined;
|
||||||
const posSpIdStr = existingPos?.spId !== undefined ? String(existingPos.spId) : undefined;
|
const posSpIdStr = existingPos?.spId !== undefined ? String(existingPos.spId) : undefined;
|
||||||
const originalOffer = offers.find(orig =>
|
const originalOffer = offers.find(orig =>
|
||||||
(orig.supplierId === o.supplierId || (supSpIdStr !== undefined && orig.supplierId === supSpIdStr)) &&
|
(orig.supplierId === o.supplierId || (supSpIdStr !== undefined && orig.supplierId === supSpIdStr)) &&
|
||||||
(orig.positionId === o.positionId || (posSpIdStr !== undefined && orig.positionId === posSpIdStr)) &&
|
(orig.positionId === o.positionId || (posSpIdStr !== undefined && orig.positionId === posSpIdStr)) &&
|
||||||
(orig.itemIndex ?? 0) === (o.itemIndex ?? 0)
|
(orig.itemIndex ?? 0) === (o.itemIndex ?? 0)
|
||||||
);
|
);
|
||||||
const kpFileOfferId = existingSup
|
// Файл КП колонки известен напрямую; поиск по имени поставщика — только для старых колонок
|
||||||
|
// (у поставщика с несколькими КП он отдал бы обеим колонкам первый файл).
|
||||||
|
const kpFileOfferId = existingSup?.kpFileSpId
|
||||||
|
?? (existingSup
|
||||||
? (formData.uploadedKpFiles ?? []).find(f => f.supplierName === existingSup.name)?.spId
|
? (formData.uploadedKpFiles ?? []).find(f => f.supplierName === existingSup.name)?.spId
|
||||||
: undefined;
|
: undefined);
|
||||||
const isDeliveryPos = extraPositions.find(ep => ep.id === o.positionId)?.isDelivery;
|
const isDeliveryPos = extraPositions.find(ep => ep.id === o.positionId)?.isDelivery;
|
||||||
const kmVal = isDeliveryPos ? deliveryKm[`${o.positionId}::${o.supplierId}`] : undefined;
|
const kmVal = isDeliveryPos ? deliveryKm[`${o.positionId}::${o.supplierId}`] : undefined;
|
||||||
return {
|
return {
|
||||||
@@ -851,11 +894,14 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
setSaveError,
|
setSaveError,
|
||||||
// derived
|
// derived
|
||||||
suppliersWithOffer,
|
suppliersWithOffer,
|
||||||
|
columnLabels,
|
||||||
|
columnDisplayNames,
|
||||||
allPositions,
|
allPositions,
|
||||||
regularPositions,
|
regularPositions,
|
||||||
extraPositionsRows,
|
extraPositionsRows,
|
||||||
supplierCurrencies,
|
supplierCurrencies,
|
||||||
computedOffers,
|
computedOffers,
|
||||||
|
positionTotals,
|
||||||
minTotalByPosition,
|
minTotalByPosition,
|
||||||
// handlers
|
// handlers
|
||||||
updateEdit,
|
updateEdit,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Alert, Spinner, Dialog, Icon } from "@tnsu/ui-kit";
|
import { Button, Alert, Spinner, Dialog, Icon } from "@tnsu/ui-kit";
|
||||||
import { CTStatus, FormStep } from "../types/domain";
|
import { CTStatus, FormStep } from "../types/domain";
|
||||||
import { CtService } from "../../services/CtService";
|
import { CtService } from "../../services/CtService";
|
||||||
import { CTFormData } from "../hooks/useCtForm";
|
import { CTFormData } from "../hooks/useCtForm";
|
||||||
import { useIncore } from "./useIncore";
|
import { useIncore } from "./useIncore";
|
||||||
|
import { findReworkRow } from "../utils/approvalAccess";
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -30,6 +31,7 @@ const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
|
|||||||
Отклонено: "rejected",
|
Отклонено: "rejected",
|
||||||
Согласовано: "approved",
|
Согласовано: "approved",
|
||||||
"На согласовании": "on_approval",
|
"На согласовании": "on_approval",
|
||||||
|
"На доработке": "rework",
|
||||||
};
|
};
|
||||||
|
|
||||||
const INCORE_STEP_LABELS: Record<string, string> = {
|
const INCORE_STEP_LABELS: Record<string, string> = {
|
||||||
@@ -57,6 +59,20 @@ export const Step4Approval: React.FC<Props> = ({ service, formData, onPrev, onSa
|
|||||||
|
|
||||||
const incore = useIncore({ service, formData, isInitiator });
|
const incore = useIncore({ service, formData, isInitiator });
|
||||||
|
|
||||||
|
// Возврат на доработку: кто вернул и что исправить — из записанного маршрута. Сам шаг сейчас показывает
|
||||||
|
// новый планируемый маршрут для повторной отправки (идёт с начала), старых строк в нём не видно.
|
||||||
|
const [reworkRow, setReworkRow] = useState<IMatrixRow | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "rework" || !ctId) {
|
||||||
|
setReworkRow(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
service
|
||||||
|
.getApprovalMatrixForRequest(ctId)
|
||||||
|
.then((rows) => setReworkRow(findReworkRow(rows)))
|
||||||
|
.catch((err) => console.warn("[Шаг 4] не удалось загрузить комментарий возврата на доработку:", err));
|
||||||
|
}, [service, ctId, status]);
|
||||||
|
|
||||||
const loadMatrix = useMemo(
|
const loadMatrix = useMemo(
|
||||||
() => (): Promise<IMatrixRow[]> =>
|
() => (): Promise<IMatrixRow[]> =>
|
||||||
isSubmitted && ctId
|
isSubmitted && ctId
|
||||||
@@ -134,6 +150,13 @@ export const Step4Approval: React.FC<Props> = ({ service, formData, onPrev, onSa
|
|||||||
{status === "approved" && <Alert variant="ok">СТ согласована всеми участниками.</Alert>}
|
{status === "approved" && <Alert variant="ok">СТ согласована всеми участниками.</Alert>}
|
||||||
{status === "rejected" && <Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert>}
|
{status === "rejected" && <Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert>}
|
||||||
{status === "on_approval" && <Alert variant="info">СТ находится на согласовании.</Alert>}
|
{status === "on_approval" && <Alert variant="info">СТ находится на согласовании.</Alert>}
|
||||||
|
{status === "rework" && (
|
||||||
|
<Alert variant="warn">
|
||||||
|
СТ возвращена на доработку{reworkRow?.who ? ` (${reworkRow.who})` : ""}.
|
||||||
|
{reworkRow?.comment ? ` Что исправить: ${reworkRow.comment}.` : ""} Внесите правки и отправьте на согласование
|
||||||
|
заново — маршрут пойдёт с начала.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
|
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
|
||||||
|
|
||||||
@@ -201,6 +224,7 @@ export const Step4Approval: React.FC<Props> = ({ service, formData, onPrev, onSa
|
|||||||
onDecided={handleDecided}
|
onDecided={handleDecided}
|
||||||
search={(q) => service.searchUsers(q)}
|
search={(q) => service.searchUsers(q)}
|
||||||
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||||
|
allowRework
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -54,15 +54,18 @@ export type CTStatus =
|
|||||||
| "formed"
|
| "formed"
|
||||||
| "on_approval"
|
| "on_approval"
|
||||||
| "approved"
|
| "approved"
|
||||||
| "rejected";
|
| "rejected"
|
||||||
|
| "rework"; // возвращена согласующим на доработку — инициатор правит и отправляет заново
|
||||||
|
|
||||||
export interface Supplier {
|
export interface Supplier {
|
||||||
id: string;
|
id: string; // ключ колонки Шага 3: `${spId}_${kpFileSpId}` (см. utils/supplierColumns), у старых данных — String(spId)
|
||||||
spId?: number; // SP item ID in Поставщики (populated after saveSupplierWithOffer)
|
spId?: number; // SP item ID in Поставщики (populated after saveSupplierWithOffer)
|
||||||
|
kpFileSpId?: number; // SP Id файла КП этой колонки — у поставщика может быть несколько КП (напр. KZT и USD)
|
||||||
name: string;
|
name: string;
|
||||||
inn?: string; // UI only — not stored in SP
|
inn?: string; // UI only — not stored in SP
|
||||||
bin?: string; // БИН из флоу (Adata) — хранится в SP-поле 'bin'
|
bin?: string; // БИН из флоу (Adata) — хранится в SP-поле 'bin'
|
||||||
hasOffer?: boolean; // determines target list: Поставщики vs Не получены КП
|
hasOffer?: boolean; // determines target list: Поставщики vs Не получены КП
|
||||||
|
comment?: string; // только у поставщика без КП (hasOffer: false) — почему нет КП
|
||||||
status?: string;
|
status?: string;
|
||||||
currency?: string; // currency code for KP (KZT by default); stored here so Step 3 can show correct columns even if offers are empty
|
currency?: string; // currency code for KP (KZT by default); stored here so Step 3 can show correct columns even if offers are empty
|
||||||
adataData?: AdataCompanyInfo; // кэш последней проверки Adata — SP-поле 'adataData' (JSON)
|
adataData?: AdataCompanyInfo; // кэш последней проверки Adata — SP-поле 'adataData' (JSON)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { findReworkRow, isUserInApprovalChain, pickRowToNotify } from './approvalAccess';
|
||||||
|
import { IMatrixRow } from '../../../../shared/approval';
|
||||||
|
|
||||||
|
function makeRow(overrides: Partial<IMatrixRow>): IMatrixRow {
|
||||||
|
return {
|
||||||
|
order: 1, who: 'Иванов Иван', email: 'ivanov@tnsu.kz', position: 'Директор', status: 'На согласовании',
|
||||||
|
date: '', comment: '', allowReassign: false, ...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isUserInApprovalChain (кому видны цены и КП при согласовании)', () => {
|
||||||
|
// Раньше сверяли только по ФИО: «Иванов Иван» в SP и «Иван Иванов» в профиле — согласующему
|
||||||
|
// скрывались цены и КП, хотя он в цепочке.
|
||||||
|
it('находит согласующего по почте, даже если ФИО записано иначе', () => {
|
||||||
|
const matrix = [makeRow({ who: 'Иванов Иван' })];
|
||||||
|
|
||||||
|
expect(isUserInApprovalChain(matrix, { email: 'IVANOV@tnsu.kz', displayName: 'Иван Иванов' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без почты в строке — сверяет по ФИО', () => {
|
||||||
|
const matrix = [makeRow({ email: '' })];
|
||||||
|
|
||||||
|
expect(isUserInApprovalChain(matrix, { email: 'ivanov@tnsu.kz', displayName: 'Иванов Иван' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не в цепочке — false', () => {
|
||||||
|
const matrix = [makeRow({})];
|
||||||
|
|
||||||
|
expect(isUserInApprovalChain(matrix, { email: 'petrov@tnsu.kz', displayName: 'Петров Пётр' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустая цепочка — false', () => {
|
||||||
|
expect(isUserInApprovalChain([], { email: 'ivanov@tnsu.kz', displayName: 'Иванов Иван' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findReworkRow (кто вернул СТ на доработку и что исправить)', () => {
|
||||||
|
it('находит строку со статусом «На доработке»', () => {
|
||||||
|
const matrix = [
|
||||||
|
makeRow({ order: 1, status: 'Согласовано' }),
|
||||||
|
makeRow({ order: 2, who: 'Петров Пётр', status: 'На доработке', comment: 'Уточните цены поставщика' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(findReworkRow(matrix)).toMatchObject({ who: 'Петров Пётр', comment: 'Уточните цены поставщика' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет возврата — undefined', () => {
|
||||||
|
expect(findReworkRow([makeRow({ status: 'Согласовано' })])).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickRowToNotify (какую строку маршрута передать потоку уведомлений)', () => {
|
||||||
|
const row = (id: number, status: string): { id: number; status: string } => ({ id, status });
|
||||||
|
|
||||||
|
it('есть активный согласующий — его строку («поступила на согласование»)', () => {
|
||||||
|
expect(pickRowToNotify([row(1, 'Согласовано'), row(2, 'На согласовании'), row(3, 'Ждёт очереди')])?.id).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Раньше при возврате уходила последняя строка (напр. «Ждёт очереди»), и поток не знал, кто вернул и с каким
|
||||||
|
// комментарием.
|
||||||
|
it('возврат на доработку — строку того, кто вернул', () => {
|
||||||
|
expect(pickRowToNotify([row(1, 'Согласовано'), row(2, 'На доработке'), row(3, 'Ждёт очереди')])?.id).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отклонение — строку того, кто отклонил', () => {
|
||||||
|
expect(pickRowToNotify([row(1, 'Отклонено'), row(2, 'Ждёт очереди')])?.id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('все согласовали — последнюю строку', () => {
|
||||||
|
expect(pickRowToNotify([row(1, 'Согласовано'), row(2, 'Согласовано')])?.id).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустой маршрут — нечего отправлять', () => {
|
||||||
|
expect(pickRowToNotify([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { IMatrixRow } from '../../../../shared/approval';
|
||||||
|
import { REQUEST_STATUS } from '../../../../shared/approval/models/statuses';
|
||||||
|
|
||||||
|
export interface CurrentUser {
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Участник цепочки согласования — ему видны цены и КП. Сверяем по почте (без учёта регистра): ФИО в SP
|
||||||
|
// и в профиле пишут по-разному («Иванов Иван» / «Иван Иванов»), и по одному ФИО согласующему цены и КП
|
||||||
|
// скрывались. ФИО — запасной вариант для строк без почты.
|
||||||
|
export function isUserInApprovalChain(matrix: IMatrixRow[], user: CurrentUser): boolean {
|
||||||
|
const email = user.email.trim().toLowerCase();
|
||||||
|
return matrix.some(row => {
|
||||||
|
const rowEmail = (row.email ?? '').trim().toLowerCase();
|
||||||
|
if (rowEmail && email) return rowEmail === email;
|
||||||
|
return row.who === user.displayName;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка маршрута, которой СТ вернули на доработку: инициатору показываем, кто вернул и что исправить.
|
||||||
|
export function findReworkRow(matrix: IMatrixRow[]): IMatrixRow | undefined {
|
||||||
|
return matrix.find(row => row.status === REQUEST_STATUS.rework);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка маршрута для потока уведомлений (он по ней выходит на СТ, её автора и комментарий):
|
||||||
|
// активный согласующий — «поступила на согласование»; иначе строка того, кто вернул на доработку или
|
||||||
|
// отклонил, — чтобы в письме инициатору были его имя и комментарий; иначе (все согласовали) — последняя.
|
||||||
|
export function pickRowToNotify<T extends { status: string }>(rows: T[]): T | undefined {
|
||||||
|
return rows.find(r => r.status === REQUEST_STATUS.onApproval)
|
||||||
|
?? rows.find(r => r.status === REQUEST_STATUS.rework)
|
||||||
|
?? rows.find(r => r.status === REQUEST_STATUS.rejected)
|
||||||
|
?? rows[rows.length - 1];
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { buildCriteriaRecords, isPaymentTypeOption, plainTextFromSp, restoreCriteria } from './criteria';
|
||||||
|
import { Supplier } from '../types/domain';
|
||||||
|
|
||||||
|
const ARLAN_KZT: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true };
|
||||||
|
const ARLAN_USD: Supplier = { id: '263_285', spId: 263, kpFileSpId: 285, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true };
|
||||||
|
const KALA: Supplier = { id: '264_278', spId: 264, kpFileSpId: 278, name: 'ТОО «Кала Саплай»', hasOffer: true };
|
||||||
|
const MANUAL: Supplier = { id: 'extra_sup_1', name: 'Ручной поставщик', hasOffer: true };
|
||||||
|
|
||||||
|
const spIdOf = (column: Supplier): number | undefined => column.spId ?? (column.id === 'extra_sup_1' ? 300 : undefined);
|
||||||
|
|
||||||
|
describe('buildCriteriaRecords (ручные правки «Дополнительной информации» → SP)', () => {
|
||||||
|
// Раньше правки и свои строки жили только в браузере: согласующий и PDF видели лишь то, что распознал ИИ.
|
||||||
|
it('одна запись условий на поставщика', () => {
|
||||||
|
const records = buildCriteriaRecords(
|
||||||
|
[KALA],
|
||||||
|
{ '264_278': { paymentType: '100% предоплата', deliveryType: 'EXW', deadline: '10 дней', sertificates: 'Да' } },
|
||||||
|
[],
|
||||||
|
spIdOf,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(records).toEqual([{
|
||||||
|
supplierSpId: 264, paymentType: '100% предоплата', deliveryType: 'EXW', deadline: '10 дней', sertificates: 'Да',
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Условия в SP — на поставщика, а не на КП: у колонок одного поставщика берём первую заполненную.
|
||||||
|
it('несколько КП одного поставщика — одна запись', () => {
|
||||||
|
const records = buildCriteriaRecords(
|
||||||
|
[ARLAN_KZT, ARLAN_USD],
|
||||||
|
{
|
||||||
|
'263_277': { paymentType: '', deliveryType: '', deadline: '', sertificates: '' },
|
||||||
|
'263_285': { paymentType: '50% предоплата, 50% по факту поставки', deliveryType: '', deadline: '', sertificates: '' },
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
spIdOf,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(records).toHaveLength(1);
|
||||||
|
expect(records[0]).toMatchObject({ paymentType: '50% предоплата, 50% по факту поставки' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('свои строки — запись на (строка, поставщик) с непустым значением', () => {
|
||||||
|
const records = buildCriteriaRecords(
|
||||||
|
[KALA, MANUAL],
|
||||||
|
{},
|
||||||
|
[{ id: 'c1', label: 'Гарантия', values: { '264_278': '12 мес', extra_sup_1: '' } }],
|
||||||
|
spIdOf,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Пустые условия поставщика не пишем — только заполненная своя строка.
|
||||||
|
expect(records).toEqual([{ supplierSpId: 264, criteriaLabel: 'Гарантия', value: '12 мес' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пропускает колонку без SP Id поставщика', () => {
|
||||||
|
const noSp: Supplier = { id: 'local_1', name: 'Без SP', hasOffer: true };
|
||||||
|
|
||||||
|
expect(buildCriteriaRecords([noSp], { local_1: { paymentType: 'x', deliveryType: '', deadline: '', sertificates: '' } }, [], () => undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoreCriteria (SP → таблица, в т.ч. по ссылке)', () => {
|
||||||
|
it('условия поставщика расходятся по всем его колонкам, свои строки группируются по названию', () => {
|
||||||
|
const restored = restoreCriteria(
|
||||||
|
[
|
||||||
|
{ id: 1, supplierSpId: 263, paymentType: 'Отсрочка', deliveryType: 'DAP', deadline: '', sertificates: 'Нет' },
|
||||||
|
{ id: 2, supplierSpId: 263, criteriaLabel: 'Гарантия', value: '24 мес' },
|
||||||
|
{ id: 3, supplierSpId: 264, criteriaLabel: 'Гарантия', value: '12 мес' },
|
||||||
|
],
|
||||||
|
[ARLAN_KZT, ARLAN_USD, KALA],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(restored.criteriaMap['263_277'].paymentType).toBe('Отсрочка');
|
||||||
|
expect(restored.criteriaMap['263_285'].sertificates).toBe('Нет');
|
||||||
|
expect(restored.customCriteria).toEqual([
|
||||||
|
{ id: 'sp_Гарантия', label: 'Гарантия', values: { '263_277': '24 мес', '263_285': '24 мес', '264_278': '12 мес' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ИИ при каждой обработке КП добавляет запись условий — берём последнюю.
|
||||||
|
it('из нескольких записей условий поставщика берёт последнюю', () => {
|
||||||
|
const restored = restoreCriteria(
|
||||||
|
[
|
||||||
|
{ id: 5, supplierSpId: 264, paymentType: 'старое', deliveryType: '', deadline: '', sertificates: '' },
|
||||||
|
{ id: 9, supplierSpId: 264, paymentType: 'новое', deliveryType: '', deadline: '', sertificates: '' },
|
||||||
|
],
|
||||||
|
[KALA],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(restored.criteriaMap['264_278'].paymentType).toBe('новое');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPaymentTypeOption', () => {
|
||||||
|
it('значение из списка — да, своё — нет', () => {
|
||||||
|
expect(isPaymentTypeOption('100% предоплата')).toBe(true);
|
||||||
|
expect(isPaymentTypeOption('40% предоплата, 60% по аккредитиву')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('plainTextFromSp', () => {
|
||||||
|
// comment в SP «Критерии» — многострочное поле с форматированием: SharePoint заворачивает текст в HTML,
|
||||||
|
// и по ссылке своя строка показывалась как «<div class="ExternalClass…">да</div>».
|
||||||
|
it('убирает обёртку ExternalClass и теги', () => {
|
||||||
|
expect(plainTextFromSp('<div class="ExternalClass11A142948BDD4FEB93E8B98BB14AE465">да</div>')).toBe('да');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('переносы строк сохраняет, HTML-сущности раскодирует', () => {
|
||||||
|
expect(plainTextFromSp('<div>12 мес<br>от "поставки" & монтажа</div>')).toBe('12 мес\nот "поставки" & монтажа');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('обычный текст не трогает', () => {
|
||||||
|
expect(plainTextFromSp('12 мес')).toBe('12 мес');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Supplier } from '../types/domain';
|
||||||
|
import { CriteriaEntry } from '../Step3ComparisonTable/matrixTypes';
|
||||||
|
import { PAYMENT_TYPE_OPTIONS } from '../Step3ComparisonTable/matrixHelpers';
|
||||||
|
|
||||||
|
// Своя строка «Дополнительной информации» (кнопка «Добавить условие»): значения по колонкам.
|
||||||
|
export interface CustomCriteriaRow {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
values: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись «Критерии» в SP. Условия поставщика — одна запись на поставщика; своя строка — запись на
|
||||||
|
// (строка, поставщик): название в criteriaLabel, значение в comment (как в «Критерии ОИД»).
|
||||||
|
export interface FixedCriteriaRecord {
|
||||||
|
id?: number;
|
||||||
|
supplierSpId: number;
|
||||||
|
paymentType: string;
|
||||||
|
deliveryType: string;
|
||||||
|
deadline: string;
|
||||||
|
sertificates: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomCriteriaRecord {
|
||||||
|
id?: number;
|
||||||
|
supplierSpId: number;
|
||||||
|
criteriaLabel: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CriteriaRecord = FixedCriteriaRecord | CustomCriteriaRecord;
|
||||||
|
|
||||||
|
const isCustomRecord = (r: CriteriaRecord): r is CustomCriteriaRecord => 'criteriaLabel' in r;
|
||||||
|
|
||||||
|
const EMPTY_ENTRY: CriteriaEntry = { paymentType: '', deliveryType: '', deadline: '', sertificates: '' };
|
||||||
|
|
||||||
|
const hasAnyValue = (e: CriteriaEntry): boolean =>
|
||||||
|
[e.paymentType, e.deliveryType, e.deadline, e.sertificates].some(v => (v ?? '').trim() !== '');
|
||||||
|
|
||||||
|
const HTML_ENTITIES: Record<string, string> = {
|
||||||
|
' ': ' ',
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
''': "'",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Текст из многострочного поля SP с форматированием (comment в «Критерии»): SharePoint хранит его как HTML
|
||||||
|
// в обёртке <div class="ExternalClass…">. Оставляем чистый текст, переносы строк — как есть.
|
||||||
|
export function plainTextFromSp(value: string): string {
|
||||||
|
if (!value.includes('<')) return value;
|
||||||
|
return value
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/&(nbsp|amp|lt|gt|quot|#39);/g, entity => HTML_ENTITIES[entity] ?? entity)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPaymentTypeOption(value: string): boolean {
|
||||||
|
return PAYMENT_TYPE_OPTIONS.includes(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Таблица «Дополнительная информация» → записи SP. Условия в SP — на поставщика, а не на КП: у колонок
|
||||||
|
// одного поставщика (несколько КП) берём первую заполненную. Колонки без SP Id поставщика пропускаем.
|
||||||
|
export function buildCriteriaRecords(
|
||||||
|
columns: Supplier[],
|
||||||
|
criteriaMap: Record<string, CriteriaEntry>,
|
||||||
|
customCriteria: CustomCriteriaRow[],
|
||||||
|
spIdOf: (column: Supplier) => number | undefined,
|
||||||
|
): CriteriaRecord[] {
|
||||||
|
const records: CriteriaRecord[] = [];
|
||||||
|
const savedSuppliers = new Set<number>();
|
||||||
|
|
||||||
|
columns.forEach(column => {
|
||||||
|
const supplierSpId = spIdOf(column);
|
||||||
|
const entry = criteriaMap[column.id];
|
||||||
|
if (supplierSpId === undefined || savedSuppliers.has(supplierSpId) || !entry || !hasAnyValue(entry)) return;
|
||||||
|
savedSuppliers.add(supplierSpId);
|
||||||
|
records.push({
|
||||||
|
supplierSpId,
|
||||||
|
paymentType: entry.paymentType.trim(),
|
||||||
|
deliveryType: entry.deliveryType.trim(),
|
||||||
|
deadline: entry.deadline.trim(),
|
||||||
|
sertificates: entry.sertificates.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
customCriteria.forEach(row => {
|
||||||
|
const savedForRow = new Set<number>();
|
||||||
|
columns.forEach(column => {
|
||||||
|
const supplierSpId = spIdOf(column);
|
||||||
|
const value = (row.values[column.id] ?? '').trim();
|
||||||
|
if (supplierSpId === undefined || !value || savedForRow.has(supplierSpId)) return;
|
||||||
|
savedForRow.add(supplierSpId);
|
||||||
|
records.push({ supplierSpId, criteriaLabel: row.label, value });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Записи SP → таблица (в т.ч. при открытии по ссылке). Условия поставщика расходятся по всем его
|
||||||
|
// колонкам; ИИ при каждой обработке КП добавляет запись — берём последнюю (max Id).
|
||||||
|
export function restoreCriteria(
|
||||||
|
records: CriteriaRecord[],
|
||||||
|
columns: Supplier[],
|
||||||
|
): { criteriaMap: Record<string, CriteriaEntry>; customCriteria: CustomCriteriaRow[] } {
|
||||||
|
const latestFixed = new Map<number, FixedCriteriaRecord>();
|
||||||
|
records.forEach(r => {
|
||||||
|
if (isCustomRecord(r)) return;
|
||||||
|
const previous = latestFixed.get(r.supplierSpId);
|
||||||
|
if (!previous || (r.id ?? 0) > (previous.id ?? 0)) latestFixed.set(r.supplierSpId, r);
|
||||||
|
});
|
||||||
|
|
||||||
|
const criteriaMap: Record<string, CriteriaEntry> = {};
|
||||||
|
columns.forEach(column => {
|
||||||
|
const fixed = column.spId !== undefined ? latestFixed.get(column.spId) : undefined;
|
||||||
|
if (!fixed) return;
|
||||||
|
criteriaMap[column.id] = {
|
||||||
|
...EMPTY_ENTRY,
|
||||||
|
paymentType: fixed.paymentType,
|
||||||
|
deliveryType: fixed.deliveryType,
|
||||||
|
deadline: fixed.deadline,
|
||||||
|
sertificates: fixed.sertificates,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const customByLabel = new Map<string, CustomCriteriaRow>();
|
||||||
|
records.forEach(r => {
|
||||||
|
if (!isCustomRecord(r)) return;
|
||||||
|
const row = customByLabel.get(r.criteriaLabel) ?? { id: `sp_${r.criteriaLabel}`, label: r.criteriaLabel, values: {} };
|
||||||
|
columns.filter(c => c.spId === r.supplierSpId).forEach(c => { row.values[c.id] = r.value; });
|
||||||
|
customByLabel.set(r.criteriaLabel, row);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { criteriaMap, customCriteria: Array.from(customByLabel.values()) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { buildKpStorageName } from './kpFileName';
|
||||||
|
|
||||||
|
describe('buildKpStorageName (имя файла КП в библиотеке)', () => {
|
||||||
|
// КП сохранялись под исходным именем с перезаписью: «КП.pdf» из одной СТ затирал «КП.pdf» другой,
|
||||||
|
// и согласующий первой СТ открывал чужое КП. Префикс СТ делает имя уникальным.
|
||||||
|
it('добавляет префикс СТ', () => {
|
||||||
|
expect(buildKpStorageName('357', 'КП.pdf')).toBe('357_КП.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не добавляет префикс второй раз', () => {
|
||||||
|
expect(buildKpStorageName('357', '357_КП.pdf')).toBe('357_КП.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без СТ — исходное имя', () => {
|
||||||
|
expect(buildKpStorageName(undefined, 'КП.pdf')).toBe('КП.pdf');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Имя файла КП в библиотеке «Коммерческие предложения» — с префиксом СТ. Файлы лежат в корне библиотеки
|
||||||
|
// и загружались под исходным именем с перезаписью: «КП.pdf» одной СТ затирал «КП.pdf» другой, и
|
||||||
|
// согласующий открывал чужое КП. Ссылки, выгрузка INCORE и восстановление по ссылке работают по этому имени.
|
||||||
|
export function buildKpStorageName(ctId: string | undefined, originalName: string): string {
|
||||||
|
if (!ctId) return originalName;
|
||||||
|
const prefix = `${ctId}_`;
|
||||||
|
return originalName.startsWith(prefix) ? originalName : `${prefix}${originalName}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { getNoOfferSuppliers, noOfferRecordsToSuppliers, planNoOfferSync, withNoOfferSuppliers } from './noOfferSuppliers';
|
||||||
|
import { Supplier } from '../types/domain';
|
||||||
|
|
||||||
|
describe('planNoOfferSync («Не получены КП» по одной СТ)', () => {
|
||||||
|
const existing = [
|
||||||
|
{ id: 1, name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ, ТЕСТ!!!!!' },
|
||||||
|
{ id: 2, name: 'ТОО Asia Techno', comment: 'не работают с маленьким объемом' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('новый поставщик — создать', () => {
|
||||||
|
const plan = planNoOfferSync(existing, [
|
||||||
|
...existing.map(e => ({ name: e.name, comment: e.comment })),
|
||||||
|
{ name: 'ТОО «Новый»', comment: '' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(plan.toCreate).toEqual([{ name: 'ТОО «Новый»', comment: '' }]);
|
||||||
|
expect(plan.toRemove).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Удалили на шаге 2 — должен уйти и из SP, иначе согласующий по ссылке увидит его снова.
|
||||||
|
it('удалённый на шаге 2 — убрать', () => {
|
||||||
|
const plan = planNoOfferSync(existing, [{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ, ТЕСТ!!!!!' }]);
|
||||||
|
|
||||||
|
expect(plan.toRemove).toEqual([2]);
|
||||||
|
expect(plan.toCreate).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('изменённый комментарий — пересоздать запись', () => {
|
||||||
|
const plan = planNoOfferSync(existing, [
|
||||||
|
{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'отказались' },
|
||||||
|
{ name: 'ТОО Asia Techno', comment: 'не работают с маленьким объемом' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(plan.toRemove).toEqual([1]);
|
||||||
|
expect(plan.toCreate).toEqual([{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'отказались' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пробелы по краям названия и комментария не считаются изменением', () => {
|
||||||
|
const plan = planNoOfferSync(existing, [
|
||||||
|
{ name: ' ТОО БЕЗ КП ТЕСТ ', comment: 'НЕ ТРОГАТЬ, ТЕСТ!!!!! ' },
|
||||||
|
{ name: 'ТОО Asia Techno', comment: 'не работают с маленьким объемом' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(plan).toEqual({ toCreate: [], toRemove: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNoOfferSuppliers', () => {
|
||||||
|
it('поставщики без КП для списка под таблицей сравнения', () => {
|
||||||
|
const suppliers: Supplier[] = [
|
||||||
|
{ id: '263_277', spId: 263, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true },
|
||||||
|
{ id: 'noOffer_1', name: 'ТОО БЕЗ КП ТЕСТ', hasOffer: false, comment: 'НЕ ТРОГАТЬ' },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getNoOfferSuppliers(suppliers)).toEqual([{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('поставщики без КП при открытии по ссылке', () => {
|
||||||
|
const fromSp = noOfferRecordsToSuppliers([{ id: 28, name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ' }]);
|
||||||
|
|
||||||
|
it('записи SP становятся поставщиками без КП', () => {
|
||||||
|
expect(fromSp).toEqual([{ id: 'noOffer_28', name: 'ТОО БЕЗ КП ТЕСТ', hasOffer: false, comment: 'НЕ ТРОГАТЬ' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('добавляются к колонкам с КП', () => {
|
||||||
|
const withOffer: Supplier[] = [{ id: '263_277', spId: 263, name: 'Арлан', hasOffer: true }];
|
||||||
|
|
||||||
|
expect(withNoOfferSuppliers(withOffer, fromSp).map(s => s.id)).toEqual(['263_277', 'noOffer_28']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('свои поставщики без КП в сессии не заменяются', () => {
|
||||||
|
const session: Supplier[] = [{ id: 'local_1', name: 'Из сессии', hasOffer: false }];
|
||||||
|
|
||||||
|
expect(withNoOfferSuppliers(session, fromSp)).toEqual(session);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Supplier } from '../types/domain';
|
||||||
|
|
||||||
|
// Поставщик, не предоставивший КП: название и комментарий (почему нет КП).
|
||||||
|
export interface NoOfferSupplierEntry {
|
||||||
|
name: string;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись списка «Не получены КП» этой СТ в SP.
|
||||||
|
export interface NoOfferSupplierRecord extends NoOfferSupplierEntry {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameEntry = (a: NoOfferSupplierEntry, b: NoOfferSupplierEntry): boolean =>
|
||||||
|
a.name.trim() === b.name.trim() && a.comment.trim() === b.comment.trim();
|
||||||
|
|
||||||
|
// Синхронизация «Не получены КП» одной СТ с тем, что сейчас на шаге 2: новых — создать, удалённых и
|
||||||
|
// изменённых (другой комментарий) — убрать, изменённых — создать заново. Раньше записи только
|
||||||
|
// добавлялись и без привязки к СТ, поэтому по ссылке список было не восстановить.
|
||||||
|
export function planNoOfferSync(
|
||||||
|
existing: NoOfferSupplierRecord[],
|
||||||
|
desired: NoOfferSupplierEntry[],
|
||||||
|
): { toCreate: NoOfferSupplierEntry[]; toRemove: number[] } {
|
||||||
|
return {
|
||||||
|
toCreate: desired.filter(d => !existing.some(e => sameEntry(e, d))),
|
||||||
|
toRemove: existing.filter(e => !desired.some(d => sameEntry(e, d))).map(e => e.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поставщики без КП из колонок формы — для списка под таблицей сравнения (Шаг 3).
|
||||||
|
export function getNoOfferSuppliers(suppliers: Supplier[]): NoOfferSupplierEntry[] {
|
||||||
|
return suppliers
|
||||||
|
.filter(s => s.hasOffer === false)
|
||||||
|
.map(s => ({ name: s.name, comment: s.comment ?? '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поставщики без КП из SP (открытие по ссылке / без сессии) — в колонки формы с hasOffer: false.
|
||||||
|
// Префикс id — чтобы не пересечься с id колонок поставщиков с КП.
|
||||||
|
export function noOfferRecordsToSuppliers(records: NoOfferSupplierRecord[]): Supplier[] {
|
||||||
|
return records.map(r => ({ id: `noOffer_${r.id}`, name: r.name, hasOffer: false, comment: r.comment }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонки формы с поставщиками без КП из SP: сессию не трогаем, если в ней уже есть свои без КП.
|
||||||
|
export function withNoOfferSuppliers(current: Supplier[], fromSp: Supplier[]): Supplier[] {
|
||||||
|
if (fromSp.length === 0 || current.some(s => s.hasOffer === false)) return current;
|
||||||
|
return [...current, ...fromSp];
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { SpOfferRecord, buildOffersFromSpRecords } from './spOffers';
|
||||||
|
|
||||||
|
function makeRecord(overrides: Partial<SpOfferRecord>): SpOfferRecord {
|
||||||
|
return {
|
||||||
|
positionSpId: 1543,
|
||||||
|
supplierSpId: 263,
|
||||||
|
supplierName: 'ТОО «Арлан Офис Снаб»',
|
||||||
|
supplierPosition: 'Доска пробковая в раме 90х120',
|
||||||
|
price: 28500,
|
||||||
|
quantity: 4,
|
||||||
|
unit: 'шт.',
|
||||||
|
priceTenge: 28500,
|
||||||
|
summaTenge: 114000,
|
||||||
|
currencyCode: 'KZT',
|
||||||
|
itemIndex: 0,
|
||||||
|
offerFileSpId: 277,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
columnIdOf: (supplierSpId: number, offerFileSpId?: number): string => `${supplierSpId}_${offerFileSpId}`,
|
||||||
|
currencyCodeOf: (): string => 'KZT',
|
||||||
|
fallbackOfferFileId: (): number | undefined => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildOffersFromSpRecords (открытие СТ по ссылке / без сессии)', () => {
|
||||||
|
// Раньше название из КП терялось: шаг 3 подставлял название из заявки, а «Сохранить» записывало
|
||||||
|
// его в SP поверх названия поставщика — согласующий не видел, что предложена другая модель.
|
||||||
|
it('сохраняет наименование ТМЦ из КП', () => {
|
||||||
|
const [offer] = buildOffersFromSpRecords([makeRecord({})], deps);
|
||||||
|
|
||||||
|
expect(offer.supplierPosition).toBe('Доска пробковая в раме 90х120');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустое наименование из КП не подменяет названием из заявки', () => {
|
||||||
|
const [offer] = buildOffersFromSpRecords([makeRecord({ supplierPosition: '' })], deps);
|
||||||
|
|
||||||
|
expect(offer.supplierPosition).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('переносит колонку, позицию, цены и файл КП', () => {
|
||||||
|
const [offer] = buildOffersFromSpRecords([makeRecord({})], deps);
|
||||||
|
|
||||||
|
expect(offer).toMatchObject({
|
||||||
|
supplierId: '263_277',
|
||||||
|
positionId: '1543',
|
||||||
|
itemIndex: 0,
|
||||||
|
unit: 'шт.',
|
||||||
|
pricePerUnit: 28500,
|
||||||
|
quantity: 4,
|
||||||
|
pricePerUnitKzt: 28500,
|
||||||
|
totalPriceKzt: 114000,
|
||||||
|
offerId: '277',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без файла в записи берёт файл КП из сессии', () => {
|
||||||
|
const [offer] = buildOffersFromSpRecords(
|
||||||
|
[makeRecord({ offerFileSpId: undefined })],
|
||||||
|
{ ...deps, fallbackOfferFileId: () => 300 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(offer.offerId).toBe('300');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { SupplierOffer } from '../types/domain';
|
||||||
|
|
||||||
|
// Запись «Оценка поставщиков», прочитанная из SP (CtService.loadFullOffersForPositions).
|
||||||
|
export interface SpOfferRecord {
|
||||||
|
positionSpId: number;
|
||||||
|
supplierSpId: number;
|
||||||
|
supplierName: string;
|
||||||
|
supplierPosition: string; // наименование ТМЦ в КП поставщика
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
priceTenge: number;
|
||||||
|
summaTenge: number;
|
||||||
|
currencyCode: string;
|
||||||
|
itemIndex: number;
|
||||||
|
valutaSpId?: number;
|
||||||
|
offerFileSpId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildOffersDeps {
|
||||||
|
columnIdOf: (supplierSpId: number, offerFileSpId?: number) => string;
|
||||||
|
currencyCodeOf: (valutaSpId?: number) => string;
|
||||||
|
// Файл КП из сессии — для старых записей без offerFileId.
|
||||||
|
fallbackOfferFileId: (supplierSpId: number) => number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Офферы формы из записей SP (открытие СТ по ссылке / без сессии). Наименование ТМЦ из КП переносим
|
||||||
|
// обязательно: без него шаг 3 подставляет название из заявки — согласующий не видит, что поставщик
|
||||||
|
// предложил другую модель, а «Сохранить» записывает название заявки в SP поверх названия поставщика.
|
||||||
|
export function buildOffersFromSpRecords(records: SpOfferRecord[], deps: BuildOffersDeps): SupplierOffer[] {
|
||||||
|
return records.map(r => {
|
||||||
|
const offerFileId = r.offerFileSpId ?? deps.fallbackOfferFileId(r.supplierSpId);
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
supplierId: deps.columnIdOf(r.supplierSpId, r.offerFileSpId),
|
||||||
|
positionId: String(r.positionSpId),
|
||||||
|
itemIndex: r.itemIndex,
|
||||||
|
supplierPosition: r.supplierPosition || undefined,
|
||||||
|
unit: r.unit,
|
||||||
|
pricePerUnit: r.price,
|
||||||
|
quantity: r.quantity,
|
||||||
|
currencyCode: deps.currencyCodeOf(r.valutaSpId),
|
||||||
|
pricePerUnitKzt: r.priceTenge || r.price,
|
||||||
|
totalPriceKzt: r.summaTenge,
|
||||||
|
totalWithExtraKzt: r.summaTenge,
|
||||||
|
offerId: offerFileId !== undefined ? String(offerFileId) : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds } from './supplierColumns';
|
||||||
|
import { Supplier } from '../types/domain';
|
||||||
|
|
||||||
|
describe('buildSupplierColumnId', () => {
|
||||||
|
it('колонка = поставщик + файл КП', () => {
|
||||||
|
expect(buildSupplierColumnId(263, 277)).toBe('263_277');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без файла (старые данные) — только поставщик', () => {
|
||||||
|
expect(buildSupplierColumnId(263)).toBe('263');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findColumnForRecord', () => {
|
||||||
|
const KZT: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true };
|
||||||
|
const USD: Supplier = { id: '263_290', spId: 263, kpFileSpId: 290, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true };
|
||||||
|
const LEGACY: Supplier = { id: '264', spId: 264, name: 'ТОО «Кала Саплай»', hasOffer: true };
|
||||||
|
|
||||||
|
// Два КП одного поставщика: запись из SP должна попасть в колонку своего файла, а не в первую.
|
||||||
|
it('находит колонку по поставщику и файлу КП', () => {
|
||||||
|
expect(findColumnForRecord([KZT, USD], 263, 290)).toBe(USD);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('старая колонка без файла принимает запись поставщика', () => {
|
||||||
|
expect(findColumnForRecord([LEGACY], 264, 278)).toBe(LEGACY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не отдаёт запись чужого файла в колонку другого КП', () => {
|
||||||
|
expect(findColumnForRecord([KZT], 263, 290)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildColumnsFromSpOffers (открытие по ссылке / без сессии)', () => {
|
||||||
|
const rows = [
|
||||||
|
{ supplierSpId: 263, supplierName: 'ТОО «Арлан Офис Снаб»', offerFileSpId: 277, currencyCode: 'KZT' },
|
||||||
|
{ supplierSpId: 263, supplierName: 'ТОО «Арлан Офис Снаб»', offerFileSpId: 277, currencyCode: 'KZT' },
|
||||||
|
{ supplierSpId: 263, supplierName: 'ТОО «Арлан Офис Снаб»', offerFileSpId: 290, currencyCode: 'USD' },
|
||||||
|
{ supplierSpId: 264, supplierName: 'ТОО «Кала Саплай»', currencyCode: 'KZT' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('одна колонка на каждый КП поставщика, со своей валютой', () => {
|
||||||
|
expect(buildColumnsFromSpOffers(rows)).toEqual([
|
||||||
|
{ id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true, currency: 'KZT' },
|
||||||
|
{ id: '263_290', spId: 263, kpFileSpId: 290, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true, currency: 'USD' },
|
||||||
|
{ id: '264', spId: 264, kpFileSpId: undefined, name: 'ТОО «Кала Саплай»', hasOffer: true, currency: 'KZT' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('подписи колонок поставщика с несколькими КП', () => {
|
||||||
|
const ARLAN = 'ТОО «Арлан Офис Снаб»';
|
||||||
|
const columns: Supplier[] = [
|
||||||
|
{ id: '263_277', spId: 263, kpFileSpId: 277, name: ARLAN, hasOffer: true },
|
||||||
|
{ id: '264_278', spId: 264, kpFileSpId: 278, name: 'ТОО «Кала Саплай»', hasOffer: true },
|
||||||
|
{ id: '263_285', spId: 263, kpFileSpId: 285, name: ARLAN, hasOffer: true },
|
||||||
|
];
|
||||||
|
const uploaded = [
|
||||||
|
{ spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf' },
|
||||||
|
{ spId: 278, name: 'KP_02_Qala_Supply_KZT.pdf' },
|
||||||
|
{ spId: 285, name: 'KP_01_Arlan_Office_USD.pdf' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Две колонки «Арлан» в шапке неотличимы — подписываем файлом КП, но только их.
|
||||||
|
it('buildColumnLabels: файл КП — только у колонок поставщика с несколькими КП', () => {
|
||||||
|
expect(buildColumnLabels(columns, uploaded)).toEqual({
|
||||||
|
'263_277': 'KP_01_Arlan_Office_KZT.pdf',
|
||||||
|
'263_285': 'KP_01_Arlan_Office_USD.pdf',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildColumnLabels: файл неизвестен — номер КП по порядку', () => {
|
||||||
|
expect(buildColumnLabels(columns, [])).toEqual({ '263_277': 'КП 1', '263_285': 'КП 2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ИИ-комментарий и выбор рекомендованного сопоставляют поставщиков по имени — имена должны различаться.
|
||||||
|
it('buildColumnDisplayNames: имя с подписью для повторяющихся, просто имя для остальных', () => {
|
||||||
|
const names = buildColumnDisplayNames(columns, buildColumnLabels(columns, uploaded));
|
||||||
|
|
||||||
|
expect(names).toEqual({
|
||||||
|
'263_277': `${ARLAN} (KP_01_Arlan_Office_KZT.pdf)`,
|
||||||
|
'264_278': 'ТОО «Кала Саплай»',
|
||||||
|
'263_285': `${ARLAN} (KP_01_Arlan_Office_USD.pdf)`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adata проверяет компанию: результат нужен всем её колонкам, а платный запрос — один.
|
||||||
|
it('findSiblingColumnIds: все колонки той же компании', () => {
|
||||||
|
expect(findSiblingColumnIds(columns, '263_285')).toEqual(['263_277', '263_285']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findSiblingColumnIds: поставщик без SP Id — только сама колонка', () => {
|
||||||
|
expect(findSiblingColumnIds([{ id: 'extra_sup_1', name: 'Ручной', hasOffer: true }], 'extra_sup_1')).toEqual(['extra_sup_1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Supplier } from '../types/domain';
|
||||||
|
|
||||||
|
// Колонка таблицы сравнения = поставщик + файл КП: у одного поставщика может быть несколько
|
||||||
|
// предложений (напр. в тенге и в долларах). Разделитель «_», а не «::» — «::» уже служит
|
||||||
|
// разделителем в ключах ячеек (`${supplierId}::${positionId}`, deliveryKm и т.д.).
|
||||||
|
export function buildSupplierColumnId(supplierSpId: number, kpFileSpId?: number): string {
|
||||||
|
return kpFileSpId !== undefined ? `${supplierSpId}_${kpFileSpId}` : String(supplierSpId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонка для записи «Оценка поставщиков»: сначала колонка этого же файла КП, иначе — старая
|
||||||
|
// колонка поставщика без файла (данные до появления нескольких КП у поставщика).
|
||||||
|
export function findColumnForRecord(
|
||||||
|
columns: Supplier[],
|
||||||
|
supplierSpId: number,
|
||||||
|
offerFileSpId?: number,
|
||||||
|
): Supplier | undefined {
|
||||||
|
const sameFile = columns.find(c => c.spId === supplierSpId && c.kpFileSpId !== undefined && c.kpFileSpId === offerFileSpId);
|
||||||
|
return sameFile ?? columns.find(c => c.spId === supplierSpId && c.kpFileSpId === undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись «Оценка поставщиков», из которой восстанавливается колонка (открытие по ссылке / без сессии).
|
||||||
|
export interface SpOfferColumnSource {
|
||||||
|
supplierSpId: number;
|
||||||
|
supplierName: string;
|
||||||
|
offerFileSpId?: number;
|
||||||
|
currencyCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Одна колонка на каждую пару (поставщик, файл КП), в порядке первого появления.
|
||||||
|
export function buildColumnsFromSpOffers(records: SpOfferColumnSource[]): Supplier[] {
|
||||||
|
const columnsById = new Map<string, Supplier>();
|
||||||
|
records.forEach(r => {
|
||||||
|
const id = buildSupplierColumnId(r.supplierSpId, r.offerFileSpId);
|
||||||
|
if (columnsById.has(id)) return;
|
||||||
|
columnsById.set(id, {
|
||||||
|
id,
|
||||||
|
spId: r.supplierSpId,
|
||||||
|
kpFileSpId: r.offerFileSpId,
|
||||||
|
name: r.supplierName,
|
||||||
|
hasOffer: true,
|
||||||
|
currency: r.currencyCode,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(columnsById.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонки той же компании (тот же SP Id поставщика), включая саму колонку. Нужно для данных, которые
|
||||||
|
// принадлежат компании, а не КП: проверка Adata, БИН.
|
||||||
|
export function findSiblingColumnIds(columns: Supplier[], columnId: string): string[] {
|
||||||
|
const column = columns.find(c => c.id === columnId);
|
||||||
|
if (column?.spId === undefined) return [columnId];
|
||||||
|
return columns.filter(c => c.spId === column.spId).map(c => c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписи колонок поставщика с несколькими КП — имя файла КП (без файла — «КП N» по порядку).
|
||||||
|
// У поставщика с одним КП подписи нет: в шапке хватает названия.
|
||||||
|
export function buildColumnLabels(
|
||||||
|
columns: Supplier[],
|
||||||
|
uploadedKpFiles: Array<{ spId: number; name: string }>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
const columnsBySupplier = new Map<number, Supplier[]>();
|
||||||
|
columns.forEach(c => {
|
||||||
|
if (c.spId === undefined) return;
|
||||||
|
columnsBySupplier.set(c.spId, [...(columnsBySupplier.get(c.spId) ?? []), c]);
|
||||||
|
});
|
||||||
|
columnsBySupplier.forEach(group => {
|
||||||
|
if (group.length < 2) return;
|
||||||
|
group.forEach((c, index) => {
|
||||||
|
const fileName = uploadedKpFiles.find(f => f.spId === c.kpFileSpId)?.name;
|
||||||
|
labels[c.id] = fileName ?? `КП ${index + 1}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Различимые имена колонок для текстов и ИИ (комментарий, выбор рекомендованного), где поставщиков
|
||||||
|
// сопоставляют по имени: «Название (файл КП)» у повторяющихся, просто название у остальных.
|
||||||
|
export function buildColumnDisplayNames(
|
||||||
|
columns: Supplier[],
|
||||||
|
labels: Record<string, string>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const names: Record<string, string> = {};
|
||||||
|
columns.forEach(c => {
|
||||||
|
names[c.id] = labels[c.id] ? `${c.name} (${labels[c.id]})` : c.name;
|
||||||
|
});
|
||||||
|
return names;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { convertQuantity, getUnitStatus, normalizeUnit, unitsEquivalent } from './unitConversion';
|
||||||
|
|
||||||
|
describe('unitsEquivalent — синонимы одной единицы', () => {
|
||||||
|
// СТ №12: в заявке «Штука», у поставщика «шт.» — тумблер помечал «шт. *» (пересчёт невозможен).
|
||||||
|
it('«Штука» = «шт.» = «шт» = «ед.»', () => {
|
||||||
|
expect(unitsEquivalent('Штука', 'шт.')).toBe(true);
|
||||||
|
expect(unitsEquivalent('шт', 'ед.')).toBe(true);
|
||||||
|
expect(unitsEquivalent('Штук', 'ШТ')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('полные и краткие названия: килограмм, литр, тонна, метр', () => {
|
||||||
|
expect(unitsEquivalent('Килограмм', 'кг')).toBe(true);
|
||||||
|
expect(unitsEquivalent('Литр', 'л.')).toBe(true);
|
||||||
|
expect(unitsEquivalent('Тонна', 'т')).toBe(true);
|
||||||
|
expect(unitsEquivalent('Метр', 'м.')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('квадратные, кубические и погонные метры в разных написаниях', () => {
|
||||||
|
expect(unitsEquivalent('кв.м.', 'м²')).toBe(true);
|
||||||
|
expect(unitsEquivalent('куб.м.', 'м3')).toBe(true);
|
||||||
|
expect(unitsEquivalent('пог.м', 'м.п.')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('комплект, упаковка, пара — свои синонимы', () => {
|
||||||
|
expect(unitsEquivalent('Комплект', 'компл.')).toBe(true);
|
||||||
|
expect(unitsEquivalent('Упаковка', 'уп.')).toBe(true);
|
||||||
|
expect(unitsEquivalent('пара', 'пар')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('разные единицы не равны', () => {
|
||||||
|
expect(unitsEquivalent('шт', 'компл')).toBe(false);
|
||||||
|
expect(unitsEquivalent('кг', 'т')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('convertQuantity — пересчёт в ед. изм. заявки', () => {
|
||||||
|
it('тонны в килограммы', () => {
|
||||||
|
expect(convertQuantity(0.5, 'т', 'кг')).toBe(500);
|
||||||
|
expect(convertQuantity(1, 'Тонна', 'Килограмм')).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('миллиметры в метры, литры в кубометры', () => {
|
||||||
|
expect(convertQuantity(1500, 'мм', 'м')).toBeCloseTo(1.5);
|
||||||
|
expect(convertQuantity(1000, 'л', 'куб.м.')).toBeCloseTo(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Штуки в комплекты без кратности не перевести — честно говорим «невозможно».
|
||||||
|
it('штучные единицы между собой и с массой не пересчитываются', () => {
|
||||||
|
expect(convertQuantity(1, 'шт', 'компл')).toBeNull();
|
||||||
|
expect(convertQuantity(1, 'шт', 'кг')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('незнакомая единица — пересчёт невозможен', () => {
|
||||||
|
expect(convertQuantity(1, 'бухта', 'м')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeUnit', () => {
|
||||||
|
// Раньше точку на конце срезали ПОСЛЕ синонимов, и «кв.м.» не распознавалось.
|
||||||
|
it('точки и пробелы не мешают распознать единицу', () => {
|
||||||
|
expect(normalizeUnit('кв.м.')).toBe(normalizeUnit('м2'));
|
||||||
|
expect(normalizeUnit(' шт. ')).toBe(normalizeUnit('Штука'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUnitStatus — подсветка несоответствия ед. изм. (п.6)', () => {
|
||||||
|
it('одинаковые единицы — ничего не подсвечиваем', () => {
|
||||||
|
expect(getUnitStatus('шт.', 'Штука', false)).toEqual({ status: 'same' });
|
||||||
|
expect(getUnitStatus('шт.', 'Штука', true)).toEqual({ status: 'same' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Тумблер выключен: несовпадение видно сразу, а не только по незаметной «*» при включённом тумблере.
|
||||||
|
it('тумблер выключен — разные единицы: несоответствие с подсказкой', () => {
|
||||||
|
expect(getUnitStatus('т', 'кг', false)).toEqual({ status: 'mismatch', hint: 'В заявке: кг, у поставщика: т' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('тумблер включён — пересчитали: коэффициент и подсказка', () => {
|
||||||
|
expect(getUnitStatus('т', 'кг', true)).toEqual({ status: 'converted', ratio: 1000, hint: 'Пересчитано из т в кг' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('тумблер включён — пересчитать нельзя: подсказка сравнивать вручную', () => {
|
||||||
|
expect(getUnitStatus('шт', 'кг', true)).toEqual({
|
||||||
|
status: 'impossible',
|
||||||
|
hint: 'Нельзя перевести шт → кг: сравнивайте вручную',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустая единица поставщика — не несоответствие', () => {
|
||||||
|
expect(getUnitStatus('', 'кг', false)).toEqual({ status: 'same' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,61 +3,84 @@
|
|||||||
// поставщика из ед. КП в ед. изм. позиции заявки.
|
// поставщика из ед. КП в ед. изм. позиции заявки.
|
||||||
// Несовместимые размерности (напр. кг ↔ м³ — нужна плотность) не пересчитываются.
|
// Несовместимые размерности (напр. кг ↔ м³ — нужна плотность) не пересчитываются.
|
||||||
|
|
||||||
|
// Штучные единицы — каждая своя «размерность»: шт ↔ компл без кратности не перевести.
|
||||||
|
type Dimension = 'mass' | 'length' | 'area' | 'volume' | 'piece' | 'set' | 'pack' | 'pair' | 'roll' | 'sheet' | 'bag';
|
||||||
|
|
||||||
interface UnitDef {
|
interface UnitDef {
|
||||||
dim: 'mass' | 'length' | 'area' | 'volume';
|
dim: Dimension;
|
||||||
toBase: number; // множитель к базовой единице размерности
|
toBase: number; // множитель к базовой единице размерности
|
||||||
}
|
}
|
||||||
|
|
||||||
// Базовые единицы: масса — кг, длина — м, площадь — м², объём — л.
|
// Канонические единицы. Базовые: масса — кг, длина — м, площадь — м², объём — л, штучные — сама единица.
|
||||||
const UNIT_TABLE: Record<string, UnitDef> = {
|
const UNIT_TABLE: Record<string, UnitDef> = {
|
||||||
// масса (база — кг)
|
|
||||||
'мг': { dim: 'mass', toBase: 0.000001 },
|
'мг': { dim: 'mass', toBase: 0.000001 },
|
||||||
'г': { dim: 'mass', toBase: 0.001 },
|
'г': { dim: 'mass', toBase: 0.001 },
|
||||||
'гр': { dim: 'mass', toBase: 0.001 },
|
|
||||||
'грамм': { dim: 'mass', toBase: 0.001 },
|
|
||||||
'кг': { dim: 'mass', toBase: 1 },
|
'кг': { dim: 'mass', toBase: 1 },
|
||||||
'килограмм': { dim: 'mass', toBase: 1 },
|
|
||||||
'ц': { dim: 'mass', toBase: 100 },
|
'ц': { dim: 'mass', toBase: 100 },
|
||||||
'центнер': { dim: 'mass', toBase: 100 },
|
|
||||||
'т': { dim: 'mass', toBase: 1000 },
|
'т': { dim: 'mass', toBase: 1000 },
|
||||||
'тн': { dim: 'mass', toBase: 1000 },
|
|
||||||
'тонна': { dim: 'mass', toBase: 1000 },
|
|
||||||
'тонн': { dim: 'mass', toBase: 1000 },
|
|
||||||
// длина (база — м)
|
|
||||||
'мм': { dim: 'length', toBase: 0.001 },
|
'мм': { dim: 'length', toBase: 0.001 },
|
||||||
'см': { dim: 'length', toBase: 0.01 },
|
'см': { dim: 'length', toBase: 0.01 },
|
||||||
'дм': { dim: 'length', toBase: 0.1 },
|
'дм': { dim: 'length', toBase: 0.1 },
|
||||||
'м': { dim: 'length', toBase: 1 },
|
'м': { dim: 'length', toBase: 1 },
|
||||||
'метр': { dim: 'length', toBase: 1 },
|
|
||||||
'пм': { dim: 'length', toBase: 1 },
|
|
||||||
'км': { dim: 'length', toBase: 1000 },
|
'км': { dim: 'length', toBase: 1000 },
|
||||||
// площадь (база — м²)
|
|
||||||
'мм2': { dim: 'area', toBase: 0.000001 },
|
'мм2': { dim: 'area', toBase: 0.000001 },
|
||||||
'см2': { dim: 'area', toBase: 0.0001 },
|
'см2': { dim: 'area', toBase: 0.0001 },
|
||||||
'дм2': { dim: 'area', toBase: 0.01 },
|
'дм2': { dim: 'area', toBase: 0.01 },
|
||||||
'м2': { dim: 'area', toBase: 1 },
|
'м2': { dim: 'area', toBase: 1 },
|
||||||
// объём (база — л)
|
|
||||||
'мл': { dim: 'volume', toBase: 0.001 },
|
'мл': { dim: 'volume', toBase: 0.001 },
|
||||||
'см3': { dim: 'volume', toBase: 0.001 },
|
|
||||||
'л': { dim: 'volume', toBase: 1 },
|
'л': { dim: 'volume', toBase: 1 },
|
||||||
'дм3': { dim: 'volume', toBase: 1 },
|
|
||||||
'м3': { dim: 'volume', toBase: 1000 },
|
'м3': { dim: 'volume', toBase: 1000 },
|
||||||
|
'шт': { dim: 'piece', toBase: 1 },
|
||||||
|
'компл': { dim: 'set', toBase: 1 },
|
||||||
|
'упак': { dim: 'pack', toBase: 1 },
|
||||||
|
'пара': { dim: 'pair', toBase: 1 },
|
||||||
|
'рулон': { dim: 'roll', toBase: 1 },
|
||||||
|
'лист': { dim: 'sheet', toBase: 1 },
|
||||||
|
'мешок': { dim: 'bag', toBase: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Приводим написание единицы к ключу таблицы: нижний регистр, без пробелов,
|
// Синонимы → каноническая единица. Ключи — уже без пробелов, точек и дефисов, в нижнем регистре
|
||||||
// ³/² → 3/2, частые синонимы (куб.м → м3, кв.м → м2, пог.м → пм), без хвостовых точек.
|
// (см. cleanUnit): «Штука», «шт.», «кв.м.», «м.п.», «к-т» и т.п. В заявках и КП пишут по-разному —
|
||||||
export function normalizeUnit(raw: string | undefined): string {
|
// без словаря «Штука» и «шт.» считались разными единицами, и тумблер помечал их «пересчёт невозможен».
|
||||||
let u = (raw ?? '').toLowerCase().trim();
|
const UNIT_ALIASES: Record<string, string> = {
|
||||||
u = u.replace(/³/g, '3').replace(/²/g, '2');
|
'миллиграмм': 'мг',
|
||||||
u = u.replace(/\s+/g, '');
|
'гр': 'г', 'грамм': 'г', 'граммов': 'г',
|
||||||
u = u.replace(/^куб\.?м3?$/, 'м3').replace(/^кубм$/, 'м3');
|
'килограмм': 'кг', 'килограммов': 'кг', 'килограмма': 'кг',
|
||||||
u = u.replace(/^кв\.?м2?$/, 'м2').replace(/^квм$/, 'м2');
|
'центнер': 'ц',
|
||||||
u = u.replace(/^пог\.?м$/, 'пм');
|
'тн': 'т', 'тонна': 'т', 'тонн': 'т', 'тонны': 'т',
|
||||||
u = u.replace(/\.+$/, '');
|
'миллиметр': 'мм', 'сантиметр': 'см', 'дециметр': 'дм', 'километр': 'км',
|
||||||
return u;
|
'метр': 'м', 'метров': 'м', 'метра': 'м',
|
||||||
|
'пм': 'м', 'мп': 'м', 'погм': 'м', 'метрпогонный': 'м', 'погонныйметр': 'м',
|
||||||
|
'квм': 'м2', 'квм2': 'м2', 'квадратныйметр': 'м2', 'метрквадратный': 'м2',
|
||||||
|
'кубм': 'м3', 'кубм3': 'м3', 'куб': 'м3', 'кубометр': 'м3', 'кубическийметр': 'м3', 'метркубический': 'м3',
|
||||||
|
'см3': 'мл', 'дм3': 'л',
|
||||||
|
'миллилитр': 'мл', 'литр': 'л', 'литров': 'л', 'литра': 'л',
|
||||||
|
'штука': 'шт', 'штук': 'шт', 'штуки': 'шт', 'ед': 'шт', 'единица': 'шт', 'единиц': 'шт', 'pcs': 'шт',
|
||||||
|
'комплект': 'компл', 'комплектов': 'компл', 'кмпл': 'компл', 'кт': 'компл',
|
||||||
|
'упаковка': 'упак', 'упаковок': 'упак', 'уп': 'упак',
|
||||||
|
'пар': 'пара', 'пары': 'пара',
|
||||||
|
'рул': 'рулон', 'рулонов': 'рулон',
|
||||||
|
'листов': 'лист', 'листа': 'лист',
|
||||||
|
'меш': 'мешок', 'мешков': 'мешок',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Нижний регистр, ³/² → 3/2, без пробелов, точек и дефисов: «кв. м.» → «квм», «к-т» → «кт».
|
||||||
|
function cleanUnit(raw: string | undefined): string {
|
||||||
|
return (raw ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/³/g, '3')
|
||||||
|
.replace(/²/g, '2')
|
||||||
|
.replace(/[\s.-]+/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// true, если единицы по сути одинаковые (тн == т == Тонна).
|
// Приводим написание единицы к канонической (ключ UNIT_TABLE). Незнакомую возвращаем очищенной —
|
||||||
|
// так одинаково написанные незнакомые единицы всё равно считаются равными.
|
||||||
|
export function normalizeUnit(raw: string | undefined): string {
|
||||||
|
const cleaned = cleanUnit(raw);
|
||||||
|
return UNIT_ALIASES[cleaned] ?? cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// true, если единицы по сути одинаковые (тн == т == Тонна, шт. == Штука).
|
||||||
export function unitsEquivalent(a: string | undefined, b: string | undefined): boolean {
|
export function unitsEquivalent(a: string | undefined, b: string | undefined): boolean {
|
||||||
return normalizeUnit(a) === normalizeUnit(b);
|
return normalizeUnit(a) === normalizeUnit(b);
|
||||||
}
|
}
|
||||||
@@ -73,3 +96,34 @@ export function convertQuantity(qty: number, fromUnit: string | undefined, toUni
|
|||||||
if (!from || !to || from.dim !== to.dim) return null;
|
if (!from || !to || from.dim !== to.dim) return null;
|
||||||
return qty * (from.toBase / to.toBase);
|
return qty * (from.toBase / to.toBase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Статус ед. изм. поставщика относительно заявки — для подсветки ячеек на шаге 3:
|
||||||
|
// same — единицы совпадают (или у поставщика не указана) — без подсветки;
|
||||||
|
// mismatch — тумблер выключен, единицы разные — показать, что сравнивать надо внимательно;
|
||||||
|
// converted — тумблер включён, пересчитали в ед. заявки (ratio — коэффициент кол-ва);
|
||||||
|
// impossible — тумблер включён, пересчитать нельзя (шт ↔ кг, шт ↔ компл).
|
||||||
|
export type UnitStatusKind = 'same' | 'mismatch' | 'converted' | 'impossible';
|
||||||
|
|
||||||
|
export interface UnitStatus {
|
||||||
|
status: UnitStatusKind;
|
||||||
|
ratio?: number;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnitStatus(
|
||||||
|
supplierUnit: string | undefined,
|
||||||
|
requestUnit: string | undefined,
|
||||||
|
translateUnits: boolean,
|
||||||
|
): UnitStatus {
|
||||||
|
const supplier = (supplierUnit ?? '').trim();
|
||||||
|
const request = (requestUnit ?? '').trim();
|
||||||
|
if (!supplier || !request || unitsEquivalent(supplier, request)) return { status: 'same' };
|
||||||
|
if (!translateUnits) {
|
||||||
|
return { status: 'mismatch', hint: `В заявке: ${request}, у поставщика: ${supplier}` };
|
||||||
|
}
|
||||||
|
const ratio = convertQuantity(1, supplier, request);
|
||||||
|
if (ratio === null) {
|
||||||
|
return { status: 'impossible', hint: `Нельзя перевести ${supplier} → ${request}: сравнивайте вручную` };
|
||||||
|
}
|
||||||
|
return { status: 'converted', ratio, hint: `Пересчитано из ${supplier} в ${request}` };
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
Request,
|
Request,
|
||||||
Supplier,
|
Supplier,
|
||||||
SupplierOffer,
|
SupplierOffer,
|
||||||
SupplierCriteria,
|
|
||||||
SyntexFields,
|
SyntexFields,
|
||||||
FlowResult,
|
FlowResult,
|
||||||
AdataCompanyInfo,
|
AdataCompanyInfo,
|
||||||
} from "../components/types/domain";
|
} from "../components/types/domain";
|
||||||
import {
|
import {
|
||||||
ApprovalListService,
|
ApprovalListService,
|
||||||
|
ApprovalStatusKey,
|
||||||
ApproverService,
|
ApproverService,
|
||||||
IApprovalListFields,
|
IApprovalListFields,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
@@ -24,6 +24,19 @@ import {
|
|||||||
getRequestTypeIdByPageUrl,
|
getRequestTypeIdByPageUrl,
|
||||||
writeRoute,
|
writeRoute,
|
||||||
} from "../../../shared/approval";
|
} from "../../../shared/approval";
|
||||||
|
import {
|
||||||
|
findKpFileOfferIds,
|
||||||
|
findSupersededOfferIds,
|
||||||
|
keepLatestOfferRecords,
|
||||||
|
parseOfferItemIndex,
|
||||||
|
} from "../../../shared/offers/offerRecords";
|
||||||
|
import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria";
|
||||||
|
import { pickRowToNotify } from "../components/utils/approvalAccess";
|
||||||
|
import {
|
||||||
|
NoOfferSupplierEntry,
|
||||||
|
NoOfferSupplierRecord,
|
||||||
|
planNoOfferSync,
|
||||||
|
} from "../components/utils/noOfferSuppliers";
|
||||||
|
|
||||||
// Round monetary values to 2 decimals before persisting to SharePoint (drops float tails).
|
// Round monetary values to 2 decimals before persisting to SharePoint (drops float tails).
|
||||||
function round2(value: number): number {
|
function round2(value: number): number {
|
||||||
@@ -78,10 +91,11 @@ const APPROVAL_FIELDS: IApprovalListFields = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ.
|
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ.
|
||||||
const APPROVAL_STATUS_TO_CT_STATUS: Record<"onApproval" | "approved" | "rejected", CTStatus> = {
|
const APPROVAL_STATUS_TO_CT_STATUS: Record<ApprovalStatusKey, CTStatus> = {
|
||||||
onApproval: "on_approval",
|
onApproval: "on_approval",
|
||||||
approved: "approved",
|
approved: "approved",
|
||||||
rejected: "rejected",
|
rejected: "rejected",
|
||||||
|
rework: "rework",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обратный маппинг для чтения статуса шапки СТ (loadCTById) — раньше status не читался вовсе,
|
// Обратный маппинг для чтения статуса шапки СТ (loadCTById) — раньше status не читался вовсе,
|
||||||
@@ -92,6 +106,7 @@ const CHOICE_TO_CT_STATUS: Record<string, CTStatus> = {
|
|||||||
"На согласовании": "on_approval",
|
"На согласовании": "on_approval",
|
||||||
Согласовано: "approved",
|
Согласовано: "approved",
|
||||||
Отклонено: "rejected",
|
Отклонено: "rejected",
|
||||||
|
"На доработке": "rework",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Контактные поля поставщика (из «База данных поставщиков») для saveSupplierContacts.
|
// Контактные поля поставщика (из «База данных поставщиков») для saveSupplierContacts.
|
||||||
@@ -242,6 +257,42 @@ export class CtService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Повторное сохранение шага 1 с позициями другого проекта — проект в шапке той же СТ.
|
||||||
|
async updateCTProject(ctId: string, projectName: string): Promise<void> {
|
||||||
|
const res = await this.http.fetch(this._liById(LISTS.CT, ctId), SPHttpClient.configurations.v1, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json;odata=nometadata",
|
||||||
|
"IF-MATCH": "*",
|
||||||
|
"X-HTTP-Method": "MERGE",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ project: projectName }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Ошибка обновления проекта СТ: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Позиции, снятые при повторном сохранении шага 1: их предложения и сами позиции — в корзину.
|
||||||
|
// Сначала предложения: без позиции они бы остались висеть в «Оценка поставщиков».
|
||||||
|
async removeCTPositions(positionSpIds: number[]): Promise<void> {
|
||||||
|
if (positionSpIds.length === 0) return;
|
||||||
|
const filter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or ");
|
||||||
|
const res = await this.http.get(
|
||||||
|
`${this._li(LISTS.OFFERS)}?$select=Id&$filter=${filter}&$top=5000`,
|
||||||
|
SPHttpClient.configurations.v1,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`Ошибка чтения предложений позиций: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const offerIds = ((data.value ?? []) as Array<{ Id: number }>).map((item) => item.Id);
|
||||||
|
// MERGE/DELETE на списке предложений блокирует ресивер — убираем через recycle(), как в saveOffers.
|
||||||
|
const recycle = (listName: string) => async (id: number): Promise<void> => {
|
||||||
|
await this.http
|
||||||
|
.post(`${this._liById(listName, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
||||||
|
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
||||||
|
};
|
||||||
|
await this._runChunked(offerIds, 20, recycle(LISTS.OFFERS));
|
||||||
|
await this._runChunked(positionSpIds, 20, recycle(LISTS.CT_POSITIONS));
|
||||||
|
}
|
||||||
|
|
||||||
async updateCTStatus(
|
async updateCTStatus(
|
||||||
ctId: string,
|
ctId: string,
|
||||||
status: CTStatus,
|
status: CTStatus,
|
||||||
@@ -252,6 +303,7 @@ export class CtService {
|
|||||||
approved: "Согласовано",
|
approved: "Согласовано",
|
||||||
formed: "Черновик",
|
formed: "Черновик",
|
||||||
rejected: "Отклонено",
|
rejected: "Отклонено",
|
||||||
|
rework: "На доработке",
|
||||||
};
|
};
|
||||||
const spStatus = statusMap[status];
|
const spStatus = statusMap[status];
|
||||||
if (!spStatus) return;
|
if (!spStatus) return;
|
||||||
@@ -566,23 +618,30 @@ export class CtService {
|
|||||||
|
|
||||||
// ─── Критерии ──────────────────────────────────────────────────────────────
|
// ─── Критерии ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async saveCriteria(
|
// «Дополнительная информация» шага 3 → «Критерии» этой СТ целиком: прежние записи СТ (в т.ч. от ИИ)
|
||||||
criteria: SupplierCriteria[],
|
// в корзину, текущие — заново. Раньше ручные правки и свои строки в SP не попадали вовсе.
|
||||||
ctSpId?: number,
|
async replaceCriteria(ctId: string, records: CriteriaRecord[]): Promise<void> {
|
||||||
): Promise<void> {
|
const ctSpId = parseInt(ctId, 10);
|
||||||
if (criteria.length === 0) return;
|
const existing = await this.loadCriteriaForCt(ctId);
|
||||||
// Было: POST на каждый критерий в цикле await. Стало: один $batch (у списка «Критерии»
|
await this._runChunked(existing.map((r) => r.id as number), 20, async (id) => {
|
||||||
// капризного вебхука нет, changeset безопасен).
|
await this.http
|
||||||
const bodies = criteria.map((c) => ({
|
.post(`${this._liById(LISTS.CRITERIA, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
||||||
Title: c.supplierId,
|
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
||||||
supplierId: parseInt(c.supplierId, 10),
|
});
|
||||||
deliveryType: c.deliveryType,
|
// У списка «Критерии» капризного вебхука нет — пакетное создание безопасно.
|
||||||
paymentType: c.paymentType,
|
const bodies = records.map((r) =>
|
||||||
deadline: c.deadline,
|
"criteriaLabel" in r
|
||||||
sertificates: c.certificates,
|
? { Title: `${ctSpId}-${r.supplierSpId}-${r.criteriaLabel}`, CTId: ctSpId, supplierId: r.supplierSpId, criteriaLabel: r.criteriaLabel, comment: r.value }
|
||||||
comment: c.comment ?? "",
|
: {
|
||||||
...(ctSpId ? { CTId: ctSpId } : {}),
|
Title: String(r.supplierSpId),
|
||||||
}));
|
CTId: ctSpId,
|
||||||
|
supplierId: r.supplierSpId,
|
||||||
|
paymentType: r.paymentType,
|
||||||
|
deliveryType: r.deliveryType,
|
||||||
|
deadline: r.deadline,
|
||||||
|
sertificates: r.sertificates,
|
||||||
|
},
|
||||||
|
);
|
||||||
await this.batchCreateItems(LISTS.CRITERIA, bodies);
|
await this.batchCreateItems(LISTS.CRITERIA, bodies);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,30 +673,28 @@ export class CtService {
|
|||||||
if (!res.ok) throw new Error(`Ошибка сохранения критериев: ${res.status}`);
|
if (!res.ok) throw new Error(`Ошибка сохранения критериев: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCriteria(supplierSpIds: number[]): Promise<
|
// «Критерии» этой СТ. Раньше читались по поставщику без фильтра по СТ — подтягивались условия
|
||||||
Array<{
|
// того же поставщика из других СТ.
|
||||||
supplierSpId: number;
|
async loadCriteriaForCt(ctId: string): Promise<CriteriaRecord[]> {
|
||||||
paymentType: string;
|
const url =
|
||||||
deliveryType: string;
|
`${this._li(LISTS.CRITERIA)}?$select=Id,supplierId,paymentType,deliveryType,deadline,sertificates,criteriaLabel,comment` +
|
||||||
deadline: string;
|
`&$filter=CTId eq ${parseInt(ctId, 10)}&$top=2000`;
|
||||||
sertificates: string;
|
|
||||||
}>
|
|
||||||
> {
|
|
||||||
if (supplierSpIds.length === 0) return [];
|
|
||||||
const filter = supplierSpIds
|
|
||||||
.map((id) => `supplierId eq ${id}`)
|
|
||||||
.join(" or ");
|
|
||||||
const url = `${this._li(LISTS.CRITERIA)}?$select=supplierId,paymentType,deliveryType,deadline,sertificates&$filter=${filter}`;
|
|
||||||
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) throw new Error(`Ошибка чтения критериев: ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return (data.value ?? []).map((item: Record<string, unknown>) => ({
|
return ((data.value ?? []) as Array<Record<string, unknown>>).map((item): CriteriaRecord => {
|
||||||
supplierSpId: Number(item.supplierId),
|
const base = { id: Number(item.Id), supplierSpId: Number(item.supplierId) };
|
||||||
|
const label = String(item.criteriaLabel ?? "").trim();
|
||||||
|
// comment — многострочное поле с форматированием: SharePoint отдаёт его в HTML-обёртке.
|
||||||
|
if (label) return { ...base, criteriaLabel: label, value: plainTextFromSp(String(item.comment ?? "")) };
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
paymentType: String(item.paymentType ?? ""),
|
paymentType: String(item.paymentType ?? ""),
|
||||||
deliveryType: String(item.deliveryType ?? ""),
|
deliveryType: String(item.deliveryType ?? ""),
|
||||||
deadline: String(item.deadline ?? ""),
|
deadline: String(item.deadline ?? ""),
|
||||||
sertificates: String(item.sertificates ?? ""),
|
sertificates: String(item.sertificates ?? ""),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Согласование СТ (через shared/approval — approval-route API + список
|
// ─── Согласование СТ (через shared/approval — approval-route API + список
|
||||||
@@ -690,14 +747,12 @@ export class CtService {
|
|||||||
async notifyActiveApprover(ctId: string): Promise<void> {
|
async notifyActiveApprover(ctId: string): Promise<void> {
|
||||||
const rows = await this.approval.getForRequest(parseInt(ctId, 10));
|
const rows = await this.approval.getForRequest(parseInt(ctId, 10));
|
||||||
if (rows.length === 0) return;
|
if (rows.length === 0) return;
|
||||||
// Есть активный согласующий → уведомляем его («поступила на согласование»).
|
// Есть активный согласующий → уведомляем его («поступила на согласование»). Иначе — строка того,
|
||||||
// Иначе финал (все согласовали / кто-то отклонил): активной строки уже нет, но поток
|
// кто вернул на доработку или отклонил (поток берёт из неё имя и комментарий для письма инициатору),
|
||||||
// сам выбирает получателя (инициатора) по статусу СТ — ему нужен лишь любой RowID этой СТ,
|
// а если все согласовали — последняя: поток по ней выходит на СТ и её автора. Раньше при возврате
|
||||||
// чтобы выйти на саму СТ и её автора. Берём последнюю строку маршрута. Без этого
|
// уходила последняя строка (напр. «Ждёт очереди»), и кто вернул и почему, поток не знал.
|
||||||
// «Согласовано»/«Отклонено» инициатору не уходили (поток вообще не вызывался).
|
const rowToNotify = pickRowToNotify(rows);
|
||||||
const active = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0];
|
if (rowToNotify) await this.triggerApprovalNotification(rowToNotify.id);
|
||||||
const rowToNotify = active ?? rows[rows.length - 1];
|
|
||||||
await this.triggerApprovalNotification(rowToNotify.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async triggerApprovalNotification(spItemId: number): Promise<void> {
|
async triggerApprovalNotification(spItemId: number): Promise<void> {
|
||||||
@@ -773,12 +828,14 @@ export class CtService {
|
|||||||
positionSpId: number;
|
positionSpId: number;
|
||||||
supplierSpId: number;
|
supplierSpId: number;
|
||||||
supplierName: string;
|
supplierName: string;
|
||||||
|
supplierPosition: string;
|
||||||
price: number;
|
price: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
priceTenge: number;
|
priceTenge: number;
|
||||||
summaTenge: number;
|
summaTenge: number;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
|
itemIndex: number;
|
||||||
valutaSpId?: number;
|
valutaSpId?: number;
|
||||||
deliveryDistanceKm?: number;
|
deliveryDistanceKm?: number;
|
||||||
offerFileSpId?: number;
|
offerFileSpId?: number;
|
||||||
@@ -792,21 +849,24 @@ export class CtService {
|
|||||||
// valutaId is selected directly; caller enriches currencyCode via loadCurrencyRatesByIds.
|
// valutaId is selected directly; caller enriches currencyCode via loadCurrencyRatesByIds.
|
||||||
const url =
|
const url =
|
||||||
`${this._li(LISTS.OFFERS)}` +
|
`${this._li(LISTS.OFFERS)}` +
|
||||||
`?$select=positionId,supplierId,price,quantity,unit,priceTenge,summaTenge,valutaId,deliveryDistanceKm,offerFileId,supplier/Id,supplier/Title` +
|
`?$select=Id,Title,positionId,supplierId,supplierPosition,price,quantity,unit,priceTenge,summaTenge,valutaId,deliveryDistanceKm,offerFileId,supplier/Id,supplier/Title` +
|
||||||
`&$expand=supplier` +
|
`&$expand=supplier` +
|
||||||
`&$filter=${filter}` +
|
`&$filter=${filter}` +
|
||||||
`&$top=2000`;
|
`&$top=2000`;
|
||||||
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((item: Record<string, unknown>) => {
|
const records = ((data.value ?? []) as Array<Record<string, unknown>>).map((item) => {
|
||||||
const sup = item["supplier"] as Record<string, unknown> | undefined;
|
const sup = item["supplier"] as Record<string, unknown> | undefined;
|
||||||
const rawValutaId = item["valutaId"];
|
const rawValutaId = item["valutaId"];
|
||||||
const valutaSpId = rawValutaId ? Number(rawValutaId) : undefined;
|
const valutaSpId = rawValutaId ? Number(rawValutaId) : undefined;
|
||||||
return {
|
return {
|
||||||
|
id: Number(item["Id"]),
|
||||||
|
itemIndex: parseOfferItemIndex(String(item["Title"] ?? "")),
|
||||||
positionSpId: Number(item["positionId"]),
|
positionSpId: Number(item["positionId"]),
|
||||||
supplierSpId: Number(item["supplierId"]),
|
supplierSpId: Number(item["supplierId"]),
|
||||||
supplierName: String(sup?.["Title"] ?? ""),
|
supplierName: String(sup?.["Title"] ?? ""),
|
||||||
|
supplierPosition: String(item["supplierPosition"] ?? ""),
|
||||||
price: Number(item["price"] ?? 0),
|
price: Number(item["price"] ?? 0),
|
||||||
quantity: Number(item["quantity"] ?? 0),
|
quantity: Number(item["quantity"] ?? 0),
|
||||||
unit: String(item["unit"] ?? ""),
|
unit: String(item["unit"] ?? ""),
|
||||||
@@ -818,6 +878,7 @@ export class CtService {
|
|||||||
offerFileSpId: item["offerFileId"] ? Number(item["offerFileId"]) : undefined,
|
offerFileSpId: item["offerFileId"] ? Number(item["offerFileId"]) : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
return keepLatestOfferRecords(records);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadKpFilesForOffers(positionSpIds: number[]): Promise<
|
async loadKpFilesForOffers(positionSpIds: number[]): Promise<
|
||||||
@@ -845,13 +906,14 @@ export class CtService {
|
|||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
// По одному элементу на файл КП, а не на поставщика: у поставщика может быть несколько КП.
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
const pairs: Array<{ supplierSpId: number; offerSpId: number }> = [];
|
const pairs: Array<{ supplierSpId: number; offerSpId: number }> = [];
|
||||||
for (const item of data.value ?? []) {
|
for (const item of data.value ?? []) {
|
||||||
const supplierSpId = Number(item["supplierId"]);
|
const supplierSpId = Number(item["supplierId"]);
|
||||||
const offerSpId = Number(item["offerFileId"] ?? 0);
|
const offerSpId = Number(item["offerFileId"] ?? 0);
|
||||||
if (!offerSpId || !supplierSpId || seen.has(supplierSpId)) continue;
|
if (!offerSpId || !supplierSpId || seen.has(offerSpId)) continue;
|
||||||
seen.add(supplierSpId);
|
seen.add(offerSpId);
|
||||||
pairs.push({ supplierSpId, offerSpId });
|
pairs.push({ supplierSpId, offerSpId });
|
||||||
}
|
}
|
||||||
if (pairs.length === 0) return [];
|
if (pairs.length === 0) return [];
|
||||||
@@ -943,9 +1005,10 @@ export class CtService {
|
|||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadKpFile(file: File): Promise<{ spId: number; name: string }> {
|
// storageName — имя в библиотеке (с префиксом СТ, см. buildKpStorageName); по умолчанию исходное.
|
||||||
|
async uploadKpFile(file: File, storageName: string = file.name): Promise<{ spId: number; name: string }> {
|
||||||
const buffer = await this._readFile(file);
|
const buffer = await this._readFile(file);
|
||||||
const uploadUrl = `${this.baseUrl}/_api/web/GetFolderByServerRelativeUrl('${encodeURIComponent(this.kpFolder)}')/Files/add(url='${encodeURIComponent(file.name)}',overwrite=true)`;
|
const uploadUrl = `${this.baseUrl}/_api/web/GetFolderByServerRelativeUrl('${encodeURIComponent(this.kpFolder)}')/Files/add(url='${encodeURIComponent(storageName)}',overwrite=true)`;
|
||||||
|
|
||||||
const uploadRes = await this.http.post(
|
const uploadRes = await this.http.post(
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
@@ -961,7 +1024,7 @@ export class CtService {
|
|||||||
if (!uploadRes.ok)
|
if (!uploadRes.ok)
|
||||||
throw new Error(`Ошибка загрузки файла: ${uploadRes.status}`);
|
throw new Error(`Ошибка загрузки файла: ${uploadRes.status}`);
|
||||||
|
|
||||||
const fileSRUrl = `${this.kpFolder}/${file.name}`;
|
const fileSRUrl = `${this.kpFolder}/${storageName}`;
|
||||||
const itemRes = await this.http.get(
|
const itemRes = await this.http.get(
|
||||||
`${this.baseUrl}/_api/web/GetFileByServerRelativeUrl('${encodeURIComponent(fileSRUrl)}')/ListItemAllFields?$select=Id`,
|
`${this.baseUrl}/_api/web/GetFileByServerRelativeUrl('${encodeURIComponent(fileSRUrl)}')/ListItemAllFields?$select=Id`,
|
||||||
SPHttpClient.configurations.v1,
|
SPHttpClient.configurations.v1,
|
||||||
@@ -969,7 +1032,7 @@ export class CtService {
|
|||||||
if (!itemRes.ok)
|
if (!itemRes.ok)
|
||||||
throw new Error(`Ошибка получения ID файла: ${itemRes.status}`);
|
throw new Error(`Ошибка получения ID файла: ${itemRes.status}`);
|
||||||
const itemData = await itemRes.json();
|
const itemData = await itemRes.json();
|
||||||
return { spId: Number(itemData.Id), name: file.name };
|
return { spId: Number(itemData.Id), name: storageName };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getKpItemFields(fileSpId: number): Promise<SyntexFields> {
|
async getKpItemFields(fileSpId: number): Promise<SyntexFields> {
|
||||||
@@ -1239,23 +1302,38 @@ export class CtService {
|
|||||||
return { spId, offerId };
|
return { spId, offerId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveNoOfferSupplierItem(name: string, comment?: string): Promise<void> {
|
// «Не получены КП» этой СТ (привязка по лукапу CT). Раньше записи сохранялись без СТ и с дедупом
|
||||||
const escaped = name.replace(/'/g, "''");
|
// по названию на все СТ сразу — по ссылке список поставщиков без КП было не восстановить.
|
||||||
const checkRes = await this.http.get(
|
async loadNoOfferSuppliers(ctId: string): Promise<NoOfferSupplierRecord[]> {
|
||||||
`${this._li(LISTS.NO_OFFERS)}?$filter=Title eq '${escaped}'&$select=Id&$top=1`,
|
const res = await this.http.get(
|
||||||
|
`${this._li(LISTS.NO_OFFERS)}?$select=Id,Title,comment&$filter=CTId eq ${parseInt(ctId, 10)}&$top=500`,
|
||||||
SPHttpClient.configurations.v1,
|
SPHttpClient.configurations.v1,
|
||||||
);
|
);
|
||||||
if (checkRes.ok && ((await checkRes.json()).value ?? []).length > 0) return;
|
if (!res.ok) throw new Error(`Ошибка чтения поставщиков без КП: ${res.status}`);
|
||||||
const res = await this.http.post(
|
const data = await res.json();
|
||||||
this._li(LISTS.NO_OFFERS),
|
return ((data.value ?? []) as Array<Record<string, unknown>>).map((item) => ({
|
||||||
SPHttpClient.configurations.v1,
|
id: Number(item.Id),
|
||||||
{
|
name: String(item.Title ?? ""),
|
||||||
|
comment: String(item.comment ?? ""),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приводит «Не получены КП» этой СТ к списку с шага 2: новых — создать, удалённых и изменённых — убрать.
|
||||||
|
async syncNoOfferSuppliers(ctId: string, entries: NoOfferSupplierEntry[]): Promise<void> {
|
||||||
|
const existing = await this.loadNoOfferSuppliers(ctId);
|
||||||
|
const { toCreate, toRemove } = planNoOfferSync(existing, entries);
|
||||||
|
await this._runChunked(toRemove, 20, async (id) => {
|
||||||
|
await this.http
|
||||||
|
.post(`${this._liById(LISTS.NO_OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
||||||
|
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
||||||
|
});
|
||||||
|
for (const entry of toCreate) {
|
||||||
|
const res = await this.http.post(this._li(LISTS.NO_OFFERS), SPHttpClient.configurations.v1, {
|
||||||
headers: { "Content-Type": "application/json;odata=nometadata" },
|
headers: { "Content-Type": "application/json;odata=nometadata" },
|
||||||
body: JSON.stringify({ Title: name, comment: comment ?? "" }),
|
body: JSON.stringify({ Title: entry.name.trim(), comment: entry.comment.trim(), CTId: parseInt(ctId, 10) }),
|
||||||
},
|
});
|
||||||
);
|
if (!res.ok) throw new Error(`Ошибка сохранения поставщика без КП: ${res.status}`);
|
||||||
if (!res.ok)
|
}
|
||||||
throw new Error(`Ошибка сохранения поставщика без КП: ${res.status}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentUserProfile(): Promise<{
|
async getCurrentUserProfile(): Promise<{
|
||||||
@@ -1532,30 +1610,29 @@ export class CtService {
|
|||||||
supplierPosition: string;
|
supplierPosition: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
itemIndex: number;
|
itemIndex: number;
|
||||||
|
offerFileSpId?: number;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
if (positionSpIds.length === 0) return [];
|
if (positionSpIds.length === 0) return [];
|
||||||
const filter = positionSpIds
|
const filter = positionSpIds
|
||||||
.map((id) => `positionId eq ${id}`)
|
.map((id) => `positionId eq ${id}`)
|
||||||
.join(" or ");
|
.join(" or ");
|
||||||
const url = `${this._li(LISTS.OFFERS)}?$select=Title,positionId,supplierId,price,quantity,supplierPosition,unit&$filter=${filter}`;
|
const url = `${this._li(LISTS.OFFERS)}?$select=Id,Title,positionId,supplierId,price,quantity,supplierPosition,unit,offerFileId&$filter=${filter}`;
|
||||||
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((item: Record<string, unknown>) => {
|
const records = ((data.value ?? []) as Array<Record<string, unknown>>).map((item) => ({
|
||||||
// Title format: "supId-posId" (old, index 0) or "supId-posId-N" (bundle row N)
|
id: Number(item.Id),
|
||||||
const parts = String(item.Title ?? "").split("-");
|
|
||||||
const itemIndex = parts.length >= 3 ? parseInt(parts[2], 10) || 0 : 0;
|
|
||||||
return {
|
|
||||||
positionSpId: Number(item.positionId),
|
positionSpId: Number(item.positionId),
|
||||||
supplierSpId: Number(item.supplierId),
|
supplierSpId: Number(item.supplierId),
|
||||||
price: Number(item.price ?? 0),
|
price: Number(item.price ?? 0),
|
||||||
quantity: Number(item.quantity ?? 0),
|
quantity: Number(item.quantity ?? 0),
|
||||||
supplierPosition: String(item.supplierPosition ?? ""),
|
supplierPosition: String(item.supplierPosition ?? ""),
|
||||||
unit: String(item.unit ?? ""),
|
unit: String(item.unit ?? ""),
|
||||||
itemIndex,
|
itemIndex: parseOfferItemIndex(String(item.Title ?? "")),
|
||||||
};
|
offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined,
|
||||||
});
|
}));
|
||||||
|
return keepLatestOfferRecords(records);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveKpOffer(params: {
|
async saveKpOffer(params: {
|
||||||
@@ -1596,6 +1673,73 @@ export class CtService {
|
|||||||
);
|
);
|
||||||
if (!res.ok)
|
if (!res.ok)
|
||||||
throw new Error(`Ошибка сохранения предложения: ${res.status}`);
|
throw new Error(`Ошибка сохранения предложения: ${res.status}`);
|
||||||
|
|
||||||
|
// Повторная обработка КП (исправленный КП, файл загружен заново) раньше добавляла записи рядом
|
||||||
|
// с прежними — в SP копились дубли, если потом не нажимали «Сохранить». Сначала создаём новую
|
||||||
|
// запись, потом убираем прежние: упади создание — старое предложение останется.
|
||||||
|
const created = (await res.json()) as { Id: number };
|
||||||
|
await this._recycleSupersededOffers({
|
||||||
|
id: created.Id,
|
||||||
|
supplierSpId: params.supplierSpId,
|
||||||
|
positionSpId: params.positionSpId,
|
||||||
|
offerFileSpId: params.offerSpId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// КП убрали из сравнения (Шаг 2, корзина в «Загруженные КП»): записи этого файла по позициям СТ
|
||||||
|
// уходят в корзину. Сам файл остаётся в библиотеке. Старые записи без offerFileId не найти —
|
||||||
|
// их перезапишет «Сохранить» по текущей таблице.
|
||||||
|
async recycleKpFileOffers(fileSpId: number, positionSpIds: number[]): Promise<void> {
|
||||||
|
if (positionSpIds.length === 0) return;
|
||||||
|
const positionFilter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or ");
|
||||||
|
const url =
|
||||||
|
`${this._li(LISTS.OFFERS)}?$select=Id,offerFileId` +
|
||||||
|
`&$filter=offerFileId eq ${fileSpId} and (${positionFilter})&$top=5000`;
|
||||||
|
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||||
|
if (!res.ok) throw new Error(`Ошибка чтения предложений КП: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const records = ((data.value ?? []) as Array<Record<string, unknown>>).map((item) => ({
|
||||||
|
id: Number(item.Id),
|
||||||
|
offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined,
|
||||||
|
}));
|
||||||
|
// MERGE на этом списке блокирует ресивер — убираем через recycle(), как в saveOffers.
|
||||||
|
await this._runChunked(findKpFileOfferIds(records, fileSpId), 20, async (id) => {
|
||||||
|
await this.http
|
||||||
|
.post(`${this._liById(LISTS.OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
||||||
|
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _recycleSupersededOffers(saved: {
|
||||||
|
id: number;
|
||||||
|
supplierSpId: number;
|
||||||
|
positionSpId: number;
|
||||||
|
offerFileSpId: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const url =
|
||||||
|
`${this._li(LISTS.OFFERS)}?$select=Id,Title,positionId,supplierId,offerFileId` +
|
||||||
|
`&$filter=supplierId eq ${saved.supplierSpId} and positionId eq ${saved.positionSpId}`;
|
||||||
|
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||||
|
// Не критично для обработки КП: оставшиеся дубли отсеивают загрузчики (keepLatestOfferRecords),
|
||||||
|
// а «Сохранить» перезапишет записи позиции целиком. Но молча не глотаем.
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`[CtService] не удалось прочитать прежние предложения: ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const existing = ((data.value ?? []) as Array<Record<string, unknown>>).map((item) => ({
|
||||||
|
id: Number(item.Id),
|
||||||
|
supplierSpId: Number(item.supplierId),
|
||||||
|
positionSpId: Number(item.positionId),
|
||||||
|
itemIndex: parseOfferItemIndex(String(item.Title ?? "")),
|
||||||
|
offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined,
|
||||||
|
}));
|
||||||
|
// MERGE на этом списке блокирует ресивер — убираем через recycle(), как в saveOffers.
|
||||||
|
await this._runChunked(findSupersededOfferIds(existing, saved), 20, async (id) => {
|
||||||
|
await this.http
|
||||||
|
.post(`${this._liById(LISTS.OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
||||||
|
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Mapping ──────────────────────────────────────────────────────────────
|
// ─── Mapping ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { buildKpCriteria, buildKpOffers, buildKpSuppliers } from './kpOffers';
|
||||||
|
import { KpFile } from './kpTypes';
|
||||||
|
|
||||||
|
function makeKpFile(overrides: Partial<KpFile>): KpFile {
|
||||||
|
return {
|
||||||
|
localId: 'kp_1',
|
||||||
|
file: new File([''], 'kp.pdf'),
|
||||||
|
status: 'done',
|
||||||
|
spId: 100,
|
||||||
|
supplierName: 'ТОО «Кала Саплай»',
|
||||||
|
supplierSpId: 264,
|
||||||
|
currencyCode: 'KZT',
|
||||||
|
flowDone: 1,
|
||||||
|
flowTotal: 1,
|
||||||
|
flowResults: [
|
||||||
|
{ positionId: 'line_1', flowResult: { position: 'Доска пробковая', price: 28500, quantity: 4, conditions: 'Предоплата' } },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тот же КП поставщика загружен повторно под другим именем (или прислан исправленный).
|
||||||
|
const FIRST = makeKpFile({ localId: 'kp_1', spId: 100 });
|
||||||
|
const SECOND = makeKpFile({
|
||||||
|
localId: 'kp_2',
|
||||||
|
spId: 101,
|
||||||
|
flowResults: [
|
||||||
|
{ positionId: 'line_1', flowResult: { position: 'Доска пробковая', price: 27000, quantity: 4, conditions: 'Постоплата' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('КП одного поставщика: берётся последний', () => {
|
||||||
|
it('одна колонка поставщика вместо двух', () => {
|
||||||
|
expect(buildKpSuppliers([FIRST, SECOND])).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('предложения только из последнего КП', () => {
|
||||||
|
const offers = buildKpOffers([FIRST, SECOND]);
|
||||||
|
|
||||||
|
expect(offers).toHaveLength(1);
|
||||||
|
expect(offers[0]).toMatchObject({ pricePerUnit: 27000, offerId: '101' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('критерии из последнего КП', () => {
|
||||||
|
const criteria = buildKpCriteria([FIRST, SECOND]);
|
||||||
|
|
||||||
|
expect(criteria).toHaveLength(1);
|
||||||
|
expect(criteria[0].paymentType).toBe('Постоплата');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('разных поставщиков оставляет отдельными колонками', () => {
|
||||||
|
const other = makeKpFile({ localId: 'kp_3', supplierName: 'ТОО «Арлан Офис Снаб»', supplierSpId: 263 });
|
||||||
|
|
||||||
|
expect(buildKpSuppliers([FIRST, other]).map(s => s.id)).toEqual(['264', '263']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { CurrencyRate, SelectedPosition, Supplier, SupplierCriteria, SupplierOffer } from '../types/domain';
|
import { CurrencyRate, SelectedPosition, Supplier, SupplierCriteria, SupplierOffer } from '../types/domain';
|
||||||
import { KpFile } from './kpTypes';
|
import { KpFile } from './kpTypes';
|
||||||
|
import { pickLatestKpFilePerSupplier } from '../../../../shared/offers/kpFiles';
|
||||||
|
|
||||||
// Чистые сборщики доменных сущностей из обработанных файлов КП (без React, без сети).
|
// Чистые сборщики доменных сущностей из обработанных файлов КП (без React, без сети).
|
||||||
|
// Из нескольких КП одного поставщика каждый сборщик берёт последний (pickLatestKpFilePerSupplier):
|
||||||
|
// иначе повторно загруженный КП давал вторую колонку того же поставщика.
|
||||||
|
|
||||||
// Поставщики с КП — по одному на файл с распознанным поставщиком. id = String(spId) для связи с офферами.
|
// Поставщики с КП — по одному на файл с распознанным поставщиком. id = String(spId) для связи с офферами.
|
||||||
export function buildKpSuppliers(files: KpFile[]): Supplier[] {
|
export function buildKpSuppliers(files: KpFile[]): Supplier[] {
|
||||||
return files
|
return pickLatestKpFilePerSupplier(files)
|
||||||
.filter((f) => f.supplierName)
|
.filter((f) => f.supplierName)
|
||||||
.map((f) => ({
|
.map((f) => ({
|
||||||
id: f.supplierSpId !== undefined ? String(f.supplierSpId) : f.localId,
|
id: f.supplierSpId !== undefined ? String(f.supplierSpId) : f.localId,
|
||||||
@@ -19,7 +22,7 @@ export function buildKpSuppliers(files: KpFile[]): Supplier[] {
|
|||||||
// Офферы из результатов потока (позиция × поставщик). Цена/кол-во — в валюте поставщика.
|
// Офферы из результатов потока (позиция × поставщик). Цена/кол-во — в валюте поставщика.
|
||||||
export function buildKpOffers(files: KpFile[]): SupplierOffer[] {
|
export function buildKpOffers(files: KpFile[]): SupplierOffer[] {
|
||||||
const offers: SupplierOffer[] = [];
|
const offers: SupplierOffer[] = [];
|
||||||
for (const f of files) {
|
for (const f of pickLatestKpFilePerSupplier(files)) {
|
||||||
if (f.supplierSpId === undefined || f.flowResults.length === 0) {
|
if (f.supplierSpId === undefined || f.flowResults.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -43,7 +46,7 @@ export function buildKpOffers(files: KpFile[]): SupplierOffer[] {
|
|||||||
// Критерии из результатов потока — один набор на поставщика (первый файл с любым критерием).
|
// Критерии из результатов потока — один набор на поставщика (первый файл с любым критерием).
|
||||||
export function buildKpCriteria(files: KpFile[]): SupplierCriteria[] {
|
export function buildKpCriteria(files: KpFile[]): SupplierCriteria[] {
|
||||||
const criteria: SupplierCriteria[] = [];
|
const criteria: SupplierCriteria[] = [];
|
||||||
for (const f of files) {
|
for (const f of pickLatestKpFilePerSupplier(files)) {
|
||||||
if (f.supplierSpId === undefined) {
|
if (f.supplierSpId === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions, AadHttpClient } from '@microsoft/sp-http';
|
import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions, AadHttpClient } from '@microsoft/sp-http';
|
||||||
import {
|
import {
|
||||||
|
ApprovalStatusKey,
|
||||||
ApprovalListService,
|
ApprovalListService,
|
||||||
ApproverService,
|
ApproverService,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
IMatrixRow
|
IMatrixRow
|
||||||
} from '../../../shared/approval';
|
} from '../../../shared/approval';
|
||||||
|
import { keepLatestOfferRecords, parseOfferItemIndex } from '../../../shared/offers/offerRecords';
|
||||||
import type {
|
import type {
|
||||||
CtFormData,
|
CtFormData,
|
||||||
CtStatus,
|
CtStatus,
|
||||||
@@ -163,10 +165,12 @@ const CRITERIA_FIELDS = {
|
|||||||
const KPS_REQUEST_TYPE_PAGE_URL = 'kps';
|
const KPS_REQUEST_TYPE_PAGE_URL = 'kps';
|
||||||
|
|
||||||
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ.
|
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ.
|
||||||
const APPROVAL_STATUS_TO_CT_STATUS: Record<'onApproval' | 'approved' | 'rejected', CtStatus> = {
|
const APPROVAL_STATUS_TO_CT_STATUS: Record<ApprovalStatusKey, CtStatus> = {
|
||||||
onApproval: 'on_approval',
|
onApproval: 'on_approval',
|
||||||
approved: 'approved',
|
approved: 'approved',
|
||||||
rejected: 'rejected'
|
rejected: 'rejected',
|
||||||
|
// Кнопки «На доработку» в КПС нет (пока только СТ ОМТС) — статус недостижим; на всякий случай как отклонение.
|
||||||
|
rework: 'rejected'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Округление денег до 2 знаков (срезаем хвост float перед записью в SP).
|
// Округление денег до 2 знаков (срезаем хвост float перед записью в SP).
|
||||||
@@ -718,14 +722,12 @@ export class KpsCtFormService {
|
|||||||
`&$filter=${filter}&$top=2000`
|
`&$filter=${filter}&$top=2000`
|
||||||
);
|
);
|
||||||
const mapped = rows.map((r) => {
|
const mapped = rows.map((r) => {
|
||||||
const parts = String(r.Title ?? '').split('-');
|
|
||||||
const itemIndex = parts.length >= 3 ? parseInt(parts[2], 10) || 0 : 0;
|
|
||||||
return {
|
return {
|
||||||
id: r.Id,
|
id: r.Id,
|
||||||
positionSpId: Number(r.positionId ?? 0),
|
positionSpId: Number(r.positionId ?? 0),
|
||||||
supplierSpId: Number(r.supplierId ?? r.supplier?.Id ?? 0),
|
supplierSpId: Number(r.supplierId ?? r.supplier?.Id ?? 0),
|
||||||
supplierName: String(r.supplier?.Title ?? ''),
|
supplierName: String(r.supplier?.Title ?? ''),
|
||||||
itemIndex,
|
itemIndex: parseOfferItemIndex(r.Title),
|
||||||
supplierPosition: String(r.supplierPosition ?? ''),
|
supplierPosition: String(r.supplierPosition ?? ''),
|
||||||
unit: String(r.unit ?? ''),
|
unit: String(r.unit ?? ''),
|
||||||
quantity: Number(r.quantity ?? 0),
|
quantity: Number(r.quantity ?? 0),
|
||||||
@@ -737,15 +739,7 @@ export class KpsCtFormService {
|
|||||||
// Дедуп: одна запись на (поставщик, позиция, itemIndex), держим последнюю (max Id). На «защищённом»
|
// Дедуп: одна запись на (поставщик, позиция, itemIndex), держим последнюю (max Id). На «защищённом»
|
||||||
// списке recycle прежних офферов мог не сработать (`saveOffers` ловит ошибку) → накапливаются дубли
|
// списке recycle прежних офферов мог не сработать (`saveOffers` ловит ошибку) → накапливаются дубли
|
||||||
// прошлых сохранений; без дедупа они рисуются как повторяющиеся под-строки при реконструкции.
|
// прошлых сохранений; без дедупа они рисуются как повторяющиеся под-строки при реконструкции.
|
||||||
const byKey = new Map<string, (typeof mapped)[0]>();
|
return keepLatestOfferRecords(mapped);
|
||||||
for (const m of mapped) {
|
|
||||||
const key = `${m.supplierSpId}::${m.positionSpId}::${m.itemIndex}`;
|
|
||||||
const prev = byKey.get(key);
|
|
||||||
if (!prev || m.id > prev.id) {
|
|
||||||
byKey.set(key, m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(byKey.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Следующий номер СТ — сквозная нумерация max(number)+1 (одним POST, без MERGE).
|
// Следующий номер СТ — сквозная нумерация max(number)+1 (одним POST, без MERGE).
|
||||||
|
|||||||
+4
-1
@@ -146,7 +146,10 @@ export function useKpProcessing(service: OidService, formData: OIDFormData) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (offers.length > 0) {
|
if (offers.length > 0) {
|
||||||
service.saveOffers(offers).catch(() => undefined);
|
service.replaceSupplierOffers(supplierSpId, offers).catch((err) => {
|
||||||
|
// Не критично: Шаг 3 при сохранении перезаписывает предложения позиций целиком.
|
||||||
|
console.warn("[КП ОИД] не удалось записать предложения:", err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
} from "../components/types/domain";
|
} from "../components/types/domain";
|
||||||
import {
|
import {
|
||||||
ApprovalListService,
|
ApprovalListService,
|
||||||
|
ApprovalStatusKey,
|
||||||
ApproverService,
|
ApproverService,
|
||||||
IApprovalListFields,
|
IApprovalListFields,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
getRequestTypeIdByPageUrl,
|
getRequestTypeIdByPageUrl,
|
||||||
writeRoute,
|
writeRoute,
|
||||||
} from "../../../shared/approval";
|
} from "../../../shared/approval";
|
||||||
|
import { findSupplierOfferIds } from "../../../shared/offers/offerRecords";
|
||||||
|
|
||||||
const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed";
|
const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed";
|
||||||
|
|
||||||
@@ -67,10 +69,12 @@ const APPROVAL_FIELDS: IApprovalListFields = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ ОИД.
|
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ ОИД.
|
||||||
const APPROVAL_STATUS_TO_OID_STATUS: Record<"onApproval" | "approved" | "rejected", OIDStatus> = {
|
const APPROVAL_STATUS_TO_OID_STATUS: Record<ApprovalStatusKey, OIDStatus> = {
|
||||||
onApproval: "on_approval",
|
onApproval: "on_approval",
|
||||||
approved: "approved",
|
approved: "approved",
|
||||||
rejected: "rejected",
|
rejected: "rejected",
|
||||||
|
// Кнопки «На доработку» в ОИД нет (пока только СТ ОМТС) — статус недостижим; на всякий случай как отклонение.
|
||||||
|
rework: "rejected",
|
||||||
};
|
};
|
||||||
|
|
||||||
const EXT_API_RESOURCE = "9232d9e2-9807-4753-808f-07250e5474b0";
|
const EXT_API_RESOURCE = "9232d9e2-9807-4753-808f-07250e5474b0";
|
||||||
@@ -786,6 +790,47 @@ export class OidService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Предложения нового КП поставщика (Шаг 2) заменяют его прежние по этим позициям. Раньше повторная
|
||||||
|
// обработка КП добавляла записи рядом с прежними, а Шаг 3 грузит всё из SP — позиция показывалась
|
||||||
|
// «комплектом» из одинаковых строк, и автосохранение Шага 3 закрепляло его в SP.
|
||||||
|
// Порядок «снимок → запись → удаление снимка»: упади запись — прежнее предложение останется.
|
||||||
|
async replaceSupplierOffers(supplierSpId: number, offers: SupplierOffer[]): Promise<void> {
|
||||||
|
if (offers.length === 0) return;
|
||||||
|
const positionSpIds = offers.map((o) => Number(o.positionId));
|
||||||
|
const filter =
|
||||||
|
`supplierId eq ${supplierSpId} and (` +
|
||||||
|
positionSpIds.map((id) => `positionId eq ${id}`).join(" or ") +
|
||||||
|
")";
|
||||||
|
const res = await this.sp.get(
|
||||||
|
`${listUrl(LISTS.OFFERS)}/items?$filter=${encodeURIComponent(filter)}&$select=Id,positionId,supplierId&$top=1000`,
|
||||||
|
SPHttpClient.configurations.v1,
|
||||||
|
);
|
||||||
|
const previous = res.ok
|
||||||
|
? ((await res.json()).value as Array<Record<string, unknown>>).map((item) => ({
|
||||||
|
id: Number(item.Id),
|
||||||
|
supplierSpId: Number(item.supplierId),
|
||||||
|
positionSpId: Number(item.positionId),
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
await this.saveOffers(offers);
|
||||||
|
|
||||||
|
const staleIds = findSupplierOfferIds(previous, supplierSpId, positionSpIds);
|
||||||
|
if (staleIds.length === 0) return;
|
||||||
|
const results = await this.executeBatch(
|
||||||
|
staleIds.map((id) => ({
|
||||||
|
method: "POST" as const,
|
||||||
|
url: `${listUrl(LISTS.OFFERS)}/items(${id})/recycle()`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.ok || r.status === 404) continue;
|
||||||
|
// SP webhook bug: item IS recycled but event receiver throws FormatException
|
||||||
|
if (r.status === 500 && r.body.includes("FormatException")) continue;
|
||||||
|
throw new Error(`Ошибка удаления прежнего предложения: ${r.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadOffersForPositions(
|
async loadOffersForPositions(
|
||||||
positionSpIds: number[],
|
positionSpIds: number[],
|
||||||
): Promise<SupplierOffer[]> {
|
): Promise<SupplierOffer[]> {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ApproverService } from './approverDecision.service';
|
||||||
|
import { ApprovalListService } from './approvalList.service';
|
||||||
|
import { IApprovalRow } from './models/IApprovalRow';
|
||||||
|
import { REQUEST_STATUS } from './models/statuses';
|
||||||
|
|
||||||
|
function makeRow(id: number, status: string): IApprovalRow {
|
||||||
|
return { id, order: id, status } as unknown as IApprovalRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поддельный список «Согласование …»: хранит строки в памяти.
|
||||||
|
function makeApproval(rows: IApprovalRow[]): { approval: ApprovalListService; activated: number[] } {
|
||||||
|
const activated: number[] = [];
|
||||||
|
const fake = {
|
||||||
|
setDecision: async (rowId: number, status: string) => {
|
||||||
|
const row = rows.find(r => r.id === rowId);
|
||||||
|
if (row) row.status = status;
|
||||||
|
},
|
||||||
|
activateNextQueued: async (requestId: number) => { activated.push(requestId); },
|
||||||
|
getForRequest: async () => rows,
|
||||||
|
};
|
||||||
|
return { approval: fake as unknown as ApprovalListService, activated };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ApproverService.decide — «На доработку»', () => {
|
||||||
|
// Раньше у согласующего были только «Согласовать» и «Отклонить»: вернуть СТ на исправление было
|
||||||
|
// нельзя, приходилось отклонять.
|
||||||
|
it('строка получает «На доработке», СТ — статус доработки, очередь дальше не идёт', async () => {
|
||||||
|
const rows = [makeRow(1, REQUEST_STATUS.approved), makeRow(2, REQUEST_STATUS.onApproval), makeRow(3, REQUEST_STATUS.queued)];
|
||||||
|
const { approval, activated } = makeApproval(rows);
|
||||||
|
const computed: string[] = [];
|
||||||
|
const service = new ApproverService(approval, async status => { computed.push(status); });
|
||||||
|
|
||||||
|
const label = await service.decide(360, 2, 'rework', 'Уточните цены поставщика');
|
||||||
|
|
||||||
|
expect(rows[1].status).toBe(REQUEST_STATUS.rework);
|
||||||
|
expect(computed).toEqual(['rework']);
|
||||||
|
expect(label).toBe(REQUEST_STATUS.rework);
|
||||||
|
expect(activated).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('«Согласовать» по-прежнему двигает очередь', async () => {
|
||||||
|
const rows = [makeRow(1, REQUEST_STATUS.onApproval), makeRow(2, REQUEST_STATUS.queued)];
|
||||||
|
const { approval, activated } = makeApproval(rows);
|
||||||
|
const service = new ApproverService(approval, async () => undefined);
|
||||||
|
|
||||||
|
await service.decide(360, 1, 'approve', '');
|
||||||
|
|
||||||
|
expect(activated).toEqual([360]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,9 +3,12 @@ import { REQUEST_STATUS } from './models/statuses';
|
|||||||
import { IApprovalRow } from './models/IApprovalRow';
|
import { IApprovalRow } from './models/IApprovalRow';
|
||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
|
|
||||||
// approve — согласовать; reject — отклонить (нет фазы исполнения и статуса
|
// approve — согласовать; reject — отклонить; rework — вернуть инициатору на доработку
|
||||||
// «возврат на доработку» — как в прежнем KPS-механизме).
|
// (после исправлений он отправляет заново, маршрут идёт с начала). Нет фазы исполнения.
|
||||||
export type ApproverDecision = 'approve' | 'reject';
|
export type ApproverDecision = 'approve' | 'reject' | 'rework';
|
||||||
|
|
||||||
|
// Статус заявки, вычисленный из строк маршрута, — форма пишет его в своё поле статуса.
|
||||||
|
export type ApprovalStatusKey = 'onApproval' | 'approved' | 'rejected' | 'rework';
|
||||||
|
|
||||||
// Активная строка текущего пользователя: либо он сам назначен на неё
|
// Активная строка текущего пользователя: либо он сам назначен на неё
|
||||||
// (isDelegator=false), либо это его делегат недоступен и он, как делегировавший,
|
// (isDelegator=false), либо это его делегат недоступен и он, как делегировавший,
|
||||||
@@ -16,7 +19,8 @@ export interface IPendingRow extends IApprovalRow {
|
|||||||
|
|
||||||
const ROW_STATUS: Record<ApproverDecision, string> = {
|
const ROW_STATUS: Record<ApproverDecision, string> = {
|
||||||
approve: REQUEST_STATUS.approved,
|
approve: REQUEST_STATUS.approved,
|
||||||
reject: REQUEST_STATUS.rejected
|
reject: REQUEST_STATUS.rejected,
|
||||||
|
rework: REQUEST_STATUS.rework
|
||||||
};
|
};
|
||||||
|
|
||||||
// Действия согласующего: определение его строки, применение решения,
|
// Действия согласующего: определение его строки, применение решения,
|
||||||
@@ -30,7 +34,7 @@ export class ApproverService {
|
|||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private readonly approval: ApprovalListService,
|
private readonly approval: ApprovalListService,
|
||||||
private readonly onStatusComputed: (status: 'onApproval' | 'approved' | 'rejected') => Promise<void>
|
private readonly onStatusComputed: (status: ApprovalStatusKey) => Promise<void>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
|
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
|
||||||
@@ -91,9 +95,12 @@ export class ApproverService {
|
|||||||
return status.label;
|
return status.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Статус заявки из строк маршрута: отклонение любой строки → «Отклонено»;
|
// Статус заявки из строк маршрута: возврат на доработку любой строкой → «На доработке»;
|
||||||
// все согласовали → «Согласовано»; иначе ещё «На согласовании».
|
// отклонение → «Отклонено»; все согласовали → «Согласовано»; иначе ещё «На согласовании».
|
||||||
private computeRequestStatus(rows: IApprovalRow[]): { key: 'onApproval' | 'approved' | 'rejected'; label: string } {
|
private computeRequestStatus(rows: IApprovalRow[]): { key: ApprovalStatusKey; label: string } {
|
||||||
|
if (rows.some((r) => r.status === REQUEST_STATUS.rework)) {
|
||||||
|
return { key: 'rework', label: REQUEST_STATUS.rework };
|
||||||
|
}
|
||||||
if (rows.some((r) => r.status === REQUEST_STATUS.rejected)) {
|
if (rows.some((r) => r.status === REQUEST_STATUS.rejected)) {
|
||||||
return { key: 'rejected', label: REQUEST_STATUS.rejected };
|
return { key: 'rejected', label: REQUEST_STATUS.rejected };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export { getRequestTypes, getRequestTypesCached, getRequestTypeIdByPageUrl, reso
|
|||||||
export { ApprovalListService, DEFAULT_APPROVAL_FIELDS } from './approvalList.service';
|
export { ApprovalListService, DEFAULT_APPROVAL_FIELDS } from './approvalList.service';
|
||||||
export type { IApproverToWrite, IApprovalListFields } from './approvalList.service';
|
export type { IApproverToWrite, IApprovalListFields } from './approvalList.service';
|
||||||
export { ApproverService } from './approverDecision.service';
|
export { ApproverService } from './approverDecision.service';
|
||||||
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
export type { ApproverDecision, ApprovalStatusKey, IPendingRow } from './approverDecision.service';
|
||||||
export { getPlannedMatrix, writeRoute } from './approvalSubmission.service';
|
export { getPlannedMatrix, writeRoute } from './approvalSubmission.service';
|
||||||
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const REQUEST_STATUS = {
|
|||||||
onApproval: 'На согласовании',
|
onApproval: 'На согласовании',
|
||||||
approved: 'Согласовано',
|
approved: 'Согласовано',
|
||||||
rejected: 'Отклонено',
|
rejected: 'Отклонено',
|
||||||
|
// Возвращено инициатору на исправление; после повторной отправки маршрут идёт с начала.
|
||||||
|
rework: 'На доработке',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Значения Choice-колонки статуса строки маршрута при провижининге.
|
// Значения Choice-колонки статуса строки маршрута при провижининге.
|
||||||
@@ -21,4 +23,5 @@ export const APPROVAL_ROW_STATUS_CHOICES: string[] = [
|
|||||||
REQUEST_STATUS.onApproval,
|
REQUEST_STATUS.onApproval,
|
||||||
REQUEST_STATUS.approved,
|
REQUEST_STATUS.approved,
|
||||||
REQUEST_STATUS.rejected,
|
REQUEST_STATUS.rejected,
|
||||||
|
REQUEST_STATUS.rework,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ interface IDecisionPanelProps {
|
|||||||
search?: (query: string) => Promise<ISiteUser[]>;
|
search?: (query: string) => Promise<ISiteUser[]>;
|
||||||
/** Вызывается после успешного переназначения (обновить экран/матрицу). */
|
/** Вызывается после успешного переназначения (обновить экран/матрицу). */
|
||||||
onDelegated?: () => void;
|
onDelegated?: () => void;
|
||||||
|
/** Кнопка «На доработку» — вернуть инициатору на исправление. Пока включена только в СТ ОМТС. */
|
||||||
|
allowRework?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Панель решения текущего шага: согласующий (Согласовать/Отклонить). Если у шага
|
// Панель решения текущего шага: согласующий (Согласовать/Отклонить). Если у шага
|
||||||
// разрешено переназначение (allowReassign) — можно передать шаг другому сотруднику.
|
// разрешено переназначение (allowReassign) — можно передать шаг другому сотруднику.
|
||||||
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestId, onDecided, search, onDelegated }) => {
|
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestId, onDecided, search, onDelegated, allowRework }) => {
|
||||||
const [checking, setChecking] = useState<boolean>(true);
|
const [checking, setChecking] = useState<boolean>(true);
|
||||||
const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined);
|
const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined);
|
||||||
const [comment, setComment] = useState<string>('');
|
const [comment, setComment] = useState<string>('');
|
||||||
@@ -61,6 +63,10 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
|
|||||||
setError('Для отклонения укажите комментарий.');
|
setError('Для отклонения укажите комментарий.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (decision === 'rework' && comment.trim().length === 0) {
|
||||||
|
setError('Укажите в комментарии, что нужно доработать.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -206,13 +212,24 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
|
|||||||
<div className={styles.title}>Ваше решение</div>
|
<div className={styles.title}>Ваше решение</div>
|
||||||
{error && <Alert variant="err">{error}</Alert>}
|
{error && <Alert variant="err">{error}</Alert>}
|
||||||
<FormField label="Комментарий" htmlFor="decisionComment">
|
<FormField label="Комментарий" htmlFor="decisionComment">
|
||||||
<TextArea id="decisionComment" value={comment} onChange={setComment} placeholder="Комментарий (обязателен при отклонении)" />
|
<TextArea
|
||||||
|
id="decisionComment"
|
||||||
|
value={comment}
|
||||||
|
onChange={setComment}
|
||||||
|
placeholder={allowRework ? 'Комментарий (обязателен при отклонении и возврате на доработку)' : 'Комментарий (обязателен при отклонении)'}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Button variant="accent" disabled={submitting} onClick={() => decide('approve')}>
|
<Button variant="accent" disabled={submitting} onClick={() => decide('approve')}>
|
||||||
<Icon name="check" size="sm" />
|
<Icon name="check" size="sm" />
|
||||||
Согласовать
|
Согласовать
|
||||||
</Button>
|
</Button>
|
||||||
|
{allowRework && (
|
||||||
|
<Button variant="secondary" disabled={submitting} onClick={() => decide('rework')}>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
На доработку
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="danger" disabled={submitting} onClick={() => decide('reject')}>
|
<Button variant="danger" disabled={submitting} onClick={() => decide('reject')}>
|
||||||
<Icon name="close" size="sm" />
|
<Icon name="close" size="sm" />
|
||||||
Отклонить
|
Отклонить
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Файл КП в обработке — общее подмножество полей KpFile форм СТ и КПС.
|
||||||
|
export interface IKpFileForSupplier {
|
||||||
|
supplierName?: string;
|
||||||
|
supplierSpId?: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DONE_STATUS = 'done';
|
||||||
|
|
||||||
|
// Несколько КП одного поставщика (повторная загрузка, тот же КП под другим именем файла) получают
|
||||||
|
// один и тот же supplierSpId — поставщик находится по имени. Без отбора в таблице сравнения
|
||||||
|
// появляется дубль колонки с тем же id, а офферы обоих файлов складываются в одну ячейку.
|
||||||
|
// Оставляем последний загруженный файл поставщика, но обработанный не вытесняем тем, что ещё в процессе.
|
||||||
|
export function pickLatestKpFilePerSupplier<T extends IKpFileForSupplier>(kpFiles: T[]): T[] {
|
||||||
|
const fileBySupplier = new Map<string, T>();
|
||||||
|
kpFiles.forEach(file => {
|
||||||
|
if (!file.supplierName) return;
|
||||||
|
const supplierKey = file.supplierSpId !== undefined ? String(file.supplierSpId) : file.supplierName;
|
||||||
|
const existing = fileBySupplier.get(supplierKey);
|
||||||
|
const canReplace = !existing || file.status === DONE_STATUS || existing.status !== DONE_STATUS;
|
||||||
|
if (canReplace) fileBySupplier.set(supplierKey, file);
|
||||||
|
});
|
||||||
|
return Array.from(fileBySupplier.values());
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { IOfferRecordKey, findKpFileOfferIds, findSupersededOfferIds, findSupplierOfferIds, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords';
|
||||||
|
|
||||||
|
interface ITestRecord extends IOfferRecordKey {
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRecord(overrides: Partial<ITestRecord>): ITestRecord {
|
||||||
|
return { id: 1, supplierSpId: 259, positionSpId: 1476, itemIndex: 0, price: 100, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseOfferItemIndex', () => {
|
||||||
|
it('«поставщик-позиция» — первая строка КП (0)', () => {
|
||||||
|
expect(parseOfferItemIndex('259-1476')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('«поставщик-позиция-N» — строка комплекта N', () => {
|
||||||
|
expect(parseOfferItemIndex('259-1476-2')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустой или битый Title считает первой строкой', () => {
|
||||||
|
expect(parseOfferItemIndex(undefined)).toBe(0);
|
||||||
|
expect(parseOfferItemIndex('')).toBe(0);
|
||||||
|
expect(parseOfferItemIndex('259-1476-x')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keepLatestOfferRecords', () => {
|
||||||
|
// Как в СТ №89: КП поставщика 259 обработан дважды → по две записи на позицию, повторная новее.
|
||||||
|
it('оставляет одну запись на поставщика и позицию — последнюю по Id', () => {
|
||||||
|
const records = [
|
||||||
|
makeRecord({ id: 1836, positionSpId: 1478, price: 100 }),
|
||||||
|
makeRecord({ id: 1850, positionSpId: 1478, price: 120 }),
|
||||||
|
makeRecord({ id: 1847, positionSpId: 1476, price: 55 }),
|
||||||
|
makeRecord({ id: 1837, positionSpId: 1476, price: 50 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = keepLatestOfferRecords(records);
|
||||||
|
|
||||||
|
expect(result.map(r => r.id).sort()).toEqual([1847, 1850]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('строки комплекта (разный itemIndex) не схлопывает', () => {
|
||||||
|
const records = [
|
||||||
|
makeRecord({ id: 10, itemIndex: 0 }),
|
||||||
|
makeRecord({ id: 11, itemIndex: 1 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(keepLatestOfferRecords(records)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('одну позицию у разных поставщиков не схлопывает', () => {
|
||||||
|
const records = [
|
||||||
|
makeRecord({ id: 10, supplierSpId: 259 }),
|
||||||
|
makeRecord({ id: 11, supplierSpId: 233 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(keepLatestOfferRecords(records)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findSupersededOfferIds', () => {
|
||||||
|
// Повторная обработка КП поставщика 259: новая запись 1850 заменяет прежнее предложение по позиции.
|
||||||
|
const saved = makeRecord({ id: 1850, positionSpId: 1478 });
|
||||||
|
|
||||||
|
it('возвращает прежние записи того же поставщика по той же позиции', () => {
|
||||||
|
const existing = [makeRecord({ id: 1836, positionSpId: 1478 }), saved];
|
||||||
|
|
||||||
|
expect(findSupersededOfferIds(existing, saved)).toEqual([1836]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('прежние строки комплекта тоже заменяются новым КП', () => {
|
||||||
|
const existing = [
|
||||||
|
makeRecord({ id: 1836, positionSpId: 1478, itemIndex: 0 }),
|
||||||
|
makeRecord({ id: 1837, positionSpId: 1478, itemIndex: 1 }),
|
||||||
|
saved,
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(findSupersededOfferIds(existing, saved)).toEqual([1836, 1837]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не трогает другие позиции и других поставщиков', () => {
|
||||||
|
const existing = [
|
||||||
|
makeRecord({ id: 1836, positionSpId: 1476 }),
|
||||||
|
makeRecord({ id: 1837, positionSpId: 1478, supplierSpId: 233 }),
|
||||||
|
saved,
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(findSupersededOfferIds(existing, saved)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не трогает запись новее сохранённой (параллельная обработка)', () => {
|
||||||
|
const existing = [saved, makeRecord({ id: 1900, positionSpId: 1478 })];
|
||||||
|
|
||||||
|
expect(findSupersededOfferIds(existing, saved)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findSupplierOfferIds', () => {
|
||||||
|
// ОИД: перед записью нового КП поставщика запоминаем его прежние записи по этим позициям,
|
||||||
|
// чтобы после записи убрать их. Номера строк комплекта ОИД в Title не хранит — берём все.
|
||||||
|
it('возвращает все записи поставщика по переданным позициям, включая строки комплекта', () => {
|
||||||
|
const records = [
|
||||||
|
makeRecord({ id: 314, supplierSpId: 18, positionSpId: 121 }),
|
||||||
|
makeRecord({ id: 315, supplierSpId: 18, positionSpId: 121 }),
|
||||||
|
makeRecord({ id: 316, supplierSpId: 18, positionSpId: 122 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(findSupplierOfferIds(records, 18, [121, 122])).toEqual([314, 315, 316]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не трогает другие позиции и других поставщиков', () => {
|
||||||
|
const records = [
|
||||||
|
makeRecord({ id: 314, supplierSpId: 18, positionSpId: 999 }),
|
||||||
|
makeRecord({ id: 315, supplierSpId: 19, positionSpId: 121 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(findSupplierOfferIds(records, 18, [121])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('несколько КП одного поставщика (offerFileSpId)', () => {
|
||||||
|
// Отдельные предложения поставщика (KZT и USD) — разные файлы КП, их записи не дубли друг друга.
|
||||||
|
it('keepLatestOfferRecords не схлопывает записи разных файлов КП', () => {
|
||||||
|
const records = [
|
||||||
|
makeRecord({ id: 10, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 277 }),
|
||||||
|
makeRecord({ id: 11, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 290 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(keepLatestOfferRecords(records)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keepLatestOfferRecords схлопывает дубли внутри одного файла', () => {
|
||||||
|
const records = [
|
||||||
|
makeRecord({ id: 10, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 277 }),
|
||||||
|
makeRecord({ id: 11, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 277 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(keepLatestOfferRecords(records).map(r => r.id)).toEqual([11]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findSupersededOfferIds не трогает записи другого КП того же поставщика', () => {
|
||||||
|
const saved = makeRecord({ id: 20, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 290 });
|
||||||
|
const existing = [
|
||||||
|
makeRecord({ id: 10, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 277 }),
|
||||||
|
makeRecord({ id: 15, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 290 }),
|
||||||
|
saved,
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(findSupersededOfferIds(existing, saved)).toEqual([15]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findKpFileOfferIds (КП убрали из сравнения)', () => {
|
||||||
|
it('возвращает записи только этого файла КП', () => {
|
||||||
|
const records = [
|
||||||
|
makeRecord({ id: 10, supplierSpId: 263, offerFileSpId: 277 }),
|
||||||
|
makeRecord({ id: 11, supplierSpId: 263 }),
|
||||||
|
makeRecord({ id: 12, supplierSpId: 263, offerFileSpId: 285 }),
|
||||||
|
makeRecord({ id: 13, supplierSpId: 264, offerFileSpId: 277 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(findKpFileOfferIds(records, 277)).toEqual([10, 13]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Записи списка «Оценка поставщиков»: одна запись = одна строка КП поставщика по позиции.
|
||||||
|
// У позиции может быть несколько строк КП (комплект) — они различаются itemIndex. У поставщика
|
||||||
|
// может быть несколько КП (отдельные предложения, напр. KZT и USD) — они различаются offerFileSpId.
|
||||||
|
|
||||||
|
export interface IOfferRecordKey {
|
||||||
|
// SP Id записи — чем больше, тем позже создана.
|
||||||
|
id: number;
|
||||||
|
supplierSpId: number;
|
||||||
|
positionSpId: number;
|
||||||
|
itemIndex: number;
|
||||||
|
// SP Id файла КП. Пусто у старых записей и в формах, где несколько КП у поставщика не поддержаны.
|
||||||
|
offerFileSpId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title записи: «поставщик-позиция» (первая строка КП) или «поставщик-позиция-N» (строка комплекта N).
|
||||||
|
export function parseOfferItemIndex(title: string | undefined): number {
|
||||||
|
const parts = (title ?? '').split('-');
|
||||||
|
if (parts.length < 3) return 0;
|
||||||
|
return parseInt(parts[2], 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Одна запись на (поставщик, файл КП, позиция, itemIndex), побеждает последняя (max Id). Дубли копятся,
|
||||||
|
// когда КП обрабатывают повторно: каждая обработка добавляет записи, а прежние удаляются только
|
||||||
|
// при «Сохранить». Без этого на шаге 3 одна позиция рисуется несколькими одинаковыми под-строками.
|
||||||
|
export function keepLatestOfferRecords<T extends IOfferRecordKey>(records: T[]): T[] {
|
||||||
|
const latestByKey = new Map<string, T>();
|
||||||
|
for (const record of records) {
|
||||||
|
const key = `${record.supplierSpId}::${record.offerFileSpId ?? ''}::${record.positionSpId}::${record.itemIndex}`;
|
||||||
|
const previous = latestByKey.get(key);
|
||||||
|
if (!previous || record.id > previous.id) {
|
||||||
|
latestByKey.set(key, record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(latestByKey.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Все записи поставщика по указанным позициям — снимок «до» записи нового КП, чтобы после записи
|
||||||
|
// убрать прежние. Для форм, где SP Id новой записи заранее не известен (ОИД пишет пакетом).
|
||||||
|
export function findSupplierOfferIds(
|
||||||
|
records: Array<Pick<IOfferRecordKey, 'id' | 'supplierSpId' | 'positionSpId'>>,
|
||||||
|
supplierSpId: number,
|
||||||
|
positionSpIds: number[],
|
||||||
|
): number[] {
|
||||||
|
return records
|
||||||
|
.filter(record => record.supplierSpId === supplierSpId && positionSpIds.includes(record.positionSpId))
|
||||||
|
.map(record => record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Записи одного файла КП — когда КП убирают из сравнения (корзина в «Загруженные КП»).
|
||||||
|
export function findKpFileOfferIds(
|
||||||
|
records: Array<Pick<IOfferRecordKey, 'id' | 'offerFileSpId'>>,
|
||||||
|
fileSpId: number,
|
||||||
|
): number[] {
|
||||||
|
return records.filter(record => record.offerFileSpId === fileSpId).map(record => record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Какие записи вытеснила только что сохранённая: прежние записи того же поставщика и того же файла КП
|
||||||
|
// по той же позиции, включая строки комплекта. Записи другого КП того же поставщика — отдельное
|
||||||
|
// предложение, их не трогаем. Записи новее сохранённой тоже: их могла создать параллельная обработка.
|
||||||
|
export function findSupersededOfferIds(
|
||||||
|
existing: IOfferRecordKey[],
|
||||||
|
saved: Pick<IOfferRecordKey, 'id' | 'supplierSpId' | 'positionSpId' | 'offerFileSpId'>,
|
||||||
|
): number[] {
|
||||||
|
return existing
|
||||||
|
.filter(record =>
|
||||||
|
record.supplierSpId === saved.supplierSpId &&
|
||||||
|
record.offerFileSpId === saved.offerFileSpId &&
|
||||||
|
record.positionSpId === saved.positionSpId &&
|
||||||
|
record.id < saved.id
|
||||||
|
)
|
||||||
|
.map(record => record.id);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user