fix(ct): подсветка минимальной суммы в каждой позиции, а не только в общем итоге
Минимум по позиции считался, но подсветку получали только строка «Итого по позиции» у комплекта и строки доп. расходов — обычная ячейка «Сумма, ₸» не подсвечивалась никогда, и зелёный был виден только в общем итоге. Итог комплекта к тому же пересчитывался отдельно (без override цены в ₸ и кол-ва позиции по умолчанию) и сравнивался строгим ===, поэтому минимум терялся на копейках. Теперь суммы «поставщик × позиция» — один источник (sumPositionTotals), сравнение с допуском (isMinPositionTotal), минимальная сумма зелёная в каждой позиции.
This commit is contained in:
+2
-2
@@ -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 : '';
|
||||
|
||||
|
||||
+12
-8
@@ -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
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
</td>}
|
||||
</React.Fragment>
|
||||
@@ -219,15 +224,14 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
||||
<tr key="subtotal" className={styles.bundleSubtotalRow}>
|
||||
{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 (
|
||||
<React.Fragment key={s.id}>
|
||||
<td colSpan={isRestrictedView ? subCols : subCols - 1} className={[styles.bundleSubtotalCell, selCls].filter(Boolean).join(' ')}>
|
||||
|
||||
+33
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,3 +96,23 @@ export function applyCreatedIds<T extends { id: string }>(
|
||||
): 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;
|
||||
}
|
||||
|
||||
+7
-4
@@ -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<string, number> => {
|
||||
const map = new Map<string, number>();
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user