← 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
M public/simple-instant.htmlM public/simple-minimal.htmlM public/simple-probooth.htmlM public/simple-wizard.htmlM server.js
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 →