Compare commits
4 Commits
adf3efeb64
...
5f5f0e7f81
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f5f0e7f81 | |||
| 64973a2232 | |||
| e4b6582e3c | |||
| a45807f669 |
@@ -65,7 +65,8 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
|
||||
|
||||
// Build supplierSpId → offerId map from session uploadedKpFiles.
|
||||
// offerId cannot be read back via SP REST (500), so we restore it from session.
|
||||
// Лукап offerId через REST не читается (500), поэтому сначала берём из сессии, а без сессии —
|
||||
// из числового дубля offerFileId (см. CtService.loadKpFilesForOffers).
|
||||
// formData here is the initial mount value (session state before this update).
|
||||
const offerIdBySupplierSpId = new Map<number, number>();
|
||||
for (const kpFile of (formData.uploadedKpFiles ?? [])) {
|
||||
@@ -120,7 +121,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
|
||||
// Build offers in domain format, restoring offerId from session
|
||||
const offers = rawOffers.map(o => {
|
||||
const offerIdNum = offerIdBySupplierSpId.get(o.supplierSpId);
|
||||
const offerIdNum = offerIdBySupplierSpId.get(o.supplierSpId) ?? o.offerFileSpId;
|
||||
const currencyCode = o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT';
|
||||
return {
|
||||
id: '',
|
||||
|
||||
+24
@@ -109,6 +109,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Размеры совпадают с селектом валюты в очереди загрузки (FileUploadArea), чтобы строки выглядели одинаково.
|
||||
.kpCurrencySelect {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 3px;
|
||||
background: $surface;
|
||||
color: $text;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
height: 26px;
|
||||
|
||||
&:focus { border-color: $accent; }
|
||||
}
|
||||
|
||||
.kpCurrencyCode {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
.noOfferBadge {
|
||||
background: $danger-bg;
|
||||
color: $danger;
|
||||
|
||||
+22
-1
@@ -11,6 +11,7 @@ import { useKpProcessing } from './useKpProcessing';
|
||||
import { persistStep2 } from './step2Persist';
|
||||
import { SuppliersWithoutKp } from './SuppliersWithoutKp';
|
||||
import { UploadedKpList } from './UploadedKpList';
|
||||
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
|
||||
import styles from './Step2CommercialOffers.module.scss';
|
||||
|
||||
interface Props {
|
||||
@@ -72,6 +73,23 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
onSave({ suppliers: [...withOffer, ...withoutOffer] });
|
||||
};
|
||||
|
||||
// Смена валюты уже загруженного КП (список «Загруженные КП»). Если файл ещё в очереди этой сессии,
|
||||
// синхронизируем и её: иначе persistStep2 пересоберёт поставщика из kpFiles со старой валютой.
|
||||
const handleUploadedCurrencyChange = (fileSpId: number, currencyCode: string): void => {
|
||||
const uploaded = formData.uploadedKpFiles.find(u => u.spId === fileSpId);
|
||||
if (!uploaded?.supplierName) return;
|
||||
onSave(applySupplierCurrency(
|
||||
formData.suppliers ?? [],
|
||||
formData.offers ?? [],
|
||||
uploaded.supplierName,
|
||||
currencyCode,
|
||||
currencyRates,
|
||||
));
|
||||
kp.kpFiles
|
||||
.filter(f => f.supplierName === uploaded.supplierName)
|
||||
.forEach(f => kp.handleCurrencyChange(f.localId, currencyCode));
|
||||
};
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setIsSaving(true);
|
||||
setSaveError(undefined);
|
||||
@@ -133,8 +151,11 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
<UploadedKpList
|
||||
files={formData.uploadedKpFiles}
|
||||
service={service}
|
||||
currencyOptions={currencyOptions}
|
||||
getCurrency={supplierName => getSupplierCurrency(formData.suppliers ?? [], supplierName)}
|
||||
onCurrencyChange={handleUploadedCurrencyChange}
|
||||
onRemove={(spId) => onSave({ uploadedKpFiles: formData.uploadedKpFiles.filter(u => u.spId !== spId) })}
|
||||
disabled={isReadOnly}
|
||||
disabled={isNotificationLink || isReadOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+35
-8
@@ -7,12 +7,24 @@ import styles from './Step2CommercialOffers.module.scss';
|
||||
interface Props {
|
||||
files: CTFormData['uploadedKpFiles'];
|
||||
service: CtService;
|
||||
currencyOptions: string[];
|
||||
getCurrency: (supplierName: string | undefined) => string;
|
||||
onCurrencyChange: (spId: number, currencyCode: string) => void;
|
||||
onRemove: (spId: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Секция «Загруженные КП» (постоянный список из formData): ссылка на файл + удаление из списка.
|
||||
export const UploadedKpList: React.FC<Props> = ({ files, service, onRemove, disabled }) => (
|
||||
// Секция «Загруженные КП» (постоянный список из formData): ссылка на файл, валюта КП и удаление.
|
||||
// Валюту здесь можно поправить и после возврата с Шага 3 — очередь загрузки к этому моменту уже пуста.
|
||||
export const UploadedKpList: React.FC<Props> = ({
|
||||
files,
|
||||
service,
|
||||
currencyOptions,
|
||||
getCurrency,
|
||||
onCurrencyChange,
|
||||
onRemove,
|
||||
disabled,
|
||||
}) => (
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Загруженные КП</h3>
|
||||
<ul className={styles.noOfferList}>
|
||||
@@ -27,12 +39,27 @@ export const UploadedKpList: React.FC<Props> = ({ files, service, onRemove, disa
|
||||
>
|
||||
{f.name}
|
||||
</a>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
title="Удалить КП из списка"
|
||||
onClick={() => onRemove(f.spId)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{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>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
|
||||
|
||||
const BASE_CURRENCY = 'KZT';
|
||||
|
||||
// Валюта хранится на поставщике (Шаг 3 читает supplier.currency), а офферы несут цену в валюте КП
|
||||
// и её пересчёт в тенге. При смене валюты уже загруженного КП обновляем и то и другое, чтобы
|
||||
// таблица сравнения и сохранение офферов в SP (valutaId) видели одну и ту же валюту.
|
||||
export function applySupplierCurrency(
|
||||
suppliers: Supplier[],
|
||||
offers: SupplierOffer[],
|
||||
supplierName: string,
|
||||
currencyCode: string,
|
||||
currencyRates: CurrencyRate[],
|
||||
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
|
||||
const supplierIds = new Set(
|
||||
suppliers.filter(s => s.hasOffer && s.name === supplierName).map(s => s.id),
|
||||
);
|
||||
const rate = currencyCode === BASE_CURRENCY
|
||||
? 1
|
||||
: (currencyRates.find(r => r.code === currencyCode)?.rateToKzt ?? 1);
|
||||
|
||||
const nextSuppliers = suppliers.map(s =>
|
||||
supplierIds.has(s.id) ? { ...s, currency: currencyCode } : s,
|
||||
);
|
||||
const nextOffers = offers.map(o => {
|
||||
if (!supplierIds.has(o.supplierId)) return o;
|
||||
const pricePerUnitKzt = o.pricePerUnit * rate;
|
||||
const totalPriceKzt = pricePerUnitKzt * o.quantity;
|
||||
return { ...o, currencyCode, pricePerUnitKzt, totalPriceKzt, totalWithExtraKzt: totalPriceKzt };
|
||||
});
|
||||
|
||||
return { suppliers: nextSuppliers, offers: nextOffers };
|
||||
}
|
||||
|
||||
export function getSupplierCurrency(suppliers: Supplier[], supplierName: string | undefined): string {
|
||||
const supplier = suppliers.find(s => s.hasOffer && s.name === supplierName);
|
||||
return supplier?.currency ?? BASE_CURRENCY;
|
||||
}
|
||||
+19
-2
@@ -11,15 +11,32 @@ export interface PersistStep2Args {
|
||||
noOfferSuppliers: NoOfferEntry[];
|
||||
}
|
||||
|
||||
// Несколько КП одного поставщика (повторная загрузка, тот же КП под другим именем файла) получают
|
||||
// один и тот же 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 (поставщик должен
|
||||
// доходить до Шага 3 даже без кнопки «Сохранить и продолжить»).
|
||||
export function buildKpSuppliersAndOffers(
|
||||
kpFiles: KpFile[],
|
||||
allKpFiles: KpFile[],
|
||||
currencyRates: CurrencyRate[],
|
||||
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
|
||||
const kpFiles = pickLatestFilePerSupplier(allKpFiles);
|
||||
const suppliers: Supplier[] = kpFiles
|
||||
.filter(f => f.supplierName)
|
||||
.map(f => ({
|
||||
id: f.supplierSpId ? String(f.supplierSpId) : f.localId,
|
||||
spId: f.supplierSpId,
|
||||
|
||||
-1
@@ -80,7 +80,6 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
||||
</div>
|
||||
<div className={styles.supplierMetaRow}>
|
||||
<AdataIndicator supplier={s} state={adata.adataMap[s.id]} onCheck={() => void adata.checkSupplier(s.id)} />
|
||||
{s.isNoOffer && <span className={styles.noOfferTag}>Без КП</span>}
|
||||
{currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>}
|
||||
</div>
|
||||
<div className={styles.supplierControlsRow}>
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
||||
}
|
||||
|
||||
const rate = isForeign ? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) : 1;
|
||||
const qty = item.quantity ?? ((s.isNoOffer || s.isExtraSupplier) && pos.quantity ? pos.quantity : undefined);
|
||||
const qty = item.quantity ?? (s.isExtraSupplier && pos.quantity ? pos.quantity : undefined);
|
||||
const price = item.pricePerUnit;
|
||||
const totalInCurrency = (price ?? 0) * (qty ?? 0);
|
||||
const priceKzt = (price ?? 0) * rate;
|
||||
|
||||
-10
@@ -213,16 +213,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.noOfferTag {
|
||||
font-size: 11px;
|
||||
background: $danger-bg;
|
||||
color: $danger;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.deleteSupplierBtn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
+9
-21
@@ -290,9 +290,13 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
}, []);
|
||||
|
||||
const suppliersWithOffer = useMemo(() => [
|
||||
...suppliers.map(s => ({ ...s, isExtraSupplier: false as const, isNoOffer: !s.hasOffer })),
|
||||
// Поставщики без КП с Шага 2 (hasOffer:false) в таблицу сравнения не попадают — сравнивать
|
||||
// у них нечего. Ручной ввод цен остаётся за поставщиками, добавленными на самом Шаге 3 (extras).
|
||||
...suppliers
|
||||
.filter(s => s.hasOffer !== false)
|
||||
.map(s => ({ ...s, isExtraSupplier: false as const })),
|
||||
...extraSuppliers.map(es => ({
|
||||
id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const, isNoOffer: false as const,
|
||||
id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const,
|
||||
// spId/bin известны сразу для поставщиков, добавленных из «База данных поставщиков»
|
||||
// (см. handleAddSupplierFromDatabase) — без этого useAdataCheck не увидит их БИН.
|
||||
spId: es.spId, bin: es.bin,
|
||||
@@ -343,10 +347,10 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
const rate = currency !== 'KZT'
|
||||
? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1)
|
||||
: 1;
|
||||
// For noOffer/extra suppliers quantity may be undefined in edits (user only entered price).
|
||||
// For extra suppliers quantity may be undefined in edits (user only entered price).
|
||||
// Fall back to the position's own quantity so totalWithExtraKzt stays non-zero and the
|
||||
// offer is included in filteredComputed → persisted in offersForSession → restored on remount.
|
||||
const posQtyFallback = (s.isNoOffer || s.isExtraSupplier) ? (pos.quantity || undefined) : undefined;
|
||||
const posQtyFallback = s.isExtraSupplier ? (pos.quantity || undefined) : undefined;
|
||||
items.forEach(item => result.push(computeOffer(
|
||||
{
|
||||
...item,
|
||||
@@ -694,20 +698,6 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
extraOfferIds[es.id] = offerId;
|
||||
}
|
||||
|
||||
const noOfferSupSpIds: Record<string, number> = {};
|
||||
const noOfferOfferIds: Record<string, number> = {};
|
||||
for (const s of suppliers.filter(sup => !sup.hasOffer)) {
|
||||
const hasData = computedOffers.some(o => o.supplierId === s.id && o.totalWithExtraKzt > 0);
|
||||
if (!hasData) continue;
|
||||
if (s.spId) {
|
||||
noOfferSupSpIds[s.id] = s.spId;
|
||||
continue;
|
||||
}
|
||||
const { spId, offerId } = await service.saveManualSupplierWithKp(s.name);
|
||||
noOfferSupSpIds[s.id] = spId;
|
||||
noOfferOfferIds[s.id] = offerId;
|
||||
}
|
||||
|
||||
const extraPosSpIds: Record<string, number> = {};
|
||||
for (const ep of extraPositions) {
|
||||
if (ep.spId) {
|
||||
@@ -735,7 +725,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
const offersToSave = computedOffers
|
||||
.filter(o => o.totalWithExtraKzt > 0)
|
||||
.map(o => {
|
||||
const supSpId = extraSupSpIds[o.supplierId] ?? noOfferSupSpIds[o.supplierId];
|
||||
const supSpId = extraSupSpIds[o.supplierId];
|
||||
const posSpId = extraPosSpIds[o.positionId];
|
||||
const existingSup = suppliers.find(s => s.id === o.supplierId);
|
||||
const existingPos = selectedPositions.find(p => p.id === o.positionId);
|
||||
@@ -761,7 +751,6 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
: (existingPos?.spId ? String(existingPos.spId) : o.positionId),
|
||||
offerId: originalOffer?.offerId
|
||||
?? (extraOfferIds[o.supplierId] !== undefined ? String(extraOfferIds[o.supplierId]) : undefined)
|
||||
?? (noOfferOfferIds[o.supplierId] !== undefined ? String(noOfferOfferIds[o.supplierId]) : undefined)
|
||||
?? (kpFileOfferId !== undefined ? String(kpFileOfferId) : undefined),
|
||||
...(kmVal !== undefined ? { deliveryDistanceKm: kmVal } : {}),
|
||||
};
|
||||
@@ -784,7 +773,6 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
...suppliers.map(s => ({
|
||||
...s,
|
||||
status: supplierStatuses[s.id] ?? s.status,
|
||||
...(noOfferSupSpIds[s.id] !== undefined ? { spId: noOfferSupSpIds[s.id] } : {}),
|
||||
})),
|
||||
// Move extras into suppliers (with React ID kept as `id` — required so formData.offers
|
||||
// entries with React IDs still match sup.id on remount). Skip extras that are already in
|
||||
|
||||
@@ -543,7 +543,11 @@ export class CtService {
|
||||
if (!isNaN(valId)) body.valutaId = valId;
|
||||
}
|
||||
const resolvedOfferId = o.offerId ? parseInt(o.offerId, 10) : undefined;
|
||||
if (resolvedOfferId && !isNaN(resolvedOfferId)) body.offerId = resolvedOfferId;
|
||||
if (resolvedOfferId && !isNaN(resolvedOfferId)) {
|
||||
body.offerId = resolvedOfferId;
|
||||
// Дубль лукапа числом — только его можно прочитать обратно (см. loadKpFilesForOffers).
|
||||
body.offerFileId = resolvedOfferId;
|
||||
}
|
||||
|
||||
const res = await this.http.post(this._li(LISTS.OFFERS), SPHttpClient.configurations.v1, {
|
||||
headers: { "Content-Type": "application/json;odata=nometadata" },
|
||||
@@ -777,6 +781,7 @@ export class CtService {
|
||||
currencyCode: string;
|
||||
valutaSpId?: number;
|
||||
deliveryDistanceKm?: number;
|
||||
offerFileSpId?: number;
|
||||
}>
|
||||
> {
|
||||
if (positionSpIds.length === 0) return [];
|
||||
@@ -787,7 +792,7 @@ export class CtService {
|
||||
// valutaId is selected directly; caller enriches currencyCode via loadCurrencyRatesByIds.
|
||||
const url =
|
||||
`${this._li(LISTS.OFFERS)}` +
|
||||
`?$select=positionId,supplierId,price,quantity,unit,priceTenge,summaTenge,valutaId,deliveryDistanceKm,supplier/Id,supplier/Title` +
|
||||
`?$select=positionId,supplierId,price,quantity,unit,priceTenge,summaTenge,valutaId,deliveryDistanceKm,offerFileId,supplier/Id,supplier/Title` +
|
||||
`&$expand=supplier` +
|
||||
`&$filter=${filter}` +
|
||||
`&$top=2000`;
|
||||
@@ -810,6 +815,7 @@ export class CtService {
|
||||
currencyCode: "KZT", // enriched by caller via loadCurrencyRatesByIds
|
||||
valutaSpId: valutaSpId && !isNaN(valutaSpId) ? valutaSpId : undefined,
|
||||
deliveryDistanceKm: item["deliveryDistanceKm"] != null ? Number(item["deliveryDistanceKm"]) : undefined,
|
||||
offerFileSpId: item["offerFileId"] ? Number(item["offerFileId"]) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -826,11 +832,13 @@ export class CtService {
|
||||
const filter = positionSpIds
|
||||
.map((id) => `positionId eq ${id}`)
|
||||
.join(" or ");
|
||||
// offerId — обычное числовое поле (SP-Id строки файла КП), НЕ лукап. Раньше в $select
|
||||
// ошибочно был offerIdId (raw-ID суффикс лукапа) — такого поля нет, и SP валил запрос 400.
|
||||
// Лукап offer (КП) смотрит на FileLeafRef библиотеки — SP не отдаёт такой лукап через REST
|
||||
// ни $select=offerId, ни $expand (500 «типы полей установлены неправильно»), и список КП
|
||||
// всегда был пустым для всех, кроме автора в его сессии. Поэтому id файла дублируется
|
||||
// в числовое поле offerFileId при записи и читается отсюда.
|
||||
const url =
|
||||
`${this._li(LISTS.OFFERS)}` +
|
||||
`?$select=supplierId,offerId` +
|
||||
`?$select=supplierId,offerFileId` +
|
||||
`&$filter=${filter}` +
|
||||
`&$top=2000`;
|
||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||
@@ -841,7 +849,7 @@ export class CtService {
|
||||
const pairs: Array<{ supplierSpId: number; offerSpId: number }> = [];
|
||||
for (const item of data.value ?? []) {
|
||||
const supplierSpId = Number(item["supplierId"]);
|
||||
const offerSpId = Number(item["offerId"] ?? 0);
|
||||
const offerSpId = Number(item["offerFileId"] ?? 0);
|
||||
if (!offerSpId || !supplierSpId || seen.has(supplierSpId)) continue;
|
||||
seen.add(supplierSpId);
|
||||
pairs.push({ supplierSpId, offerSpId });
|
||||
@@ -1566,6 +1574,7 @@ export class CtService {
|
||||
positionId: params.positionSpId,
|
||||
supplierId: params.supplierSpId,
|
||||
offerId: params.offerSpId,
|
||||
offerFileId: params.offerSpId,
|
||||
supplierPosition: params.supplierPosition,
|
||||
unit: params.unit,
|
||||
quantity: Number(params.quantity) || 0,
|
||||
|
||||
Reference in New Issue
Block a user