diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ExtraPositionRow.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ExtraPositionRow.tsx index c8105b9..d20c55f 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ExtraPositionRow.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ExtraPositionRow.tsx @@ -5,7 +5,7 @@ import { PositionRow } from './matrixTypes'; import { Step3Matrix } from './useStep3Matrix'; import { getUnitStatus } from '../utils/unitConversion'; import { unitStatusClass } from './unitStatusStyles'; -import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey } from './matrixHelpers'; +import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers'; import styles from './Step3ComparisonTable.module.scss'; interface Props { @@ -61,7 +61,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix, const priceKzt = (price ?? 0) * rate; const priceKztEffective = item?.priceKztOverride !== undefined ? item.priceKztOverride : priceKzt; 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 selCls = isSel ? styles.colSelected : ''; diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/MatrixRowView.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/MatrixRowView.tsx index 8759a65..260872e 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/MatrixRowView.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/MatrixRowView.tsx @@ -5,7 +5,7 @@ import { PositionRow } from './matrixTypes'; import { Step3Matrix } from './useStep3Matrix'; import { getUnitStatus } from '../utils/unitConversion'; import { unitStatusClass } from './unitStatusStyles'; -import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey } from './matrixHelpers'; +import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers'; import styles from './Step3ComparisonTable.module.scss'; interface Props { @@ -21,7 +21,7 @@ interface Props { export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrictedView }: Props): JSX.Element { const { isReadOnly, edits, translateUnits, rawValues, posQtyOverrides, selectedSupplierId, nameColWidth, - suppliersWithOffer, supplierCurrencies, minTotalByPosition, + suppliersWithOffer, supplierCurrencies, minTotalByPosition, positionTotals, updatePosQty, updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt, removeBundleItem, addBundleItem, } = matrix; @@ -203,7 +203,12 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict )} } - {!isRestrictedView && + {!isRestrictedView && {kztTotal > 0 ? formatKzt(kztTotal) : '—'} } @@ -219,15 +224,14 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict {suppliersWithOffer.map(s => { const key = buildOfferKey(s.id, pos.id); - const items = edits[key] ?? []; const currency = supplierCurrencies[s.id] ?? 'KZT'; - const isForeign = currency !== 'KZT'; const isSel = s.id === selectedSupplierId; const selCls = isSel ? styles.colSelected : ''; const subCols = getSubCols(currency, isRestrictedView); - const rate = isForeign ? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) : 1; - const totalKzt = items.reduce((sum, it) => sum + (it.pricePerUnit ?? 0) * rate * (it.quantity ?? 0), 0); - const isMin = minTotal !== undefined && totalKzt === minTotal && totalKzt > 0; + // Из того же источника, что и минимум: отдельный пересчёт (без override цены в ₸ и без кол-ва + // позиции по умолчанию) расходился на копейки, и строгое сравнение не находило минимум. + const totalKzt = positionTotals.get(key) ?? 0; + const isMin = isMinPositionTotal(totalKzt, minTotal); return ( diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.test.ts index 2ab285d..b220481 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.test.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.test.ts @@ -1,5 +1,6 @@ -import { applyCreatedIds, buildOfferKey, computeOffer, getSubCols, round2Str } from './matrixHelpers'; +import { applyCreatedIds, buildOfferKey, computeOffer, getSubCols, isMinPositionTotal, round2Str, sumPositionTotals } from './matrixHelpers'; import { ExtraPosition } from './matrixTypes'; +import { SupplierOffer } from '../types/domain'; describe('computeOffer', () => { it('считает сумму в тенге для КП в тенге', () => { @@ -100,3 +101,34 @@ describe('applyCreatedIds', () => { 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); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.ts index e6acad9..210cdfd 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.ts @@ -96,3 +96,23 @@ export function applyCreatedIds( ): 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 { + const totals = new Map(); + 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; +} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts index db391d5..6ee108a 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts @@ -4,7 +4,7 @@ import { SupplierOffer, CurrencyRate, FormStep } from '../types/domain'; import { CtService } from '../../services/CtService'; import { CTFormData } from '../hooks/useCtForm'; import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes'; -import { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers'; +import { applyCreatedIds, buildOfferKey, computeOffer, sumPositionTotals } from './matrixHelpers'; import { IVendorRecord } from '../../services/VendorDatabaseService'; import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns'; import { getNoOfferSuppliers } from '../utils/noOfferSuppliers'; @@ -378,17 +378,19 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) { React.useEffect(() => { computedOffersRef.current = computedOffers; }, [computedOffers]); + // Суммы по (поставщик, позиция) — один источник и для минимума, и для подсветки ячеек (п.13). + const positionTotals = useMemo(() => sumPositionTotals(computedOffers), [computedOffers]); + const minTotalByPosition = useMemo((): Map => { const map = new Map(); allPositions.forEach(pos => { const values = suppliersWithOffer - .map(s => computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id) - .reduce((sum, o) => sum + (o.totalWithExtraKzt ?? 0), 0)) + .map(s => positionTotals.get(buildOfferKey(s.id, pos.id)) ?? 0) .filter(v => v > 0); if (values.length > 0) map.set(pos.id, Math.min(...values)); }); return map; - }, [computedOffers, allPositions, suppliersWithOffer]); + }, [positionTotals, allPositions, suppliersWithOffer]); const updateEdit = useCallback( (supplierId: string, positionId: string, rowIdx: number, field: 'pricePerUnit' | 'quantity', value: string) => { @@ -899,6 +901,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) { extraPositionsRows, supplierCurrencies, computedOffers, + positionTotals, minTotalByPosition, // handlers updateEdit,