@metron-labs/quote-react 0.1.45 → 0.1.47

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.
@@ -1,4 +1,7 @@
1
1
  import type { EstimatorToolId } from "../../components/estimators/registry";
2
2
  import type { FixedBasketLine } from "../../components/estimators/useFixedBasket";
3
3
  import type { EstimatorRequirement } from "./types";
4
+ /** The catalogue search for this line's fixed product, or null when the active client has none. */
5
+ export declare function fixedProductSearch(tool: EstimatorToolId, requirement: EstimatorRequirement): string | null;
6
+ /** Basket lines when every line has a fixed product, so the calculator can skip the matching step. */
4
7
  export declare function fixedBasketLines(tool: EstimatorToolId, requirements: EstimatorRequirement[]): FixedBasketLine[] | null;
@@ -1,32 +1,48 @@
1
- const productFor = (tool, requirement) => {
2
- const key = requirement.key;
3
- const products = {
4
- "brick-block": { "brick-common": "5001444", "brick-engineering-red": "5002008", "brick-engineering-blue": "5001439", block: "5003187", mortar: "6003088" },
5
- skirting: { "skirting-chamfered": "4006057", "skirting-ogee": "4006045", "skirting-pencil-round": "4006048", "skirting-torus": "4006042" },
6
- architrave: { "architrave-ogee": "4006060", "architrave-ovolo": "4006063", "architrave-pencil-round": "4006061", "architrave-square-edge": "4006062", "architrave-torus": "4006059" },
7
- plasterboard: { board: "2001035", screws: "2006040", tape: "2007043", adhesive: "2004023" },
8
- insulation: { "insulation-floor": "3002110", "insulation-wall": "3002012", "insulation-roof": "3004047", "insulation-loft": "3003046" },
9
- timber: { studs: "4001168", joists: "4001201", battens: "4001053" },
1
+ import { getQuoteConfig } from "../config";
2
+ // The exact product each calculator line orders, per client. Each value is the catalogue search
3
+ // that finds it: the ID, plus a few words where the ID alone is too short to surface it (search
4
+ // matches IDs as substrings, so "11" alone hits hundreds of products). A client or line with no
5
+ // entry falls back to catalogue search.
6
+ //
7
+ // ponytail: hand-kept table in the package, for one client. It belongs in the backend, per client
8
+ // code - when it moves, only fixedProductSearch changes (to read it from the API); every caller
9
+ // goes through that.
10
+ const FIXED_PRODUCTS = {
11
+ // FORT
12
+ "NZ4Y-ZMSQ": {
13
+ "brick-block": { "brick-common": "105499", "brick-engineering-red": "1654", "brick-engineering-blue": "1723", block: "501", mortar: "646" },
14
+ // Primed MDF, 4.4m lengths. Ogee and torus come in 119mm skirting / 69mm architrave; the other
15
+ // profiles only in 94mm and 144mm, so the nearer usable width is taken. FORT has no ovolo.
16
+ skirting: { "skirting-chamfered": "1525", "skirting-ogee": "135 primed mdf ogee", "skirting-pencil-round": "892", "skirting-torus": "1941" },
17
+ architrave: { "architrave-ogee": "134 primed mdf ogee", "architrave-ovolo": "1531", "architrave-pencil-round": "1896", "architrave-square-edge": "118 primed mdf square", "architrave-torus": "1531" },
18
+ // Board keys come from the board type; see fixedProductSearch.
19
+ plasterboard: { standard: "1647", moisture: "11 moisture plasterboard", fire: "14 fire plasterboard", sound: "1769", screws: "131160", tape: "1766", adhesive: "108439" },
20
+ insulation: { "insulation-floor": "152187", "insulation-wall": "147", "insulation-roof": "442", "insulation-loft": "152180" },
21
+ timber: { studs: "1213", joists: "208", battens: "1729" },
10
22
  fencing: {
11
- "panels-boundary-0.9": "4001268", "panels-boundary-1.2": "4001269", "panels-boundary-1.5": "4001270", "panels-boundary-1.8": "4001271",
12
- "panels-skyline-0.9": "4001264", "panels-skyline-1.2": "4001265", "panels-skyline-1.5": "4001266", "panels-skyline-1.8": "4001267",
13
- "concrete-intermediate-6": "4001290", "concrete-intermediate-8": "4001291", "concrete-end-6": "4001294", "concrete-end-8": "4001295",
14
- "timber-post-6": "4001242", "timber-post-8": "4001243", postcrete: "6003008", gate: "4001630", "gravel-boards": "4001284",
23
+ "panels-boundary-0.9": "66 lap fence panel", "panels-boundary-1.2": "1450", "panels-boundary-1.5": "1449", "panels-boundary-1.8": "1448",
24
+ "panels-skyline-1.8": "1475",
25
+ "concrete-intermediate-8": "40197", "concrete-end-8": "40198",
26
+ "timber-post-6": "141411", "timber-post-8": "1709", postcrete: "12142", gate: "1984", "gravel-boards": "40200",
15
27
  },
16
- decking: { "boards-meridian": "4001251", "boards-summit": "4001360", joists: "4001201", screws: "1001103" },
17
- paving: { "surface-council": "7003121", "surface-raj": "7003003", "surface-brindle": "4001621", "surface-charcoal": "4001622", "surface-pea": "6001004", "sub-base": "6001007", bedding: "6001012" },
18
- };
19
- if (tool === "plasterboard") {
20
- return { standard: "2001035", moisture: "2002039", fire: "2002087", sound: "2002044" }[requirement.label.split(" ")[0].toLowerCase()] ?? products[tool]?.[key] ?? null;
21
- }
22
- return products[tool]?.[key] ?? null;
28
+ decking: { "boards-summit": "24322", joists: "208", screws: "131079" },
29
+ paving: { "surface-council": "23995", "surface-raj": "12369", "surface-brindle": "304", "surface-charcoal": "296", "surface-pea": "431", "sub-base": "569", bedding: "563" },
30
+ },
23
31
  };
24
- export function fixedBasketLines(tool, requirements) {
25
- if (tool === "paint" || tool === "concrete-aggregate")
32
+ /** The catalogue search for this line's fixed product, or null when the active client has none. */
33
+ export function fixedProductSearch(tool, requirement) {
34
+ const table = FIXED_PRODUCTS[getQuoteConfig().client ?? ""]?.[tool];
35
+ if (!table)
26
36
  return null;
37
+ // The plasterboard line is keyed "board" whatever the board; its label starts with the type.
38
+ const key = tool === "plasterboard" && requirement.key === "board" ? requirement.label.split(" ")[0].toLowerCase() : requirement.key;
39
+ return table[key] ?? null;
40
+ }
41
+ /** Basket lines when every line has a fixed product, so the calculator can skip the matching step. */
42
+ export function fixedBasketLines(tool, requirements) {
27
43
  const lines = requirements.map((requirement) => {
28
- const productId = productFor(tool, requirement);
29
- return productId ? { key: requirement.key, productId, qty: requirement.qty, unit: requirement.unit } : null;
44
+ const search = fixedProductSearch(tool, requirement);
45
+ return search ? { key: requirement.key, productId: search.split(" ")[0], search, qty: requirement.qty, unit: requirement.unit } : null;
30
46
  });
31
- return lines.every(Boolean) ? lines : null;
47
+ return lines.length > 0 && lines.every(Boolean) ? lines : null;
32
48
  }
@@ -1,4 +1,5 @@
1
1
  import type { MatchCandidate } from "../../util/types";
2
+ import type { EstimatorToolId } from "../../components/estimators/registry";
2
3
  import type { EstimatorRequirement } from "./types";
3
4
  export interface ResolvedRequirement {
4
5
  requirement: EstimatorRequirement;
@@ -11,11 +12,20 @@ export interface CatalogueStatus {
11
12
  /** With an account code, prices come back net of that trade account's discounts. */
12
13
  export declare function searchCatalogue(query: string, category?: string, limit?: number, account?: string): Promise<MatchCandidate[]>;
13
14
  /**
14
- * Looks up every requirement in one go. A requirement that matches nothing comes back
15
- * with an empty candidate list rather than being dropped, so the results step can say
16
- * so instead of quietly shortening the materials list.
15
+ * The unit to ask the basket for. A bag/box/roll/pack count is already a count of the product's
16
+ * sell units - the estimator did that sum - so it is asked in the product's own unit. A catalogue
17
+ * that sells the same bag "each" would otherwise refuse the line and leave it unpriced. Only a
18
+ * product sold one item at a time qualifies: 3 bags must never become 3 tonnes or 3 x 1000.
17
19
  */
18
- export declare function resolveRequirements(requirements: EstimatorRequirement[], account?: string): Promise<ResolvedRequirement[]>;
20
+ export declare function estimateRequestUnit(unit: string, candidate: MatchCandidate | null | undefined): string;
21
+ /**
22
+ * Looks up every requirement in one go - the one place both the customer calculators and
23
+ * Metron's internal tool turn calculator lines into products. With the calculator's `tool`, a
24
+ * line the client has a fixed product for gets that product first, ahead of the search results.
25
+ * A requirement that matches nothing comes back with an empty candidate list rather than being
26
+ * dropped, so the results step can say so instead of quietly shortening the materials list.
27
+ */
28
+ export declare function resolveRequirements(requirements: EstimatorRequirement[], account?: string, tool?: EstimatorToolId): Promise<ResolvedRequirement[]>;
19
29
  /**
20
30
  * Whether this client has a catalogue at all. Merchants without one still get working
21
31
  * calculators - they just don't get the basket step.
@@ -1,5 +1,6 @@
1
1
  import { apiHeaders, readJson } from "../api/boqClient";
2
- import { apiUrl } from "../config";
2
+ import { apiUrl, getQuoteConfig } from "../config";
3
+ import { fixedProductSearch } from "./fixedProducts";
3
4
  // Estimator queries repeat constantly - the same "skirting board" search fires on every
4
5
  // recalculation - so results are memoised for the tab's lifetime. Nothing here is
5
6
  // user-specific beyond the client the headers already scope it to.
@@ -24,8 +25,9 @@ function toCandidate(item) {
24
25
  }
25
26
  /** With an account code, prices come back net of that trade account's discounts. */
26
27
  export async function searchCatalogue(query, category, limit = 12, account) {
27
- // The account is part of the key: the same search is a different price for each account.
28
- const cacheKey = `${query}||${category ?? ""}||${limit}||${account ?? ""}`;
28
+ // The client and account are part of the key: the same search is a different catalogue per
29
+ // client, and a different price per account.
30
+ const cacheKey = `${getQuoteConfig().client ?? ""}||${query}||${category ?? ""}||${limit}||${account ?? ""}`;
29
31
  const cached = cache.get(cacheKey);
30
32
  if (cached)
31
33
  return cached;
@@ -42,15 +44,39 @@ export async function searchCatalogue(query, category, limit = 12, account) {
42
44
  cache.set(cacheKey, candidates);
43
45
  return candidates;
44
46
  }
47
+ const PACK_UNITS = new Set(["bag", "box", "roll", "pack"]);
48
+ const SINGLE_ITEM_UNITS = new Set(["each", "bag", "box", "roll", "pack", "tub"]);
45
49
  /**
46
- * Looks up every requirement in one go. A requirement that matches nothing comes back
47
- * with an empty candidate list rather than being dropped, so the results step can say
48
- * so instead of quietly shortening the materials list.
50
+ * The unit to ask the basket for. A bag/box/roll/pack count is already a count of the product's
51
+ * sell units - the estimator did that sum - so it is asked in the product's own unit. A catalogue
52
+ * that sells the same bag "each" would otherwise refuse the line and leave it unpriced. Only a
53
+ * product sold one item at a time qualifies: 3 bags must never become 3 tonnes or 3 x 1000.
49
54
  */
50
- export async function resolveRequirements(requirements, account) {
55
+ export function estimateRequestUnit(unit, candidate) {
56
+ const uoi = candidate?.uoi;
57
+ const singleItem = uoi != null && SINGLE_ITEM_UNITS.has(uoi.toLowerCase()) && (candidate?.uoi_count ?? 1) === 1;
58
+ return PACK_UNITS.has(unit) && singleItem ? uoi : unit;
59
+ }
60
+ /**
61
+ * Looks up every requirement in one go - the one place both the customer calculators and
62
+ * Metron's internal tool turn calculator lines into products. With the calculator's `tool`, a
63
+ * line the client has a fixed product for gets that product first, ahead of the search results.
64
+ * A requirement that matches nothing comes back with an empty candidate list rather than being
65
+ * dropped, so the results step can say so instead of quietly shortening the materials list.
66
+ */
67
+ export async function resolveRequirements(requirements, account, tool) {
51
68
  const results = await Promise.all(requirements.map(async (requirement) => {
52
69
  try {
53
- return { requirement, candidates: await searchCatalogue(requirement.query, requirement.category, undefined, account) };
70
+ const fixedSearch = tool ? fixedProductSearch(tool, requirement) : null;
71
+ const [fixed, found] = await Promise.all([
72
+ // A failed fixed lookup leaves the line on search results rather than emptying it.
73
+ fixedSearch
74
+ ? searchCatalogue(fixedSearch, undefined, undefined, account).then((items) => items.find((item) => item.product_id === fixedSearch.split(" ")[0]), () => undefined)
75
+ : undefined,
76
+ searchCatalogue(requirement.query, requirement.category, undefined, account),
77
+ ]);
78
+ const candidates = fixed ? [fixed, ...found.filter((item) => item.product_id !== fixed.product_id)] : found;
79
+ return { requirement: { ...requirement, unit: estimateRequestUnit(requirement.unit, candidates[0]) }, candidates };
54
80
  }
55
81
  catch {
56
82
  return { requirement, candidates: [] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metron-labs/quote-react",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "Metron Labs quote and estimating calculator components for React",
5
5
  "license": "UNLICENSED",
6
6
  "sideEffects": [