[object Object]

← back to Designer Wallcoverings

Kravet-family feed importer (authorized daily SFTP, no scraping)

ef219ca4b7fda285393c6b963be0ee17ecdc48b5 · 2026-06-11 11:39:47 -0700 · SteveStudio2

ftp.kravet.com:22 SFTP feed deswallco.zip → item_info.csv (44,676 items). Feed WHLS already
reflects the new June-8 prices (cross-checked vs Kravet's new-price Google Sheet). Imports
cost=WHLS, MAP, sell=max(cost/0.65/0.85, MAP) + basic specs into kravet_catalog by mfr_sku.
Catalog-only, --table extends to sister brands (lee_jofa, brunschwig, etc.). Creds gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit ef219ca4b7fda285393c6b963be0ee17ecdc48b5
Author: SteveStudio2 <stevestudio2@SteveStudio2s-Mac-Studio.local>
Date:   Thu Jun 11 11:39:47 2026 -0700

    Kravet-family feed importer (authorized daily SFTP, no scraping)
    
    ftp.kravet.com:22 SFTP feed deswallco.zip → item_info.csv (44,676 items). Feed WHLS already
    reflects the new June-8 prices (cross-checked vs Kravet's new-price Google Sheet). Imports
    cost=WHLS, MAP, sell=max(cost/0.65/0.85, MAP) + basic specs into kravet_catalog by mfr_sku.
    Catalog-only, --table extends to sister brands (lee_jofa, brunschwig, etc.). Creds gitignored.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .gitignore                            |   2 +
 shopify/scripts/kravet-feed-import.js | 113 ++++++++++++++++++++++++++++++++++
 2 files changed, 115 insertions(+)

diff --git a/.gitignore b/.gitignore
index f6b79606..0654ddaf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,3 +53,5 @@ copy-of-*
 shopify/scripts/.schu-token
 shopify/scripts/.schu-creds
 .schu-creds
+shopify/scripts/.kravet-creds
+.kravet-creds
diff --git a/shopify/scripts/kravet-feed-import.js b/shopify/scripts/kravet-feed-import.js
new file mode 100644
index 00000000..33a56d10
--- /dev/null
+++ b/shopify/scripts/kravet-feed-import.js
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+/**
+ * Kravet-family feed importer — authorized daily SFTP feed (no scraping).
+ *
+ * Source: ftp.kravet.com:22 (SFTP, creds in .kravet-creds) → deswallco.zip → item_info.csv
+ * (44,676 items, latin-1, RFC4180-quoted). Confirmed 2026-06-11: the feed's `WHLS Price`
+ * already reflects the new June-8 prices (cross-checked vs Kravet's new-price Google Sheet:
+ * feed 16235.1.0 = 51.45 = the NEW WHLS). So the feed is the single source of truth.
+ *
+ * Cost basis (Kravet = designer net): cost = WHLS Price. Kravet enforces MAP (min advertised),
+ * so DW sell = max( round(WHLS / 0.65 / 0.85, 2), MAP ). MAP ≈ 1.5 × WHLS.
+ *
+ * Catalog-only writes (NO Shopify push). Updates EXISTING kravet_catalog rows by mfr_sku.
+ *
+ *   node kravet-feed-import.js --csv /tmp/item_info.csv            # DRY (default), kravet_catalog
+ *   node kravet-feed-import.js --commit
+ *   node kravet-feed-import.js --table lee_jofa_catalog --commit   # a sister-brand table
+ */
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const args = process.argv.slice(2);
+const COMMIT = args.includes('--commit');
+const cIdx = args.indexOf('--csv');
+const CSV = cIdx >= 0 ? args[cIdx + 1] : '/tmp/item_info.csv';
+const tIdx = args.indexOf('--table');
+const TABLE = tIdx >= 0 ? args[tIdx + 1] : 'kravet_catalog';
+if (!/^[a-z_]+_catalog$/.test(TABLE)) { console.error('bad --table'); process.exit(1); }
+
+function psql(sql) { return execFileSync('psql', ['-At', '-F', '\t', '-d', 'dw_unified', '-c', sql], { encoding: 'utf8', maxBuffer: 1 << 28 }).trim(); }
+const q = s => "'" + String(s).replace(/'/g, "''") + "'";
+const num = s => { const n = parseFloat(String(s).replace(/[^0-9.\-]/g, '')); return isFinite(n) ? n : null; };
+
+// RFC4180 CSV parse of a single line (handles "" escapes + commas in quotes)
+function parseLine(l) {
+  const o = []; let cur = '', q = false;
+  for (let i = 0; i < l.length; i++) {
+    const c = l[i];
+    if (q) { if (c === '"') { if (l[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += c; }
+    else { if (c === '"') q = true; else if (c === ',') { o.push(cur); cur = ''; } else cur += c; }
+  }
+  o.push(cur); return o;
+}
+
+(function () {
+  if (!fs.existsSync(CSV)) { console.error(`feed CSV not found: ${CSV} — download deswallco.zip first`); process.exit(2); }
+  const text = fs.readFileSync(CSV, 'latin1');
+  const lines = text.split(/\r?\n/);
+  const H = parseLine(lines[0]).map(h => h.trim());
+  const ix = n => H.indexOf(n);
+  const cI = ix('Item'), cWhls = ix('WHLS Price'), cMap = ix('MAP'), cPat = ix('Pattern'),
+        cCol = ix('Color'), cBrand = ix('Brand'), cVR = ix('Vert. Repeat'), cHR = ix('Horz. Repeat'),
+        cWidth = ix('Width'), cCountry = ix('Country of Origin'), cContent = ix('Content'),
+        cColl = ix('Collection'), cImg = ix('Image File Name - HI RES'), cStatus = ix('Display Status'),
+        cTar = ix('Tariff(%)');
+  if (cI < 0 || cWhls < 0) { console.error('feed header missing Item/WHLS Price'); process.exit(2); }
+
+  // build feed map: Item → fields
+  const feed = new Map();
+  for (let k = 1; k < lines.length; k++) {
+    if (!lines[k]) continue;
+    const r = parseLine(lines[k]);
+    const item = (r[cI] || '').trim();
+    if (!item) continue;
+    feed.set(item, {
+      whls: num(r[cWhls]), map: cMap >= 0 ? num(r[cMap]) : null,
+      pattern: (r[cPat] || '').trim(), color: (r[cCol] || '').trim(), brand: (r[cBrand] || '').trim(),
+      vr: (r[cVR] || '').trim(), hr: (r[cHR] || '').trim(), width: (r[cWidth] || '').trim(),
+      country: (r[cCountry] || '').trim(), content: (r[cContent] || '').trim(),
+      coll: (r[cColl] || '').trim(), img: (r[cImg] || '').trim(),
+      status: (r[cStatus] || '').trim(), tariff: cTar >= 0 ? num(r[cTar]) : null,
+    });
+  }
+  console.log(`feed parsed: ${feed.size} items | target table: ${TABLE} | mode: ${COMMIT ? 'COMMIT' : 'DRY'}`);
+
+  // existing catalog rows
+  const rows = psql(`SELECT mfr_sku FROM ${TABLE} WHERE mfr_sku IS NOT NULL AND mfr_sku<>'';`).split('\n').filter(Boolean);
+  let matched = 0, priced = 0, miss = 0, disc = 0, samples = 0;
+  const updates = [];
+  for (const sku of rows) {
+    const f = feed.get(sku);
+    if (!f) { miss++; continue; }
+    matched++;
+    if (f.whls == null || f.whls <= 0) continue;
+    const sell = Math.max(Math.round((f.whls / 0.65 / 0.85) * 100) / 100, f.map || 0);
+    const discontinued = /disc|inactive|delet/i.test(f.status);
+    if (discontinued) disc++;
+    const sets = [
+      `cost_price=${f.whls}`, `price_trade=${f.whls}`,
+      f.map != null ? `price_retail=${f.map}` : null,
+      `dw_sell_price=${sell}`, `price_effective_date=CURRENT_DATE`,
+      f.pattern ? `pattern_name=COALESCE(NULLIF(pattern_name,''),${q(f.pattern)})` : null,
+      f.color ? `color_name=COALESCE(NULLIF(color_name,''),${q(f.color)})` : null,
+      (f.vr || f.hr) ? `pattern_repeat=${q([f.vr, f.hr].filter(Boolean).join(' x '))}` : null,
+    ].filter(Boolean).join(', ');
+    updates.push(`UPDATE ${TABLE} SET ${sets} WHERE mfr_sku=${q(sku)};`);
+    priced++;
+    if (samples < 6) { console.log(`  ${sku}  cost $${f.whls}  MAP $${f.map}  → sell $${sell}  | ${f.pattern} ${f.color}`); samples++; }
+  }
+  console.log(`\nmatched ${matched}/${rows.length} catalog rows in feed | will price ${priced} | discontinued-flag ${disc} | not-in-feed ${miss}`);
+  if (!COMMIT) { console.log('DRY-RUN — re-run with --commit to write.'); return; }
+
+  // apply in batches
+  let done = 0;
+  const BATCH = 500;
+  for (let i = 0; i < updates.length; i += BATCH) {
+    psql('BEGIN; ' + updates.slice(i, i + BATCH).join(' ') + ' COMMIT;');
+    done += Math.min(BATCH, updates.length - i);
+    process.stdout.write(`\r  written ${done}/${updates.length}…`);
+  }
+  console.log(`\nDONE: ${TABLE} — priced ${priced} rows from feed (cost=WHLS, sell=max(cost/0.65/0.85, MAP)).`);
+})();

← 3f4ec431 Store-wide sweep: set every active variant inventory=2026 +  ·  back to Designer Wallcoverings  ·  Add gdrive-price-loader.py — load vendor cost from authorita 4c223491 →