[object Object]

← back to Dw Photo Capture

TK-12162: auto-save only for registry vendors; case-insensitive vendor lookup; Minimal sends pattern name

e7a5066c3a19ab65540b652ead7a66a1f4c123ee · 2026-09-24 14:45:48 -0700 · Steve Abrams

Live dry-run against prod showed every simple-* save minting a PROV- sku: create-item
matched the label vendor to vendor_registry by exact, case-sensitive name, so
'YORK WALLCOVERINGS' missed 'York Wallcoverings' (DWYK-). Auto-save would have
turned that into drafts with placeholder SKUs.

- server.js findVendorReg(): exact vid/display/real name first, then a case- and
  punctuation-insensitive pass that only accepts a single hit (no guessing).
- /api/extract returns vendor_registered + the registry display name (private-label
  name wins, so the source name never reaches Shopify).
- All 4 pages auto-save only when vendor_registered; otherwise the manual confirm
  screen shows. Minimal now sends the pattern name, so its draft titles match.

Mocked E2E: auto-save fires for a registered vendor, never for an unregistered one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014WCvaA6QQCJp2LMdcXyrUD

Files touched

Diff

commit e7a5066c3a19ab65540b652ead7a66a1f4c123ee
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 14:45:48 2026 -0700

    TK-12162: auto-save only for registry vendors; case-insensitive vendor lookup; Minimal sends pattern name
    
    Live dry-run against prod showed every simple-* save minting a PROV- sku: create-item
    matched the label vendor to vendor_registry by exact, case-sensitive name, so
    'YORK WALLCOVERINGS' missed 'York Wallcoverings' (DWYK-). Auto-save would have
    turned that into drafts with placeholder SKUs.
    
    - server.js findVendorReg(): exact vid/display/real name first, then a case- and
      punctuation-insensitive pass that only accepts a single hit (no guessing).
    - /api/extract returns vendor_registered + the registry display name (private-label
      name wins, so the source name never reaches Shopify).
    - All 4 pages auto-save only when vendor_registered; otherwise the manual confirm
      screen shows. Minimal now sends the pattern name, so its draft titles match.
    
    Mocked E2E: auto-save fires for a registered vendor, never for an unregistered one.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_014WCvaA6QQCJp2LMdcXyrUD
---
 public/simple-instant.html  |  8 +++++---
 public/simple-minimal.html  | 20 +++++++++++++++-----
 public/simple-probooth.html |  2 +-
 public/simple-wizard.html   |  2 +-
 server.js                   | 26 ++++++++++++++++++++++----
 5 files changed, 44 insertions(+), 14 deletions(-)

diff --git a/public/simple-instant.html b/public/simple-instant.html
index 588e821..7c54ab5 100644
--- a/public/simple-instant.html
+++ b/public/simple-instant.html
@@ -270,9 +270,10 @@
   // Pre-shot adjustments (all 11) + auto-save once the label read fills vendor + mfr#.
   var panel = AdjustPanel.create({ key:'dwSimpleTune', toggleParent: $('fullResBtn').parentNode, toggleClass:'fullres-btn' });
   panel.attachPreview(video);
-  var autoFired = false;
+  var autoFired = false, vendorRegistered = false;
   function maybeAutoSave(){
-    if (autoFired || !panel.autoSave() || !extractDone || chinFields.hidden || saveBtn.disabled) return;
+    // registry vendors only — an unmatched label vendor would mint a PROV- sku, so a human confirms it
+    if (autoFired || !panel.autoSave() || !extractDone || !vendorRegistered || chinFields.hidden || saveBtn.disabled) return;
     if (!fVendor.value.trim() || !fMfr.value.trim()) return;
     autoFired = true; saveBtn.click();
   }
@@ -336,7 +337,7 @@
 
   async function doCapture(native){
     if (busy || (!stream && !native)) return;
-    busy = true; shutterBtn.disabled = true; autoFired = false;
+    busy = true; shutterBtn.disabled = true; autoFired = false; vendorRegistered = false;
 
     if (native) {
       capturedDataUrl = native.full;
@@ -374,6 +375,7 @@
 
     extractPromise.then(function(data){
       extractDone = true;
+      vendorRegistered = !!(data && data.vendor_registered === true);
       var elapsed = performance.now() - t0;
       var fields = (data && data.fields) || {};
       if (!fVendor.value.trim() && (data.vendor_matched || fields.vendor)){
diff --git a/public/simple-minimal.html b/public/simple-minimal.html
index 48b5d82..6efd019 100644
--- a/public/simple-minimal.html
+++ b/public/simple-minimal.html
@@ -124,6 +124,10 @@
   .rc-title{font:800 19px/1.25 "SF Pro Display",sans-serif;color:var(--ink)}
   .rc-sub{font:600 13px/1.4 ui-monospace,Menlo,monospace;color:var(--gold)}
   .rc-detail{font-size:13px;color:var(--muted)}
+  .rc-dwuni{font:600 11px/1 -apple-system,sans-serif;margin-top:4px}
+  .rc-dwuni.ok{color:#8fbf7a}
+  .rc-dwuni.warn{color:#e0a53a}
+  .rc-dwuni[hidden]{display:none}
   .rc-time{font:600 11px/1 ui-monospace,Menlo,monospace;color:var(--muted)}
   .rc-time[hidden]{display:none}
   .rc-again{background:var(--gold);color:#1b1407;border:none;border-radius:13px;padding:16px;
@@ -179,6 +183,7 @@
     <div class="rc-title" id="rcTitle">Created</div>
     <div class="rc-sub" id="rcSku"></div>
     <div class="rc-detail" id="rcDetail"></div>
+    <div class="rc-dwuni" id="rcDwUni" hidden></div>
     <div class="rc-time" id="rcTime" hidden></div>
     <div class="rc-manual" id="rcManual">
       <select id="mVendor"><option value="">Pick vendor…</option></select>
@@ -206,7 +211,7 @@
   var shutterWrap = $('shutterWrap'), shutterBtn = $('shutter');
   var procWrap = $('procWrap'), procMsg = $('procMsg'), procTime = $('procTime');
   var resultWrap = $('resultWrap'), rcard = $('rcard'), rcIcon = $('rcIcon'), rcTitle = $('rcTitle'),
-      rcSku = $('rcSku'), rcDetail = $('rcDetail'), rcTime = $('rcTime'), rcAgain = $('rcAgain');
+      rcSku = $('rcSku'), rcDetail = $('rcDetail'), rcDwUni = $('rcDwUni'), rcTime = $('rcTime'), rcAgain = $('rcAgain');
   var rcManual = $('rcManual'), mVendor = $('mVendor'), mMfr = $('mMfr'), rcSave = $('rcSave');
   var permWrap = $('permWrap'), permMsg = $('permMsg'), permRetry = $('permRetry');
   var vendorOverrideSel = $('vendorOverride');
@@ -355,10 +360,13 @@
         lastExtractFields = f;
         var vendor = vendorOverrideSel.value || r.vendor_matched || '';
         var mfr = cleanField(f.mfr_sku);
-        if (vendor && mfr && AUTO_CREATE) {
+        // auto-create only for a registry vendor (picked, or matched by the server) — an unmatched
+        // label vendor would mint a PROV- sku, so it goes to the confirm screen instead
+        var vendorOk = !!vendorOverrideSel.value || r.vendor_registered === true;
+        if (vendor && mfr && AUTO_CREATE && vendorOk) {
           procMsg.textContent = 'Creating ' + vendor + ' item…';
           createItem(dataUrl, vendor, mfr, f);
-        } else if (vendor && mfr && !AUTO_CREATE) {
+        } else if (vendor && mfr && !AUTO_CREATE && vendorOk) {
           showResult(true, { preview:true, vendor:vendor, mfr:mfr, fields:f });
         } else {
           showNeedsInfo(f, dataUrl, r && r.ok === false ? r.err : null);
@@ -371,7 +379,7 @@
 
   function createItem(dataUrl, vendor, mfr, fields){
     var payload = {
-      mfr: mfr, vendor: vendor, color: fields.color || '', material: fields.material || '',
+      mfr: mfr, vendor: vendor, name: cleanField(fields.pattern_name), color: fields.color || '', material: fields.material || '',
       collection: fields.collection || '', width: fields.width || '', roll_length: fields.roll_length || '',
       repeat: fields.repeat || '', pattern_match: fields.pattern_match || '', substrate: fields.substrate || '',
       how_sold: fields.how_sold || '', price: fields.price || '', price_code: fields.price_code || '',
@@ -382,7 +390,7 @@
       .then(function(r){
         if (r.duplicate) { showResult(false, { err: r.err, duplicate:true, mfr:mfr }); return; }
         if (!r.ok) { showResult(false, { err: r.err || 'Create failed', mfr:mfr, vendor:vendor, fields:fields, dataUrl:dataUrl }); return; }
-        showResult(true, { dw_sku: r.dw_sku, title: r.title, vendor:vendor });
+        showResult(true, { dw_sku: r.dw_sku, title: r.title, vendor:vendor, dw_unified: r.dw_unified });
       })
       .catch(function(e){ showResult(false, { err: 'Network error creating item: ' + (e && e.message || ''), mfr:mfr, vendor:vendor, fields:fields, dataUrl:dataUrl }); });
   }
@@ -420,7 +428,9 @@
       rcTitle.textContent = info.title || info.dw_sku;
       rcSku.textContent = 'DW# ' + info.dw_sku + (info.vendor ? '  ·  ' + info.vendor : '');
       rcDetail.textContent = 'Draft created — not live yet.';
+      setDwUni(info.dw_unified);
     } else if (ok && info.preview) {
+      rcDwUni.hidden = true;
       rcard.className = 'rcard ok';
       rcIcon.textContent = '✓';
       rcTitle.textContent = 'Read OK (preview mode)';
diff --git a/public/simple-probooth.html b/public/simple-probooth.html
index 391f056..c08385e 100644
--- a/public/simple-probooth.html
+++ b/public/simple-probooth.html
@@ -388,7 +388,7 @@ function fireExtract(url){
     extracted=f;
     st.textContent='✓ Label read — check the fields, then Save.'; st.classList.add('on');
     showTiming();
-    if(panel.autoSave() && $('#fVendor').value.trim() && $('#fMfr').value.trim() && !$('#saveBtn').disabled){
+    if(panel.autoSave() && r.vendor_registered===true && $('#fVendor').value.trim() && $('#fMfr').value.trim() && !$('#saveBtn').disabled){
       st.textContent='✓ Label read — saving draft…'; $('#saveBtn').click();
     }
   });
diff --git a/public/simple-wizard.html b/public/simple-wizard.html
index 7011333..986a68e 100644
--- a/public/simple-wizard.html
+++ b/public/simple-wizard.html
@@ -437,7 +437,7 @@
       $('analyzingPill').classList.remove('show');
       // auto-save: label read gave vendor + mfr# -> confirm for the guest (same path as the Use tap)
       const jf = (j && j.fields) || {};
-      if (panel.autoSave() && capturedDataUrl === shotRef && currentStep === 3 && j && j.ok !== false
+      if (panel.autoSave() && capturedDataUrl === shotRef && currentStep === 3 && j && j.ok !== false && j.vendor_registered === true
           && (j.vendor_matched || cleanField(jf.vendor)) && cleanField(jf.mfr_sku) && !$('btnUse').disabled) {
         setTimeout(() => { if (currentStep === 3 && capturedDataUrl === shotRef) $('btnUse').click(); }, 0);
       }
diff --git a/server.js b/server.js
index 59cdc9d..7786369 100644
--- a/server.js
+++ b/server.js
@@ -1733,7 +1733,13 @@ const appHandler = (req, res) => {
       let a = {}; try { a = JSON.parse(r.response || '{}'); } catch (e) { a = {}; }
       // map the read vendor to a known catalog vendor (fuzzy)
       const vendorMatch = a.vendor ? (fuzzyVendor(a.vendor) || a.vendor) : null;
-      send(res, 200, { ok: !r.error, fields: a, vendor_matched: vendorMatch, cost_usd: 0.0006, err: r.error || null });
+      // vendor_registered: does the read vendor resolve to a vendor_registry row (real DW# series)?
+      // vendor_matched falls back to the raw label text, so callers that save WITHOUT a human
+      // confirming (auto-save) must check this, or an unmatched vendor mints a PROV- sku.
+      const reg = vendorMatch ? await getVendorsRegistry().catch(() => []) : [];
+      const vreg = vendorMatch ? findVendorReg(reg, vendorMatch) : null;
+      send(res, 200, { ok: !r.error, fields: a, vendor_matched: vreg ? vreg.vendor : vendorMatch, vendor_registered: !!vreg,
+        cost_usd: 0.0006, err: r.error || null });
     });
     return;
   }
@@ -2560,6 +2566,20 @@ async function getMetafieldTypeMap() {
 // (sku_prefix + sku_range_start) and — best-effort — the short FileMaker `vid`
 // (KRA/WQ/PJ…) inferred from the dominant vid on that prefix's existing FM masters.
 let _vregCache = { at: 0, list: [] };
+// Registry lookup for a vendor name as read off a label or typed: exact vid / display / real name
+// first, then case- and punctuation-insensitive ("YORK WALLCOVERINGS" -> "York Wallcoverings").
+// The loose pass only accepts a SINGLE hit — two normalized matches is ambiguous, so no guess.
+function _normVendor(s) { return String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); }
+function findVendorReg(registry, vendor, vid) {
+  const hit = registry.find(v => (v.vid && vid && v.vid === vid))
+    || registry.find(v => v.vendor === vendor)
+    || registry.find(v => (v.real_vendor || '') === vendor);
+  if (hit) return hit;
+  const n = _normVendor(vendor); if (n.length < 3) return null;
+  const loose = registry.filter(v => _normVendor(v.vendor) === n || _normVendor(v.real_vendor) === n);
+  return loose.length === 1 ? loose[0] : null;
+}
+
 async function getVendorsRegistry() {
   if (Date.now() - _vregCache.at < 10 * 60 * 1000 && _vregCache.list.length) return _vregCache.list;
   // vendor_registry drives the DW#: vendor_code (vid for staging), sku_prefix, sku_range_start.
@@ -2692,9 +2712,7 @@ async function createNewItem(p, b64, dryRun) {
   }
   // Resolve the chosen vendor from the canonical registry (drives DW# + Shopify vendor + FM vid).
   const registry = await getVendorsRegistry().catch(() => []);
-  let vreg = registry.find(v => (v.vid && p.vid && v.vid === p.vid))
-    || registry.find(v => v.vendor === vendor)
-    || registry.find(v => (v.real_vendor || '') === vendor);
+  let vreg = findVendorReg(registry, vendor, p.vid);
   if (!vreg) {
     // vendor typed/spoken but not in the registry — still allow, but flag (no canonical prefix known)
     vreg = { vendor, real_vendor: vendor, vid: p.vid || '', sku_prefix: null, sku_range_start: 0, fm_vid: null, private_label: false };

← 360da78 TK-12162: await + retry the dw_unified new_items_staging wri  ·  back to Dw Photo Capture  ·  TK-12162: show a dw_unified save-status line on all 4 captur a99636b →