/** * The one parcel sent for an order: quotes, the Book check and the booking all use it. * Weight: the parcel entered in Packaging, else the product weights, where each product without * one counts as the default weight per unit (as in the Shopify app). * Size: the parcel entered in Packaging, else a box estimated from the product sizes (see * estimateItemsSize), else the default package, else 30×20×15 cm. * Matches OrdersController::build_quote_payload_from_order, so the first quotes match the booking. * All values are in the store's units. */ import type { Order, ParcelDimensions, PluginSettings } from '../../types'; import { parseNum } from './parse'; import { cmToStoreUnit, kgToStoreUnit } from './unitBounds'; /** Parcel size when there are no product sizes and no default package: a typical small box. */ export const FALLBACK_PARCEL_CM = { length: 30, width: 20, height: 15 }; const FALLBACK_WEIGHT_KG = 1; export type ParcelWeightSource = 'packaging' | 'products' | 'default'; export interface ResolvedParcel extends ParcelDimensions { /** 'default' when at least one product has no weight and the default weight stands in for it. */ weightSource: ParcelWeightSource; /** Names of the order items whose product has no weight. */ missingWeightItems: string[]; /** Weight per unit used for each product without one. */ defaultWeight: number; } type ParcelSettings = Pick; function roundWeight(value: number): number { // Enough to hide float noise without a second visible rounding when displayed to 2 places. return Number(value.toFixed(6)); } /** * Box that fits the order items, from their product sizes (store units). * Each item's sides are sorted, so it does not matter which way round they were entered. The items * lie flat and are stacked: the box takes the longest and the widest of their sides, and is as tall * as every item's shortest side × quantity added up. That never undercounts, as a volume estimate * can when one item fills the footprint and another must go on top. * Items without all three sides are left out. Null when no item has a size. * Matches OrdersController::estimate_items_size. */ export function estimateItemsSize(order: Order): Omit | null { let length = 0; let width = 0; let height = 0; for (const item of order.items ?? []) { const quantity = Math.max(0, item.quantity); const sides = [item.dimensions?.length, item.dimensions?.width, item.dimensions?.height] .map((side) => parseNum(side) ?? 0) .sort((a, b) => b - a); if (quantity === 0 || sides[2] <= 0) { continue; } length = Math.max(length, sides[0]); width = Math.max(width, sides[1]); height += sides[2] * quantity; } if (height <= 0) { return null; } // Drop float noise from adding up decimal sides, e.g. 0.1 + 0.2. return { length, width, height: Number(height.toFixed(6)) }; } function defaultItemWeight(order: Order, settings: ParcelSettings | null): number { const configured = settings?.overrides?.defaultWeightKg ?? 0; return configured > 0 ? configured : kgToStoreUnit(FALLBACK_WEIGHT_KG, order.units?.weight ?? 'kg'); } /** * Weight per unit of each order item: its product weight, or the default weight when it has none. * Used for the customs contents; an entered parcel weight does not change them. */ export function itemUnitWeights(order: Order, settings: ParcelSettings | null): number[] { const fallback = defaultItemWeight(order, settings); return (order.items ?? []).map((item) => { const weight = parseNum(item.dimensions?.weight) ?? 0; return weight > 0 ? weight : fallback; }); } export function resolveParcel(order: Order, settings: ParcelSettings | null): ResolvedParcel { const dimensionUnit = order.units?.dimension ?? 'cm'; const defaultWeight = defaultItemWeight(order, settings); const unitWeights = itemUnitWeights(order, settings); let productWeight = 0; const missingWeightItems: string[] = []; (order.items ?? []).forEach((item, index) => { productWeight += unitWeights[index] * Math.max(0, item.quantity); if (!((parseNum(item.dimensions?.weight) ?? 0) > 0)) { missingWeightItems.push(item.name); } }); const itemsSize = estimateItemsSize(order); const defaultPackage = settings?.packages?.find((pkg) => pkg.id === settings.defaultPackageId); const entered = order.parcel; const dimension = ( enteredValue: number | undefined, itemsValue: number | undefined, packageValue: number | undefined, fallbackCm: number ) => enteredValue && enteredValue > 0 ? enteredValue : itemsValue || packageValue || cmToStoreUnit(fallbackCm, dimensionUnit); let weight: number; let weightSource: ParcelWeightSource; if (entered && entered.weight > 0) { weight = entered.weight; weightSource = 'packaging'; } else if (productWeight > 0) { weight = roundWeight(productWeight); weightSource = missingWeightItems.length > 0 ? 'default' : 'products'; } else { // No items, or every quantity is 0. weight = defaultWeight; weightSource = 'default'; } return { length: dimension(entered?.length, itemsSize?.length, defaultPackage?.length, FALLBACK_PARCEL_CM.length), width: dimension(entered?.width, itemsSize?.width, defaultPackage?.width, FALLBACK_PARCEL_CM.width), height: dimension(entered?.height, itemsSize?.height, defaultPackage?.height, FALLBACK_PARCEL_CM.height), weight, weightSource, missingWeightItems, defaultWeight, }; }