fix(ct): «Перевести в ед. изм. заявки» понимает синонимы и штучные единицы

Тумблер знал только массу, длину, площадь и объём в одном написании. «Штука» в заявке и «шт.»
в КП считались разными незнакомыми единицами — тумблер помечал их «пересчёт невозможен», и
казалось, что он ничего не делает. Теперь единица приводится к канонической через словарь
синонимов (шт/Штука/ед, кг/Килограмм, л/Литр, кв.м./м², пог.м/м.п. и т.д.), добавлены штучные
единицы (компл, упак, пара, рулон, лист, мешок — между собой не пересчитываются). Заодно
«кв.м.» с точкой на конце снова распознаётся: точку срезали после поиска синонимов.
This commit is contained in:
Тимур Городецкий
2026-10-01 16:38:51 +05:00
parent 64a377276c
commit fa92cb4e2e
2 changed files with 116 additions and 29 deletions
@@ -0,0 +1,64 @@
import { convertQuantity, normalizeUnit, unitsEquivalent } from './unitConversion';
describe('unitsEquivalent — синонимы одной единицы', () => {
// СТ №12: в заявке «Штука», у поставщика «шт.» — тумблер помечал «шт. *» (пересчёт невозможен).
it('«Штука» = «шт.» = «шт» = «ед.»', () => {
expect(unitsEquivalent('Штука', 'шт.')).toBe(true);
expect(unitsEquivalent('шт', 'ед.')).toBe(true);
expect(unitsEquivalent('Штук', 'ШТ')).toBe(true);
});
it('полные и краткие названия: килограмм, литр, тонна, метр', () => {
expect(unitsEquivalent('Килограмм', 'кг')).toBe(true);
expect(unitsEquivalent('Литр', 'л.')).toBe(true);
expect(unitsEquivalent('Тонна', 'т')).toBe(true);
expect(unitsEquivalent('Метр', 'м.')).toBe(true);
});
it('квадратные, кубические и погонные метры в разных написаниях', () => {
expect(unitsEquivalent('кв.м.', 'м²')).toBe(true);
expect(unitsEquivalent('куб.м.', 'м3')).toBe(true);
expect(unitsEquivalent('пог.м', 'м.п.')).toBe(true);
});
it('комплект, упаковка, пара — свои синонимы', () => {
expect(unitsEquivalent('Комплект', 'компл.')).toBe(true);
expect(unitsEquivalent('Упаковка', 'уп.')).toBe(true);
expect(unitsEquivalent('пара', 'пар')).toBe(true);
});
it('разные единицы не равны', () => {
expect(unitsEquivalent('шт', 'компл')).toBe(false);
expect(unitsEquivalent('кг', 'т')).toBe(false);
});
});
describe('convertQuantity — пересчёт в ед. изм. заявки', () => {
it('тонны в килограммы', () => {
expect(convertQuantity(0.5, 'т', 'кг')).toBe(500);
expect(convertQuantity(1, 'Тонна', 'Килограмм')).toBe(1000);
});
it('миллиметры в метры, литры в кубометры', () => {
expect(convertQuantity(1500, 'мм', 'м')).toBeCloseTo(1.5);
expect(convertQuantity(1000, 'л', 'куб.м.')).toBeCloseTo(1);
});
// Штуки в комплекты без кратности не перевести — честно говорим «невозможно».
it('штучные единицы между собой и с массой не пересчитываются', () => {
expect(convertQuantity(1, 'шт', 'компл')).toBeNull();
expect(convertQuantity(1, 'шт', 'кг')).toBeNull();
});
it('незнакомая единица — пересчёт невозможен', () => {
expect(convertQuantity(1, 'бухта', 'м')).toBeNull();
});
});
describe('normalizeUnit', () => {
// Раньше точку на конце срезали ПОСЛЕ синонимов, и «кв.м.» не распознавалось.
it('точки и пробелы не мешают распознать единицу', () => {
expect(normalizeUnit('кв.м.')).toBe(normalizeUnit('м2'));
expect(normalizeUnit(' шт. ')).toBe(normalizeUnit('Штука'));
});
});
@@ -3,61 +3,84 @@
// поставщика из ед. КП в ед. изм. позиции заявки.
// Несовместимые размерности (напр. кг ↔ м³ — нужна плотность) не пересчитываются.
// Штучные единицы — каждая своя «размерность»: шт ↔ компл без кратности не перевести.
type Dimension = 'mass' | 'length' | 'area' | 'volume' | 'piece' | 'set' | 'pack' | 'pair' | 'roll' | 'sheet' | 'bag';
interface UnitDef {
dim: 'mass' | 'length' | 'area' | 'volume';
dim: Dimension;
toBase: number; // множитель к базовой единице размерности
}
// Базовые единицы: масса — кг, длина — м, площадь — м², объём — л.
// Канонические единицы. Базовые: масса — кг, длина — м, площадь — м², объём — л, штучные — сама единица.
const UNIT_TABLE: Record<string, UnitDef> = {
// масса (база — кг)
'мг': { dim: 'mass', toBase: 0.000001 },
'г': { dim: 'mass', toBase: 0.001 },
'гр': { dim: 'mass', toBase: 0.001 },
'грамм': { dim: 'mass', toBase: 0.001 },
'кг': { dim: 'mass', toBase: 1 },
'килограмм': { dim: 'mass', toBase: 1 },
'ц': { dim: 'mass', toBase: 100 },
'центнер': { dim: 'mass', toBase: 100 },
'т': { dim: 'mass', toBase: 1000 },
'тн': { dim: 'mass', toBase: 1000 },
'тонна': { dim: 'mass', toBase: 1000 },
'тонн': { dim: 'mass', toBase: 1000 },
// длина (база — м)
'мм': { dim: 'length', toBase: 0.001 },
'см': { dim: 'length', toBase: 0.01 },
'дм': { dim: 'length', toBase: 0.1 },
'м': { dim: 'length', toBase: 1 },
'метр': { dim: 'length', toBase: 1 },
'пм': { dim: 'length', toBase: 1 },
'км': { dim: 'length', toBase: 1000 },
// площадь (база — м²)
'мм2': { dim: 'area', toBase: 0.000001 },
'см2': { dim: 'area', toBase: 0.0001 },
'дм2': { dim: 'area', toBase: 0.01 },
'м2': { dim: 'area', toBase: 1 },
// объём (база — л)
'мл': { dim: 'volume', toBase: 0.001 },
'см3': { dim: 'volume', toBase: 0.001 },
'л': { dim: 'volume', toBase: 1 },
'дм3': { dim: 'volume', toBase: 1 },
'м3': { dim: 'volume', toBase: 1000 },
'шт': { dim: 'piece', toBase: 1 },
'компл': { dim: 'set', toBase: 1 },
'упак': { dim: 'pack', toBase: 1 },
'пара': { dim: 'pair', toBase: 1 },
'рулон': { dim: 'roll', toBase: 1 },
'лист': { dim: 'sheet', toBase: 1 },
'мешок': { dim: 'bag', toBase: 1 },
};
// Приводим написание единицы к ключу таблицы: нижний регистр, без пробелов,
// ³/² → 3/2, частые синонимы (куб.м → м3, кв.м → м2, пог.м → пм), без хвостовых точек.
export function normalizeUnit(raw: string | undefined): string {
let u = (raw ?? '').toLowerCase().trim();
u = u.replace(/³/g, '3').replace(/²/g, '2');
u = u.replace(/\s+/g, '');
u = u.replace(/^куб\.?м3?$/, 'м3').replace(/^кубм$/, 'м3');
u = u.replace(/^кв\.?м2?$/, 'м2').replace(/^квм$/, 'м2');
u = u.replace(/^пог\.?м$/, 'пм');
u = u.replace(/\.+$/, '');
return u;
// Синонимы → каноническая единица. Ключи — уже без пробелов, точек и дефисов, в нижнем регистре
// (см. cleanUnit): «Штука», «шт.», «кв.м.», «м.п.», «к-т» и т.п. В заявках и КП пишут по-разному —
// без словаря «Штука» и «шт.» считались разными единицами, и тумблер помечал их «пересчёт невозможен».
const UNIT_ALIASES: Record<string, string> = {
'миллиграмм': 'мг',
'гр': 'г', 'грамм': 'г', 'граммов': 'г',
'килограмм': 'кг', 'килограммов': 'кг', 'килограмма': 'кг',
'центнер': 'ц',
'тн': 'т', 'тонна': 'т', 'тонн': 'т', 'тонны': 'т',
'миллиметр': 'мм', 'сантиметр': 'см', 'дециметр': 'дм', 'километр': 'км',
'метр': 'м', 'метров': 'м', 'метра': 'м',
'пм': 'м', 'мп': 'м', 'погм': 'м', 'метрпогонный': 'м', 'погонныйметр': 'м',
'квм': 'м2', 'квм2': 'м2', 'квадратныйметр': 'м2', 'метрквадратный': 'м2',
'кубм': 'м3', 'кубм3': 'м3', 'куб': 'м3', 'кубометр': 'м3', 'кубическийметр': 'м3', 'метркубический': 'м3',
'см3': 'мл', 'дм3': 'л',
'миллилитр': 'мл', 'литр': 'л', 'литров': 'л', 'литра': 'л',
'штука': 'шт', 'штук': 'шт', 'штуки': 'шт', 'ед': 'шт', 'единица': 'шт', 'единиц': 'шт', 'pcs': 'шт',
'комплект': 'компл', 'комплектов': 'компл', 'кмпл': 'компл', 'кт': 'компл',
'упаковка': 'упак', 'упаковок': 'упак', 'уп': 'упак',
'пар': 'пара', 'пары': 'пара',
'рул': 'рулон', 'рулонов': 'рулон',
'листов': 'лист', 'листа': 'лист',
'меш': 'мешок', 'мешков': 'мешок',
};
// Нижний регистр, ³/² → 3/2, без пробелов, точек и дефисов: «кв. м.» → «квм», «к-т» → «кт».
function cleanUnit(raw: string | undefined): string {
return (raw ?? '')
.toLowerCase()
.replace(/³/g, '3')
.replace(/²/g, '2')
.replace(/[\s.-]+/g, '');
}
// true, если единицы по сути одинаковые (тн == т == Тонна).
// Приводим написание единицы к канонической (ключ UNIT_TABLE). Незнакомую возвращаем очищенной —
// так одинаково написанные незнакомые единицы всё равно считаются равными.
export function normalizeUnit(raw: string | undefined): string {
const cleaned = cleanUnit(raw);
return UNIT_ALIASES[cleaned] ?? cleaned;
}
// true, если единицы по сути одинаковые (тн == т == Тонна, шт. == Штука).
export function unitsEquivalent(a: string | undefined, b: string | undefined): boolean {
return normalizeUnit(a) === normalizeUnit(b);
}