punchout-simulator 0.6.0 → 0.7.0

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.
package/README.md CHANGED
@@ -18,10 +18,12 @@ It is role-neutral and runs in two modes (mirror images of each other):
18
18
 
19
19
  Protocol scope: **cXML only**.
20
20
 
21
- > **New to PunchOut, or want the full picture?** See the in-depth reports in
22
- > [`docs/`](docs/): the [PunchOut business primer](docs/punchout-business-primer_en.html),
23
- > the [Architecture reference](docs/architecture_en.html), and the
24
- > [Operations & usage guide](docs/operations-guide_en.html).
21
+ > **New to PunchOut, or want the full picture?** Read the in-depth reports at
22
+ > [**slawomir-szostak.github.io/punchout-simulator**](https://slawomir-szostak.github.io/punchout-simulator/):
23
+ > the [PunchOut business primer](https://slawomir-szostak.github.io/punchout-simulator/punchout-business-primer_en.html),
24
+ > the [Architecture reference](https://slawomir-szostak.github.io/punchout-simulator/architecture_en.html),
25
+ > and the [Operations & usage guide](https://slawomir-szostak.github.io/punchout-simulator/operations-guide_en.html).
26
+ > (Markdown sources live in [`docs/`](docs/).)
25
27
 
26
28
  ---
27
29
 
@@ -206,12 +208,13 @@ src/
206
208
 
207
209
  For a deeper treatment — component breakdown, the full data model, and Mode A /
208
210
  Mode B sequence diagrams — see the
209
- [**Architecture reference**](docs/architecture_en.html) in [`docs/`](docs/).
210
- The same folder holds a [**PunchOut business primer**](docs/punchout-business-primer_en.html)
211
+ [**Architecture reference**](https://slawomir-szostak.github.io/punchout-simulator/architecture_en.html).
212
+ Alongside it: a [**PunchOut business primer**](https://slawomir-szostak.github.io/punchout-simulator/punchout-business-primer_en.html)
211
213
  (the business process and where this tool fits) and an
212
- [**Operations & usage guide**](docs/operations-guide_en.html) (sessions,
213
- operations, profiles, product lists, validation). The reports are self-contained
214
- HTML; their canonical Markdown sources sit alongside them.
214
+ [**Operations & usage guide**](https://slawomir-szostak.github.io/punchout-simulator/operations-guide_en.html)
215
+ (sessions, operations, profiles, product lists, validation). These render at
216
+ [slawomir-szostak.github.io/punchout-simulator](https://slawomir-szostak.github.io/punchout-simulator/);
217
+ their canonical Markdown sources live in [`docs/`](docs/).
215
218
 
216
219
  ### Data model
217
220
 
@@ -1620,31 +1620,39 @@ function collectCidReferences(doc) {
1620
1620
  });
1621
1621
  return refs;
1622
1622
  }
1623
+ function itemFromNode(it) {
1624
+ const detail = it?.ItemDetail;
1625
+ const up = money(detail?.UnitPrice);
1626
+ const classifications = asArray(detail?.Classification).filter((c) => c != null).map((c) => ({ domain: attr(c, "domain") ?? "", value: text(c) ?? "" }));
1627
+ const classFirst = classifications[0];
1628
+ return {
1629
+ quantity: Number(attr(it, "quantity") ?? "1") || 1,
1630
+ supplierPartId: text(it?.ItemID?.SupplierPartID),
1631
+ supplierPartAuxiliaryId: text(it?.ItemID?.SupplierPartAuxiliaryID),
1632
+ description: text(detail?.Description),
1633
+ uom: text(detail?.UnitOfMeasure),
1634
+ unitPriceAmount: up.amount,
1635
+ currency: up.currency,
1636
+ classifications: classifications.length > 0 ? classifications : void 0,
1637
+ classificationDomain: classFirst?.domain,
1638
+ classification: classFirst?.value,
1639
+ manufacturerPartId: text(detail?.ManufacturerPartID),
1640
+ manufacturerName: text(detail?.ManufacturerName)
1641
+ };
1642
+ }
1643
+ function parseSetupItems(doc) {
1644
+ const sr = root(doc)?.Request?.PunchOutSetupRequest;
1645
+ return {
1646
+ operation: attr(sr, "operation") ?? "create",
1647
+ items: asArray(sr?.ItemOut).map(itemFromNode)
1648
+ };
1649
+ }
1623
1650
  function parseCart(doc) {
1624
1651
  const pom = root(doc)?.Message?.PunchOutOrderMessage;
1625
1652
  const sessionId = text(pom?.BuyerCookie) ?? "";
1626
1653
  const headerNode = pom?.PunchOutOrderMessageHeader;
1627
1654
  const total = money(headerNode?.Total);
1628
- const items = asArray(pom?.ItemIn).map((it) => {
1629
- const detail = it?.ItemDetail;
1630
- const up = money(detail?.UnitPrice);
1631
- const classifications = asArray(detail?.Classification).filter((c) => c != null).map((c) => ({ domain: attr(c, "domain") ?? "", value: text(c) ?? "" }));
1632
- const classFirst = classifications[0];
1633
- return {
1634
- quantity: Number(attr(it, "quantity") ?? "1") || 1,
1635
- supplierPartId: text(it?.ItemID?.SupplierPartID),
1636
- supplierPartAuxiliaryId: text(it?.ItemID?.SupplierPartAuxiliaryID),
1637
- description: text(detail?.Description),
1638
- uom: text(detail?.UnitOfMeasure),
1639
- unitPriceAmount: up.amount,
1640
- currency: up.currency,
1641
- classifications: classifications.length > 0 ? classifications : void 0,
1642
- classificationDomain: classFirst?.domain,
1643
- classification: classFirst?.value,
1644
- manufacturerPartId: text(detail?.ManufacturerPartID),
1645
- manufacturerName: text(detail?.ManufacturerName)
1646
- };
1647
- });
1655
+ const items = asArray(pom?.ItemIn).map(itemFromNode);
1648
1656
  return {
1649
1657
  sessionId,
1650
1658
  operationAllowed: attr(headerNode, "operationAllowed"),
@@ -2522,6 +2530,20 @@ var catalogOf = (s) => {
2522
2530
  const items = catalogForSupplier(s);
2523
2531
  return items.length > 0 ? items : DEMO_CATALOG;
2524
2532
  };
2533
+ function setupContextFor(cookie) {
2534
+ if (!cookie) return { operation: "create", items: [] };
2535
+ const records = readSession(cookie);
2536
+ for (let i = records.length - 1; i >= 0; i--) {
2537
+ const r = records[i];
2538
+ if (r.direction === "in" && r.docType === "SetupRequest") {
2539
+ return parseSetupItems(parseXml(r.body));
2540
+ }
2541
+ }
2542
+ return { operation: "create", items: [] };
2543
+ }
2544
+ function sameProduct(a, b) {
2545
+ return !!a.supplierPartId && a.supplierPartId === b.supplierPartId && (a.supplierPartAuxiliaryId ?? "") === (b.supplierPartAuxiliaryId ?? "");
2546
+ }
2525
2547
  function safeHttpUrl(u) {
2526
2548
  if (!u) return "";
2527
2549
  try {
@@ -2590,26 +2612,46 @@ simRoute.post("/:id/punchout", async (c) => {
2590
2612
  c.header("Content-Type", "text/xml; charset=UTF-8");
2591
2613
  return c.body(respXml);
2592
2614
  });
2593
- simRoute.get("/:id/catalog", (c) => {
2594
- const supplier = getSupplier(c.req.param("id"));
2595
- if (!supplier) return c.text("supplier not found", 404);
2596
- const cookie = c.req.query("cookie") ?? "";
2597
- const formpost = safeHttpUrl(c.req.query("formpost") ?? "");
2598
- const bd = c.req.query("bd") ?? "";
2599
- const bi = c.req.query("bi") ?? "";
2600
- const items = catalogOf(supplier);
2601
- const rows = items.map((it, i) => {
2602
- const partId = escapeXml(it.supplierPartId) + (it.supplierPartAuxiliaryId ? ` / ${escapeXml(it.supplierPartAuxiliaryId)}` : "");
2603
- const cls = (it.classifications ?? []).map((c2) => `${escapeXml(c2.domain)} ${escapeXml(c2.value)}`).join(" \xB7 ");
2604
- return `<tr>
2605
- <td><strong>${escapeXml(it.description)}</strong><br><small>${partId} \xB7 ${escapeXml(it.uom)}${cls ? ` \xB7 ${cls}` : ""}</small></td>
2606
- <td class="price">${escapeXml(it.currency)} ${it.unitPrice.toFixed(2)}</td>
2607
- <td><input type="number" name="q_${i}" value="0" min="0" step="${it.allowFractional ? "any" : "1"}" inputmode="${it.allowFractional ? "decimal" : "numeric"}"></td>
2615
+ var catalogView = (it) => ({
2616
+ description: it.description,
2617
+ supplierPartId: it.supplierPartId,
2618
+ supplierPartAuxiliaryId: it.supplierPartAuxiliaryId,
2619
+ uom: it.uom,
2620
+ currency: it.currency,
2621
+ price: it.unitPrice,
2622
+ classifications: it.classifications
2623
+ });
2624
+ var cartView = (it) => ({
2625
+ description: it.description,
2626
+ supplierPartId: it.supplierPartId,
2627
+ supplierPartAuxiliaryId: it.supplierPartAuxiliaryId,
2628
+ uom: it.uom,
2629
+ currency: it.currency,
2630
+ price: it.unitPriceAmount,
2631
+ classifications: it.classifications
2632
+ });
2633
+ function rowLabelCells(v) {
2634
+ const partId = escapeXml(v.supplierPartId ?? "") + (v.supplierPartAuxiliaryId ? ` / ${escapeXml(v.supplierPartAuxiliaryId)}` : "");
2635
+ const cls = (v.classifications ?? []).map((c) => `${escapeXml(c.domain)} ${escapeXml(c.value)}`).join(" \xB7 ");
2636
+ return `<td><strong>${escapeXml(v.description ?? "")}</strong><br><small>${partId} \xB7 ${escapeXml(v.uom ?? "")}${cls ? ` \xB7 ${cls}` : ""}</small></td>
2637
+ <td class="price">${escapeXml(v.currency ?? "")} ${(v.price ?? 0).toFixed(2)}</td>`;
2638
+ }
2639
+ function qtyRow(v, name, value, fractional, tag) {
2640
+ return `<tr>
2641
+ ${rowLabelCells(v)}
2642
+ <td><input type="number" name="${name}" value="${escapeXml(value)}" min="0" step="${fractional ? "any" : "1"}" inputmode="${fractional ? "decimal" : "numeric"}">${tag ? ` <span class="tag">${tag}</span>` : ""}</td>
2608
2643
  </tr>`;
2609
- }).join("\n");
2610
- return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8">
2644
+ }
2645
+ function readonlyRow(v, qty) {
2646
+ return `<tr>
2647
+ ${rowLabelCells(v)}
2648
+ <td class="ro">${escapeXml(qty)}</td>
2649
+ </tr>`;
2650
+ }
2651
+ function catalogPage(o) {
2652
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
2611
2653
  <meta name="viewport" content="width=device-width, initial-scale=1">
2612
- <title>${escapeXml(supplier.name)} \u2014 mock catalog</title>
2654
+ <title>${escapeXml(o.supplier.name)} \u2014 mock catalog</title>
2613
2655
  <script>
2614
2656
  // Match the main app's theme. The app appends ?theme= to the catalog link
2615
2657
  // (works even when the SPA is on a different origin, e.g. the Vite dev server);
@@ -2627,31 +2669,61 @@ simRoute.get("/:id/catalog", (c) => {
2627
2669
  })();
2628
2670
  </script>
2629
2671
  <style>
2630
- :root{--bg:#0f172a;--text:#e2e8f0;--muted:#94a3b8;--panel:#1e293b;--th:#0b1220;--border:#334155;--field:#0b1220;--price:#fbbf24;--accent:#6366f1;--accent-hover:#4f46e5}
2631
- :root[data-theme="light"]{--bg:#f5f7fb;--text:#1e293b;--muted:#4b5a73;--panel:#ffffff;--th:#eef1f7;--border:#d4dae8;--field:#ffffff;--price:#b45309;--accent:#6366f1;--accent-hover:#4f46e5}
2672
+ :root{--bg:#0f172a;--text:#e2e8f0;--muted:#94a3b8;--panel:#1e293b;--th:#0b1220;--border:#334155;--field:#0b1220;--price:#fbbf24;--accent:#6366f1;--accent-hover:#4f46e5;--banner:#1e293b}
2673
+ :root[data-theme="light"]{--bg:#f5f7fb;--text:#1e293b;--muted:#4b5a73;--panel:#ffffff;--th:#eef1f7;--border:#d4dae8;--field:#ffffff;--price:#b45309;--accent:#6366f1;--accent-hover:#4f46e5;--banner:#eef1f7}
2632
2674
  body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--text);margin:0;padding:2rem}
2633
2675
  .wrap{max-width:720px;margin:0 auto}
2634
2676
  h1{font-size:1.4rem}.sub{color:var(--muted);margin-bottom:1.5rem}
2677
+ .banner{background:var(--banner);border:1px solid var(--border);border-left:4px solid var(--accent);border-radius:8px;padding:.7rem 1rem;margin-bottom:1.25rem;font-size:.95rem}
2678
+ .banner.inspect{border-left-color:var(--price)}
2635
2679
  table{width:100%;border-collapse:collapse;background:var(--panel);border-radius:12px;overflow:hidden}
2636
2680
  th,td{padding:.75rem 1rem;text-align:left;border-bottom:1px solid var(--border)}
2637
2681
  th{background:var(--th);font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}
2638
2682
  .price{white-space:nowrap;color:var(--price)}
2683
+ .ro{color:var(--muted)}
2684
+ .tag{font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-left:.4rem}
2639
2685
  input[type=number]{width:5rem;background:var(--field);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:.35rem .5rem}
2640
2686
  button{margin-top:1.5rem;background:var(--accent);color:#fff;border:0;border-radius:8px;padding:.7rem 1.4rem;font-size:1rem;cursor:pointer}
2641
2687
  button:hover{background:var(--accent-hover)}
2642
2688
  </style></head><body><div class="wrap">
2643
- <h1>${escapeXml(supplier.name)} <small>(virtual supplier)</small></h1>
2689
+ <h1>${escapeXml(o.supplier.name)} <small>(virtual supplier)</small></h1>
2644
2690
  <div class="sub">Mock catalog served by punchout-simulator. Set quantities and return the cart.</div>
2645
- <form method="post" action="${getPublicUrl()}/sim/${supplier.id}/checkout">
2646
- <input type="hidden" name="cookie" value="${escapeXml(cookie)}">
2647
- <input type="hidden" name="formpost" value="${escapeXml(formpost)}">
2648
- <input type="hidden" name="bd" value="${escapeXml(bd)}">
2649
- <input type="hidden" name="bi" value="${escapeXml(bi)}">
2691
+ ${o.banner}
2692
+ <form method="post" action="${getPublicUrl()}/sim/${o.supplier.id}/checkout">
2693
+ <input type="hidden" name="cookie" value="${escapeXml(o.cookie)}">
2694
+ <input type="hidden" name="formpost" value="${escapeXml(o.formpost)}">
2695
+ <input type="hidden" name="bd" value="${escapeXml(o.bd)}">
2696
+ <input type="hidden" name="bi" value="${escapeXml(o.bi)}">
2650
2697
  <table><thead><tr><th>Item</th><th>Price</th><th>Qty</th></tr></thead>
2651
- <tbody>${rows}</tbody></table>
2652
- <button type="submit">Return cart to buyer \u2192</button>
2698
+ <tbody>${o.rows}</tbody></table>
2699
+ <button type="submit">${escapeXml(o.submitLabel)}</button>
2653
2700
  </form>
2654
- </div></body></html>`);
2701
+ </div></body></html>`;
2702
+ }
2703
+ simRoute.get("/:id/catalog", (c) => {
2704
+ const supplier = getSupplier(c.req.param("id"));
2705
+ if (!supplier) return c.text("supplier not found", 404);
2706
+ const cookie = c.req.query("cookie") ?? "";
2707
+ const formpost = safeHttpUrl(c.req.query("formpost") ?? "");
2708
+ const bd = c.req.query("bd") ?? "";
2709
+ const bi = c.req.query("bi") ?? "";
2710
+ const items = catalogOf(supplier);
2711
+ const ctx = setupContextFor(cookie);
2712
+ const page = (banner2, rows2, submitLabel) => c.html(catalogPage({ supplier, banner: banner2, rows: rows2, submitLabel, cookie, formpost, bd, bi }));
2713
+ if (ctx.operation === "inspect" && ctx.items.length > 0) {
2714
+ const rows2 = ctx.items.map((it) => readonlyRow(cartView(it), it.quantity)).join("\n");
2715
+ const banner2 = `<div class="banner inspect">Inspect \u2014 ${ctx.items.length} item(s), read-only. Returning the cart unchanged to the buyer.</div>`;
2716
+ return page(banner2, rows2, "Return to buyer \u2192");
2717
+ }
2718
+ const isEdit = ctx.operation === "edit";
2719
+ const prefill = (it) => isEdit ? ctx.items.find((p) => sameProduct(p, it))?.quantity ?? 0 : 0;
2720
+ const catalogRows = items.map((it, i) => qtyRow(catalogView(it), `q_${i}`, prefill(it), !!it.allowFractional)).join("\n");
2721
+ const extras = isEdit ? ctx.items.filter((p) => !items.some((it) => sameProduct(p, it))) : [];
2722
+ const extraRows = extras.map((p, j) => qtyRow(cartView(p), `qx_${j}`, p.quantity, true, "from cart")).join("\n");
2723
+ const banner = isEdit ? `<div class="banner edit">Edit \u2014 ${ctx.items.length} item(s) pre-loaded from the buyer's cart. Adjust quantities and return.</div>` : "";
2724
+ const rows = extraRows ? `${catalogRows}
2725
+ ${extraRows}` : catalogRows;
2726
+ return page(banner, rows, "Return cart to buyer \u2192");
2655
2727
  });
2656
2728
  simRoute.post("/:id/checkout", async (c) => {
2657
2729
  const supplier = getSupplier(c.req.param("id"));
@@ -2664,29 +2736,41 @@ simRoute.post("/:id/checkout", async (c) => {
2664
2736
  const formpost = safeHttpUrl(String(form.formpost ?? ""));
2665
2737
  const buyerCred = { domain: String(form.bd ?? ""), identity: String(form.bi ?? "") };
2666
2738
  const catalog = catalogOf(supplier);
2667
- const items = [];
2668
- catalog.forEach((it, i) => {
2669
- let qty = Number(form[`q_${i}`] ?? 0);
2670
- if (!it.allowFractional) qty = Math.floor(qty);
2671
- if (qty > 0) {
2672
- items.push({
2673
- quantity: qty,
2674
- supplierPartId: it.supplierPartId,
2675
- supplierPartAuxiliaryId: it.supplierPartAuxiliaryId,
2676
- description: it.description,
2677
- uom: it.uom,
2678
- unitPriceAmount: it.unitPrice,
2679
- currency: it.currency,
2680
- classifications: it.classifications,
2681
- // Keep the legacy single fields populated from the first classification
2682
- // for back-compat display (CartView) and any single-domain consumer.
2683
- classificationDomain: it.classifications[0]?.domain,
2684
- classification: it.classifications[0]?.value,
2685
- manufacturerPartId: it.manufacturerPartId,
2686
- manufacturerName: it.manufacturerName
2739
+ const ctx = setupContextFor(cookie);
2740
+ let items = [];
2741
+ if (ctx.operation === "inspect" && ctx.items.length > 0) {
2742
+ items = ctx.items;
2743
+ } else {
2744
+ catalog.forEach((it, i) => {
2745
+ let qty = Number(form[`q_${i}`] ?? 0);
2746
+ if (!it.allowFractional) qty = Math.floor(qty);
2747
+ if (qty > 0) {
2748
+ items.push({
2749
+ quantity: qty,
2750
+ supplierPartId: it.supplierPartId,
2751
+ supplierPartAuxiliaryId: it.supplierPartAuxiliaryId,
2752
+ description: it.description,
2753
+ uom: it.uom,
2754
+ unitPriceAmount: it.unitPrice,
2755
+ currency: it.currency,
2756
+ classifications: it.classifications,
2757
+ // Keep the legacy single fields populated from the first classification
2758
+ // for back-compat display (CartView) and any single-domain consumer.
2759
+ classificationDomain: it.classifications[0]?.domain,
2760
+ classification: it.classifications[0]?.value,
2761
+ manufacturerPartId: it.manufacturerPartId,
2762
+ manufacturerName: it.manufacturerName
2763
+ });
2764
+ }
2765
+ });
2766
+ if (ctx.operation === "edit") {
2767
+ const extras = ctx.items.filter((p) => !catalog.some((it) => sameProduct(p, it)));
2768
+ extras.forEach((p, j) => {
2769
+ const qty = Number(form[`qx_${j}`] ?? p.quantity);
2770
+ if (qty > 0) items.push({ ...p, quantity: qty });
2687
2771
  });
2688
2772
  }
2689
- });
2773
+ }
2690
2774
  const currency = items[0]?.currency ?? "USD";
2691
2775
  const eff = effFor(supplier.id, buyerCred);
2692
2776
  const xml = buildPunchOutOrderMessage({
@@ -1,4 +1,4 @@
1
- import{m as et}from"./index-ByIxIbJu.js";/*!-----------------------------------------------------------------------------
1
+ import{m as et}from"./index-Dr4reAcK.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -1,4 +1,4 @@
1
- import{m as f}from"./index-ByIxIbJu.js";/*!-----------------------------------------------------------------------------
1
+ import{m as f}from"./index-Dr4reAcK.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -1,4 +1,4 @@
1
- import{m as l}from"./index-ByIxIbJu.js";/*!-----------------------------------------------------------------------------
1
+ import{m as l}from"./index-Dr4reAcK.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -1,4 +1,4 @@
1
- import{m as s}from"./index-ByIxIbJu.js";/*!-----------------------------------------------------------------------------
1
+ import{m as s}from"./index-Dr4reAcK.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -1,4 +1,4 @@
1
- import{m as lt}from"./index-ByIxIbJu.js";/*!-----------------------------------------------------------------------------
1
+ import{m as lt}from"./index-Dr4reAcK.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license