@metron-labs/quote-react 0.1.74 → 0.1.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ export interface PricingBreakdown {
11
11
  calculation: string;
12
12
  costingMode: "direct" | "derived" | "review";
13
13
  }
14
+ export declare const needsQuantityConfirmation: (item: CartItem, pricing: PricingBreakdown) => boolean;
14
15
  export declare function getCartSubtotal(items: CartItem[], options?: Parameters<typeof getPricingBreakdown>[1]): number | null;
15
16
  export declare function getPricingBreakdown(item: CartItem, options?: Parameters<typeof calculateBreakdown>[1]): PricingBreakdown;
16
17
  declare function calculateBreakdown(item: CartItem, options?: {
@@ -5,10 +5,25 @@ const unitAliases = {
5
5
  "m²": "m2",
6
6
  cbm: "m3",
7
7
  "cu m": "m3",
8
+ "m³": "m3",
8
9
  lm: "m",
9
10
  "lin m": "m",
11
+ "linear metre": "m",
12
+ "linear metres": "m",
13
+ "linear meter": "m",
14
+ "linear meters": "m",
10
15
  metre: "m",
16
+ metres: "m",
11
17
  meter: "m",
18
+ meters: "m",
19
+ litre: "l",
20
+ litres: "l",
21
+ liter: "l",
22
+ liters: "l",
23
+ ltr: "l",
24
+ ltrs: "l",
25
+ rolls: "roll",
26
+ coils: "coil",
12
27
  each: "each",
13
28
  ea: "each",
14
29
  nr: "each",
@@ -29,6 +44,7 @@ const unitAliases = {
29
44
  };
30
45
  const discreteSellUnits = new Set(["sheet", "length", "box", "bag", "pack", "roll", "set", "tub", "each"]);
31
46
  const packUnits = new Set(["box", "bag", "pack", "pallet", "roll", "set", "tub"]);
47
+ const rollCountUnits = new Set(["roll", "coil"]);
32
48
  const volumeInMl = (amount, unit) => {
33
49
  if (unit === "ml")
34
50
  return amount;
@@ -49,6 +65,39 @@ const canonicalUnit = (value) => {
49
65
  const normalized = normalize(value);
50
66
  return unitAliases[normalized] ?? normalized;
51
67
  };
68
+ const unitScale = (value) => {
69
+ const unit = canonicalUnit(value);
70
+ if (unit === "ml")
71
+ return { dimension: "volume", toBase: 1 };
72
+ if (unit === "l")
73
+ return { dimension: "volume", toBase: 1000 };
74
+ if (unit === "m3")
75
+ return { dimension: "volume", toBase: 1000000 };
76
+ if (unit === "g")
77
+ return { dimension: "mass", toBase: 1 };
78
+ if (unit === "kg")
79
+ return { dimension: "mass", toBase: 1000 };
80
+ if (unit === "t" || unit === "tonne" || unit === "tonnes")
81
+ return { dimension: "mass", toBase: 1000000 };
82
+ if (unit === "mm")
83
+ return { dimension: "length", toBase: 0.001 };
84
+ if (unit === "cm")
85
+ return { dimension: "length", toBase: 0.01 };
86
+ if (unit === "m")
87
+ return { dimension: "length", toBase: 1 };
88
+ if (unit === "ft")
89
+ return { dimension: "length", toBase: 0.3048 };
90
+ if (unit === "in" || unit === "inch" || unit === "inches")
91
+ return { dimension: "length", toBase: 0.0254 };
92
+ return null;
93
+ };
94
+ const convertUnitAmount = (amount, from, to) => {
95
+ if (sameUnit(from, to))
96
+ return amount;
97
+ const source = unitScale(from);
98
+ const target = unitScale(to);
99
+ return source && target && source.dimension === target.dimension ? (amount * source.toBase) / target.toBase : null;
100
+ };
52
101
  const sameUnit = (left, right) => {
53
102
  const a = canonicalUnit(left);
54
103
  const b = canonicalUnit(right);
@@ -79,6 +128,8 @@ const supportsRequestedUnit = (item, requestedUnit) => {
79
128
  if (supported.length === 0 || supported.includes(requestedUnit)) {
80
129
  return true;
81
130
  }
131
+ if (item.unitConversions?.some((entry) => convertUnitAmount(1, requestedUnit, entry.requestUom) != null))
132
+ return true;
82
133
  if (sameUnit(requestedUnit, "pallet") && item.itemsPerPallet != null && item.itemsPerPallet > 0) {
83
134
  return true;
84
135
  }
@@ -131,6 +182,8 @@ const coverageFromDescription = (item, requestedUnit, sellUnit) => {
131
182
  const coverage = Number.parseFloat(match[1].replace(",", "."));
132
183
  return Number.isFinite(coverage) && coverage > 0 ? coverage : null;
133
184
  };
185
+ export const needsQuantityConfirmation = (item, pricing) => pricing.costingMode === "review" &&
186
+ !(item.conversionConfidence === "llm_basket_line" && item.sellUnitsOverride != null && !item.conversionNotes?.trim());
134
187
  export function getCartSubtotal(items, options) {
135
188
  const totals = items.map((item) => getPricingBreakdown(item, options).lineTotal);
136
189
  return totals.some((total) => total == null) ? null : totals.reduce((sum, total) => sum + (total ?? 0), 0);
@@ -219,6 +272,32 @@ function calculateBreakdown(item, options) {
219
272
  costingMode: "direct",
220
273
  };
221
274
  }
275
+ const singleFormRequest = rollCountUnits.has(requestedUnit);
276
+ const explicitMultiFormPack = /\b(?:pack|box)\s+of\s+(\d+)\s+(?:rolls?|coils?)\b|\b(\d+)\s+(?:rolls?|coils?)\s+(?:per|in each)\s+(?:pack|box)\b/i.test(item.description);
277
+ const itemsInSalePack = sellUnit === "pack" ? item.itemsPerPack : null;
278
+ const hasAreaOrLengthCoverage = (item.derivedLengthM ?? 0) > 0 ||
279
+ (item.derivedCoverageM2 ?? 0) > 0 ||
280
+ /^(?:m2|m²|sqm|m)\s*(?:per|\/)/i.test(item.coverageUom ?? "");
281
+ if (singleFormRequest &&
282
+ ["each", "pack"].includes(sellUnit) &&
283
+ salesUnitSize === 1 &&
284
+ !(itemsInSalePack != null && itemsInSalePack > 1) &&
285
+ !explicitMultiFormPack &&
286
+ hasAreaOrLengthCoverage) {
287
+ const required = hasOverride ? overriddenSellUnits : roundQuantity(requestedAmount * (1 + wasteFactor), item.roundingRule ?? "round_up_each");
288
+ return {
289
+ requestedAmount,
290
+ requestedUnit,
291
+ salesUnitSize,
292
+ sellUnit,
293
+ calculatedSellUnits: required,
294
+ requiredSellUnits: required,
295
+ unitPrice: item.price,
296
+ lineTotal: item.price != null ? item.price * required : null,
297
+ calculation: `${requestedAmount} ${requestedUnit}${wasteFactor > 0 ? ` x ${1 + wasteFactor}` : ""} = ${required} ${sellUnit} (one product per sale unit)`,
298
+ costingMode: "derived",
299
+ };
300
+ }
222
301
  if (!supportsRequestedUnit(item, requestedUnit)) {
223
302
  return review("Requested unit is not supported by this catalogue conversion.");
224
303
  }
@@ -228,7 +307,8 @@ function calculateBreakdown(item, options) {
228
307
  let calculation = `Quantity review required, initial request needed ${requestedAmount} ${requestedUnit}.`;
229
308
  const wasteMultiplier = 1 + wasteFactor;
230
309
  const descriptionCoverage = coverageFromDescription(item, requestedUnit, sellUnit);
231
- const conversion = item.unitConversions?.find((entry) => sameUnit(entry.requestUom, requestedUnit));
310
+ const conversion = item.unitConversions?.find((entry) => convertUnitAmount(1, requestedUnit, entry.requestUom) != null);
311
+ const conversionRequestAmount = conversion ? convertUnitAmount(requestedAmount, requestedUnit, conversion.requestUom) : null;
232
312
  const hasStructuredRoute = !!item.saleUnit && (sameUnit(requestedUnit, sellUnit) || !!conversion || (sameUnit(requestedUnit, "each") && !packUnits.has(sellUnit)));
233
313
  // A partial structured conversion must not hide a valid legacy/LLM conversion.
234
314
  if (hasStructuredRoute) {
@@ -237,8 +317,8 @@ function calculateBreakdown(item, options) {
237
317
  costingMode = "direct";
238
318
  calculation = `${requestedAmount} ${requestedUnit}${wasteFactor > 0 ? ` x ${wasteMultiplier}` : ""}`;
239
319
  }
240
- else if (conversion) {
241
- rawSellUnits = (requestedAmount * wasteMultiplier) / conversion.quantityPerSaleUnit;
320
+ else if (conversion && conversionRequestAmount != null) {
321
+ rawSellUnits = (conversionRequestAmount * wasteMultiplier) / conversion.quantityPerSaleUnit;
242
322
  costingMode = "derived";
243
323
  // A product sold individually can legitimately be requested by pallet. The
244
324
  // canonical conversion is pallets per each (for example 1/72), but present
@@ -246,9 +326,12 @@ function calculateBreakdown(item, options) {
246
326
  const itemsPerRequestedUnit = 1 / conversion.quantityPerSaleUnit;
247
327
  const roundedItemsPerRequestedUnit = Math.round(itemsPerRequestedUnit);
248
328
  const isWholePackRelationship = sameUnit(sellUnit, "each") && conversion.quantityPerSaleUnit < 1 && Math.abs(itemsPerRequestedUnit - roundedItemsPerRequestedUnit) < 1e-9;
329
+ const conversionBasis = sameUnit(requestedUnit, conversion.requestUom)
330
+ ? `${requestedAmount} ${requestedUnit}`
331
+ : `${requestedAmount} ${requestedUnit} (${conversionRequestAmount} ${conversion.requestUom})`;
249
332
  calculation = isWholePackRelationship
250
333
  ? `${requestedAmount} ${requestedUnit}${wasteFactor > 0 ? ` x ${wasteMultiplier}` : ""} x ${roundedItemsPerRequestedUnit} per ${requestedUnit}`
251
- : `${requestedAmount} ${requestedUnit}${wasteFactor > 0 ? ` x ${wasteMultiplier}` : ""} / ${conversion.quantityPerSaleUnit} ${requestedUnit} per ${sellUnit}`;
334
+ : `${conversionBasis}${wasteFactor > 0 ? ` x ${wasteMultiplier}` : ""} / ${conversion.quantityPerSaleUnit} ${conversion.requestUom} per ${sellUnit}`;
252
335
  }
253
336
  else if (sameUnit(requestedUnit, "each") && !packUnits.has(sellUnit)) {
254
337
  rawSellUnits = (requestedAmount * wasteMultiplier) / salesUnitSize;
package/package.json CHANGED
@@ -1,53 +1,53 @@
1
- {
2
- "name": "@metron-labs/quote-react",
3
- "version": "0.1.74",
4
- "description": "Metron Labs quote and estimating calculator components for React",
5
- "license": "UNLICENSED",
6
- "sideEffects": [
7
- "*.css"
8
- ],
9
- "main": "./dist/index.js",
10
- "types": "./dist/index.d.ts",
11
- "exports": {
12
- ".": {
13
- "types": "./dist/index.d.ts",
14
- "default": "./dist/index.js"
15
- },
16
- "./styles.css": "./dist/styles.css",
17
- "./*": {
18
- "types": "./dist/*.d.ts",
19
- "default": "./dist/*.js"
20
- }
21
- },
22
- "files": [
23
- "dist"
24
- ],
25
- "publishConfig": {
26
- "access": "restricted"
27
- },
28
- "scripts": {
29
- "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node scripts/copyStatic.mjs && node scripts/buildEmbed.mjs",
30
- "typecheck": "tsc --noEmit",
31
- "test": "vitest run",
32
- "prepublishOnly": "npm run typecheck && npm run build",
33
- "check:embed": "node scripts/checkEmbed.mjs"
34
- },
35
- "peerDependencies": {
36
- "react": "^18.3.0 || ^19.0.0",
37
- "react-dom": "^18.3.0 || ^19.0.0"
38
- },
39
- "dependencies": {
40
- "xlsx": "^0.18.5"
41
- },
42
- "devDependencies": {
43
- "@types/node": "^20",
44
- "@types/react": "^19",
45
- "@types/react-dom": "^19",
46
- "esbuild": "^0.28.2",
47
- "react": "19.2.3",
48
- "react-dom": "19.2.3",
49
- "tsx": "^4",
50
- "typescript": "^5",
51
- "vitest": "^3"
52
- }
53
- }
1
+ {
2
+ "name": "@metron-labs/quote-react",
3
+ "version": "0.1.76",
4
+ "description": "Metron Labs quote and estimating calculator components for React",
5
+ "license": "UNLICENSED",
6
+ "sideEffects": [
7
+ "*.css"
8
+ ],
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./styles.css": "./dist/styles.css",
17
+ "./*": {
18
+ "types": "./dist/*.d.ts",
19
+ "default": "./dist/*.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "restricted"
27
+ },
28
+ "scripts": {
29
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node scripts/copyStatic.mjs && node scripts/buildEmbed.mjs",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "prepublishOnly": "npm run typecheck && npm run build",
33
+ "check:embed": "node scripts/checkEmbed.mjs"
34
+ },
35
+ "peerDependencies": {
36
+ "react": "^18.3.0 || ^19.0.0",
37
+ "react-dom": "^18.3.0 || ^19.0.0"
38
+ },
39
+ "dependencies": {
40
+ "xlsx": "^0.18.5"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20",
44
+ "@types/react": "^19",
45
+ "@types/react-dom": "^19",
46
+ "esbuild": "^0.28.2",
47
+ "react": "19.2.3",
48
+ "react-dom": "19.2.3",
49
+ "tsx": "^4",
50
+ "typescript": "^5",
51
+ "vitest": "^3"
52
+ }
53
+ }