/** * Product dimensions (M_Product.Length / Width / Height) are ALWAYS millimetres. * * The warehouse-rent cron (../logship-scripts … rent_volume/calculate_volume.py) multiplies * L×W×H×qty on hand into billed m³ with a plain /1000 per side — there is no unit guessing any * more. A value typed in cm or m therefore bills 1 000× / 10⁹× too little, which is exactly what * happened for wonderhome in 2026-10 (24.4 × 38.1 × 23.9 "cm" → 0.00002 m³ per unit). * * `analyzeDimensions` is the single plausibility check used by the product form, the product * grid (inline edit) and the storage-usage BI page so every entry surface warns the same way. */ export const DIMENSION_UNIT = 'mm' /** Per-unit volume outside this window is almost certainly a unit mistake. */ export const IMPLAUSIBLE_MIN_M3 = 0.000001 // 1 cm³ export const IMPLAUSIBLE_MAX_M3 = 1 // 1 m³ for a single unit export type DimensionStatus = | 'empty' // nothing entered yet | 'missing' // one or two sides missing → not billed at all | 'metres' // all sides < 1 → looks like metres (0.32 × 0.14 × 0.1) | 'centimetres' // all sides < 100 with a decimal, or max < 100 → looks like cm | 'mixed' // one side < 10 while another ≥ 100 (430 × 364 × 4) | 'too-small' // per-unit volume < 1 cm³ | 'too-large' // per-unit volume > 1 m³ | 'ok' export interface DimensionInput { length?: any; width?: any; height?: any } export interface DimensionAnalysis { status: DimensionStatus ok: boolean /** true for every status that deserves a warning (everything except ok/empty) */ warn: boolean length: number width: number height: number /** per-unit volume in m³ (0 when a side is missing) */ volumeM3: number /** per-unit volume in litres (= dm³) */ litres: number /** suggested correction (already in mm), when the entry looks like cm / m */ suggestion: { length: number; width: number; height: number; factor: number } | null /** short English explanation for the UI */ message: string } const toNumber = (v: any): number => { if (v === null || v === undefined || v === '') return 0 const n = typeof v === 'number' ? v : parseFloat(String(v).replace(',', '.')) return Number.isFinite(n) ? n : 0 } const round = (n: number, digits: number) => { const f = Math.pow(10, digits) return Math.round(n * f) / f } export const formatVolume = (m3: number): string => { if (!m3) return '0 m³' if (m3 >= 0.01) return `${round(m3, 4)} m³` return `${round(m3 * 1000, 3)} l` } export const analyzeDimensions = (input: DimensionInput): DimensionAnalysis => { const length = toNumber(input.length) const width = toNumber(input.width) const height = toNumber(input.height) const sides = [length, width, height] const entered = sides.filter(s => s > 0) const volumeM3 = length > 0 && width > 0 && height > 0 ? (length * width * height) / 1e9 : 0 const base = { length, width, height, volumeM3, litres: volumeM3 * 1000, suggestion: null as DimensionAnalysis['suggestion'] } if (entered.length === 0) { return { ...base, status: 'empty', ok: false, warn: false, message: 'Enter length, width and height in millimetres (mm).' } } if (entered.length < 3) { return { ...base, status: 'missing', ok: false, warn: true, message: 'One or more sides are missing — the product is stored with 0 m³ and is not billed for storage.' } } const max = Math.max(...entered) const min = Math.min(...entered) const hasDecimal = entered.some(s => !Number.isInteger(s)) const suggest = (factor: number) => ({ length: round(length * factor, 1), width: round(width * factor, 1), height: round(height * factor, 1), factor }) if (max < 1) { return { ...base, status: 'metres', ok: false, warn: true, suggestion: suggest(1000), message: `All sides are below 1 — this looks like metres. In mm this would be ${length * 1000} × ${width * 1000} × ${height * 1000}.` } } if (max < 100 && (hasDecimal || max < 50)) { return { ...base, status: 'centimetres', ok: false, warn: true, suggestion: suggest(10), message: `All sides are below 100 mm (${length} × ${width} × ${height}) — this looks like centimetres. In mm this would be ${round(length * 10, 1)} × ${round(width * 10, 1)} × ${round(height * 10, 1)}.` } } if (min < 10 && max >= 100) { return { ...base, status: 'mixed', ok: false, warn: true, suggestion: null, message: `One side is below 10 mm while another is ${max} mm — mixed units? A side of ${min} mm is unusual; check that every side is in millimetres.` } } if (volumeM3 < IMPLAUSIBLE_MIN_M3) { return { ...base, status: 'too-small', ok: false, warn: true, suggestion: suggest(10), message: `Per-unit volume is below 1 cm³ (${length} × ${width} × ${height} mm) — check the unit, dimensions must be in mm.` } } if (volumeM3 > IMPLAUSIBLE_MAX_M3) { return { ...base, status: 'too-large', ok: false, warn: true, suggestion: null, message: `Per-unit volume is ${round(volumeM3, 2)} m³ — more than one cubic metre for a single unit. Check the unit (mm) or whether these are pallet dimensions.` } } return { ...base, status: 'ok', ok: true, warn: false, message: `Volume per unit: ${formatVolume(volumeM3)}.` } }