feat(ct): подсветка несоответствия ед. изм. поставщика и заявки на шаге 3

Несоответствие единиц было видно только по незаметной «*» и только при включённом тумблере.
Теперь «Ед. изм.» поставщика подсвечивается жёлтым, если отличается от заявки (подсказка «В
заявке: кг, у поставщика: т»). С включённым тумблером единица, кол-во и цены подсвечиваются
голубым, если пересчитаны в ед. заявки, и красным, если пересчитать нельзя (шт ↔ кг). Статус —
чистая функция getUnitStatus; у тумблера — легенда цветов вместо «*».
This commit is contained in:
Тимур Городецкий
2026-10-01 16:45:10 +05:00
parent fa92cb4e2e
commit fa6d88f2ab
7 changed files with 125 additions and 15 deletions
@@ -3,7 +3,8 @@ import { IconButton, TextInput } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain'; import { CurrencyRate } from '../types/domain';
import { PositionRow } from './matrixTypes'; import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix'; import { Step3Matrix } from './useStep3Matrix';
import { convertQuantity, unitsEquivalent } from '../utils/unitConversion'; import { getUnitStatus } from '../utils/unitConversion';
import { unitStatusClass } from './unitStatusStyles';
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey } from './matrixHelpers'; import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss'; import styles from './Step3ComparisonTable.module.scss';
@@ -67,8 +68,8 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
// Перевод в ед. изм. заявки (тумблер) — как в обычных позициях. // Перевод в ед. изм. заявки (тумблер) — как в обычных позициях.
const reqUnit = pos.unit; const reqUnit = pos.unit;
const supUnit = item?.unit || pos.unit; const supUnit = item?.unit || pos.unit;
const tRatio = translateUnits && !unitsEquivalent(supUnit, reqUnit) ? convertQuantity(1, supUnit, reqUnit) : null; const unitStatus = getUnitStatus(supUnit, reqUnit, translateUnits);
const cannotTranslate = translateUnits && !unitsEquivalent(supUnit, reqUnit) && tRatio === null; const tRatio = unitStatus.ratio ?? null;
const dispUnit = tRatio !== null ? reqUnit : (item?.unit || pos.unit); const dispUnit = tRatio !== null ? reqUnit : (item?.unit || pos.unit);
const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty; const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty;
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price; const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
@@ -91,7 +92,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
<span className={styles.computedValue}>{item?.supplierProductName || pos.materialName}</span> <span className={styles.computedValue}>{item?.supplierProductName || pos.materialName}</span>
)} )}
</td> </td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}> <td className={[styles.dataCell, unitStatusClass(unitStatus.status), selCls].filter(Boolean).join(' ')} title={unitStatus.hint}>
{showInput ? ( {showInput ? (
<div className={styles.offerInput}> <div className={styles.offerInput}>
<TextInput <TextInput
@@ -102,7 +103,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
/> />
</div> </div>
) : ( ) : (
<span className={styles.computedValue}>{(dispUnit || '—') + (cannotTranslate ? ' *' : '')}</span> <span className={styles.computedValue}>{dispUnit || '—'}</span>
)} )}
</td> </td>
<td className={[styles.dataCell, ep?.isDelivery ? styles.deliveryQtyCell : '', selCls].filter(Boolean).join(' ')}> <td className={[styles.dataCell, ep?.isDelivery ? styles.deliveryQtyCell : '', selCls].filter(Boolean).join(' ')}>
@@ -3,7 +3,8 @@ import { TextInput, Icon } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain'; import { CurrencyRate } from '../types/domain';
import { PositionRow } from './matrixTypes'; import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix'; import { Step3Matrix } from './useStep3Matrix';
import { convertQuantity, unitsEquivalent } from '../utils/unitConversion'; 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 } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss'; import styles from './Step3ComparisonTable.module.scss';
@@ -85,8 +86,12 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
// null — единицы совпадают или пересчёт невозможен. // null — единицы совпадают или пересчёт невозможен.
const reqUnit = pos.unit; const reqUnit = pos.unit;
const supUnit = item.unit || pos.unit; const supUnit = item.unit || pos.unit;
const tRatio = translateUnits && !unitsEquivalent(supUnit, reqUnit) ? convertQuantity(1, supUnit, reqUnit) : null; const unitStatus = getUnitStatus(supUnit, reqUnit, translateUnits);
const cannotTranslate = translateUnits && !unitsEquivalent(supUnit, reqUnit) && tRatio === null; const tRatio = unitStatus.ratio ?? null;
// Подсветка: «Ед. изм.» — при любом несоответствии заявке; кол-во и цены — когда тумблер включён
// (они показаны пересчитанными или их нельзя сравнивать напрямую).
const unitCls = unitStatusClass(unitStatus.status);
const valueCls = translateUnits ? unitCls : '';
const dispUnit = tRatio !== null ? reqUnit : (item.unit || pos.unit); const dispUnit = tRatio !== null ? reqUnit : (item.unit || pos.unit);
const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty; const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty;
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price; const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
@@ -123,7 +128,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
)} )}
</div> </div>
</td> </td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}> <td className={[styles.dataCell, unitCls, selCls].filter(Boolean).join(' ')} title={unitStatus.hint}>
{showInput ? ( {showInput ? (
<div className={styles.offerInput}> <div className={styles.offerInput}>
<TextInput <TextInput
@@ -134,10 +139,10 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
/> />
</div> </div>
) : ( ) : (
<span className={styles.computedValue}>{(dispUnit || '—') + (cannotTranslate ? ' *' : '')}</span> <span className={styles.computedValue}>{dispUnit || '—'}</span>
)} )}
</td> </td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}> <td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')} title={translateUnits ? unitStatus.hint : undefined}>
{showInput ? ( {showInput ? (
<div className={styles.offerInput}> <div className={styles.offerInput}>
<TextInput <TextInput
@@ -154,7 +159,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
</td> </td>
{!isRestrictedView && isForeign && ( {!isRestrictedView && isForeign && (
<> <>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}> <td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
{showInput ? ( {showInput ? (
<div className={styles.offerInput}> <div className={styles.offerInput}>
<TextInput <TextInput
@@ -174,7 +179,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
</td> </td>
</> </>
)} )}
{!isRestrictedView && <td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}> {!isRestrictedView && <td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
{showInput ? ( {showInput ? (
<div className={styles.offerInput}> <div className={styles.offerInput}>
<TextInput <TextInput
@@ -493,6 +493,35 @@
color: $muted; color: $muted;
} }
// Подсветка ед. изм. поставщика (unitStatusStyles): !important — иначе фон перебивает подсветка строки
// при наведении и выбранной колонки, и несоответствие пропадает из виду.
.dataCell.unitMismatch {
background: $warn-bg !important;
color: $warn-fg;
}
.dataCell.unitConverted {
background: $accent-bg !important;
}
.dataCell.unitImpossible {
background: $danger-bg !important;
color: $danger;
}
.translateLegendConverted,
.translateLegendImpossible {
padding: 0 4px;
border-radius: 2px;
}
.translateLegendConverted { background: $accent-bg; }
.translateLegendImpossible {
background: $danger-bg;
color: $danger;
}
.selectedTotalCell { .selectedTotalCell {
background: $accent-bg !important; background: $accent-bg !important;
@@ -142,7 +142,9 @@ export const Step3ComparisonTable: React.FC<Props> = ({
</label> </label>
{translateUnits && ( {translateUnits && (
<span className={styles.translateHint}> <span className={styles.translateHint}>
кол-во и цена показаны в ед. изм. заявки; «*» — пересчёт невозможен (ред. недоступно) кол-во и цена — в ед. изм. заявки (ред. недоступно):{' '}
<span className={styles.translateLegendConverted}>пересчитано</span>,{' '}
<span className={styles.translateLegendImpossible}>пересчёт невозможен</span>
</span> </span>
)} )}
<div className={styles.adataCheckAllGroup}> <div className={styles.adataCheckAllGroup}>
@@ -0,0 +1,15 @@
import { UnitStatusKind } from '../utils/unitConversion';
import styles from './Step3ComparisonTable.module.scss';
// Подсветка ячеек по статусу ед. изм. поставщика (см. getUnitStatus): жёлтым — единица отличается
// от заявки, голубым — пересчитано в ед. заявки, красным — пересчитать нельзя.
const UNIT_STATUS_CLASS: Record<UnitStatusKind, string> = {
same: '',
mismatch: styles.unitMismatch,
converted: styles.unitConverted,
impossible: styles.unitImpossible,
};
export function unitStatusClass(status: UnitStatusKind): string {
return UNIT_STATUS_CLASS[status];
}
@@ -1,4 +1,4 @@
import { convertQuantity, normalizeUnit, unitsEquivalent } from './unitConversion'; import { convertQuantity, getUnitStatus, normalizeUnit, unitsEquivalent } from './unitConversion';
describe('unitsEquivalent — синонимы одной единицы', () => { describe('unitsEquivalent — синонимы одной единицы', () => {
// СТ №12: в заявке «Штука», у поставщика «шт.» — тумблер помечал «шт. *» (пересчёт невозможен). // СТ №12: в заявке «Штука», у поставщика «шт.» — тумблер помечал «шт. *» (пересчёт невозможен).
@@ -62,3 +62,30 @@ describe('normalizeUnit', () => {
expect(normalizeUnit(' шт. ')).toBe(normalizeUnit('Штука')); expect(normalizeUnit(' шт. ')).toBe(normalizeUnit('Штука'));
}); });
}); });
describe('getUnitStatus — подсветка несоответствия ед. изм. (п.6)', () => {
it('одинаковые единицы — ничего не подсвечиваем', () => {
expect(getUnitStatus('шт.', 'Штука', false)).toEqual({ status: 'same' });
expect(getUnitStatus('шт.', 'Штука', true)).toEqual({ status: 'same' });
});
// Тумблер выключен: несовпадение видно сразу, а не только по незаметной «*» при включённом тумблере.
it('тумблер выключен — разные единицы: несоответствие с подсказкой', () => {
expect(getUnitStatus('т', 'кг', false)).toEqual({ status: 'mismatch', hint: 'В заявке: кг, у поставщика: т' });
});
it('тумблер включён — пересчитали: коэффициент и подсказка', () => {
expect(getUnitStatus('т', 'кг', true)).toEqual({ status: 'converted', ratio: 1000, hint: 'Пересчитано из т в кг' });
});
it('тумблер включён — пересчитать нельзя: подсказка сравнивать вручную', () => {
expect(getUnitStatus('шт', 'кг', true)).toEqual({
status: 'impossible',
hint: 'Нельзя перевести шт → кг: сравнивайте вручную',
});
});
it('пустая единица поставщика — не несоответствие', () => {
expect(getUnitStatus('', 'кг', false)).toEqual({ status: 'same' });
});
});
@@ -96,3 +96,34 @@ export function convertQuantity(qty: number, fromUnit: string | undefined, toUni
if (!from || !to || from.dim !== to.dim) return null; if (!from || !to || from.dim !== to.dim) return null;
return qty * (from.toBase / to.toBase); return qty * (from.toBase / to.toBase);
} }
// Статус ед. изм. поставщика относительно заявки — для подсветки ячеек на шаге 3:
// same — единицы совпадают (или у поставщика не указана) — без подсветки;
// mismatch — тумблер выключен, единицы разные — показать, что сравнивать надо внимательно;
// converted — тумблер включён, пересчитали в ед. заявки (ratio — коэффициент кол-ва);
// impossible — тумблер включён, пересчитать нельзя (шт ↔ кг, шт ↔ компл).
export type UnitStatusKind = 'same' | 'mismatch' | 'converted' | 'impossible';
export interface UnitStatus {
status: UnitStatusKind;
ratio?: number;
hint?: string;
}
export function getUnitStatus(
supplierUnit: string | undefined,
requestUnit: string | undefined,
translateUnits: boolean,
): UnitStatus {
const supplier = (supplierUnit ?? '').trim();
const request = (requestUnit ?? '').trim();
if (!supplier || !request || unitsEquivalent(supplier, request)) return { status: 'same' };
if (!translateUnits) {
return { status: 'mismatch', hint: `В заявке: ${request}, у поставщика: ${supplier}` };
}
const ratio = convertQuantity(1, supplier, request);
if (ratio === null) {
return { status: 'impossible', hint: `Нельзя перевести ${supplier} → ${request}: сравнивайте вручную` };
}
return { status: 'converted', ratio, hint: `Пересчитано из ${supplier} в ${request}` };
}