fix(ct): подсветка минимальной суммы в каждой позиции, а не только в общем итоге

Минимум по позиции считался, но подсветку получали только строка «Итого по позиции» у комплекта
и строки доп. расходов — обычная ячейка «Сумма, ₸» не подсвечивалась никогда, и зелёный был виден
только в общем итоге. Итог комплекта к тому же пересчитывался отдельно (без override цены в ₸ и
кол-ва позиции по умолчанию) и сравнивался строгим ===, поэтому минимум терялся на копейках.
Теперь суммы «поставщик × позиция» — один источник (sumPositionTotals), сравнение с допуском
(isMinPositionTotal), минимальная сумма зелёная в каждой позиции.
This commit is contained in:
Тимур Городецкий
2026-10-01 17:44:07 +05:00
parent 2593021a70
commit 90759fc4bc
5 changed files with 74 additions and 15 deletions
@@ -5,7 +5,7 @@ import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix'; import { Step3Matrix } from './useStep3Matrix';
import { getUnitStatus } from '../utils/unitConversion'; import { getUnitStatus } from '../utils/unitConversion';
import { unitStatusClass } from './unitStatusStyles'; 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'; import styles from './Step3ComparisonTable.module.scss';
interface Props { interface Props {
@@ -61,7 +61,7 @@ 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 : '';
@@ -5,7 +5,7 @@ import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix'; import { Step3Matrix } from './useStep3Matrix';
import { getUnitStatus } from '../utils/unitConversion'; import { getUnitStatus } from '../utils/unitConversion';
import { unitStatusClass } from './unitStatusStyles'; 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'; import styles from './Step3ComparisonTable.module.scss';
interface Props { interface Props {
@@ -21,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;
@@ -203,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>
@@ -219,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(' ')}>
@@ -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 { ExtraPosition } from './matrixTypes';
import { SupplierOffer } from '../types/domain';
describe('computeOffer', () => { describe('computeOffer', () => {
it('считает сумму в тенге для КП в тенге', () => { it('считает сумму в тенге для КП в тенге', () => {
@@ -100,3 +101,34 @@ describe('applyCreatedIds', () => {
expect(extras[0].spId).toBeUndefined(); 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);
});
});
@@ -96,3 +96,23 @@ export function applyCreatedIds<T extends { id: string }>(
): T[] { ): T[] {
return items.map(item => (createdById[item.id] ? { ...item, ...createdById[item.id] } : item)); 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;
}
@@ -4,7 +4,7 @@ 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 { applyCreatedIds, 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 { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers'; import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
@@ -378,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) => {
@@ -899,6 +901,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
extraPositionsRows, extraPositionsRows,
supplierCurrencies, supplierCurrencies,
computedOffers, computedOffers,
positionTotals,
minTotalByPosition, minTotalByPosition,
// handlers // handlers
updateEdit, updateEdit,