From 90759fc4bccb894d71920960c860d16d90895374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Thu, 1 Oct 2026 17:44:07 +0500 Subject: [PATCH] =?UTF-8?q?fix(ct):=20=D0=BF=D0=BE=D0=B4=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=BC=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B9=20=D1=81=D1=83=D0=BC=D0=BC=D1=8B=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=B0=D0=B6=D0=B4=D0=BE=D0=B9=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B7=D0=B8=D1=86=D0=B8=D0=B8,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BC=20=D0=B8=D1=82=D0=BE=D0=B3=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Минимум по позиции считался, но подсветку получали только строка «Итого по позиции» у комплекта и строки доп. расходов — обычная ячейка «Сумма, ₸» не подсвечивалась никогда, и зелёный был виден только в общем итоге. Итог комплекта к тому же пересчитывался отдельно (без override цены в ₸ и кол-ва позиции по умолчанию) и сравнивался строгим ===, поэтому минимум терялся на копейках. Теперь суммы «поставщик × позиция» — один источник (sumPositionTotals), сравнение с допуском (isMinPositionTotal), минимальная сумма зелёная в каждой позиции. --- .../Step3ComparisonTable/ExtraPositionRow.tsx | 4 +-- .../Step3ComparisonTable/MatrixRowView.tsx | 20 ++++++----- .../matrixHelpers.test.ts | 34 ++++++++++++++++++- .../Step3ComparisonTable/matrixHelpers.ts | 20 +++++++++++ .../Step3ComparisonTable/useStep3Matrix.ts | 11 +++--- 5 files changed, 74 insertions(+), 15 deletions(-) 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,