From 5807d58913e464e347614b8b9e17462d7e6a3d39 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Wed, 3 Jun 2026 21:23:01 -0400 Subject: [PATCH] Store: catalogue live depuis ERPNext (GET /store/catalog) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Curation = champ Item.show_in_store (case à cocher) ; catégorie=item_group, prix=Item Price, stock=Bin live - Bundles via Product Bundle (prix vs somme barrée), variantes mono-attribut (delta prix + stock/valeur) - Fix filtres erp.list (tableaux, pas objets — listUrl teste .length) + child-tables lues via erp.get parent - Page: fetch /store/catalog au mount, repli démo, bandeau live/démo, stock simple respecté Prérequis PG corrigé: retrait ORDER BY NULL (MySQLism) dans erpnext utilities/product.py (validation de variante) — sinon création de variantes impossible sur PostgreSQL. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/targo-hub/lib/store.js | 102 ++++++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 5 deletions(-) diff --git a/services/targo-hub/lib/store.js b/services/targo-hub/lib/store.js index f275fc5..a87d9ed 100644 --- a/services/targo-hub/lib/store.js +++ b/services/targo-hub/lib/store.js @@ -15,6 +15,7 @@ * dollar-accolade dans le HTML/JS interne (Vue utilise les doubles accolades). */ const { json } = require('./helpers') +const erp = require('./erp') const PAGE = ` @@ -146,7 +147,8 @@ a{color:inherit}
-
⚙️ Page modèle — données démo. Branchement au catalogue ERPNext (Items + bundles + prix) à l'étape suivante.
+
⚙️ Page modèle — données démo (catalogue ERPNext vide ou indisponible). Cochez « Afficher dans la boutique » sur des Items pour les voir ici.
+
Catalogue ERPNext en direct — produits cochés « Afficher dans la boutique » · prix Item Price · stock live.
{{ c }}
@@ -311,10 +313,11 @@ const DEMO=[ ]; Vue.createApp({ - data(){return{cat:'Tous',sel:null,picked:{},qty:1,cart:[],cartOpen:false,toast:null,added:false,bump:false,_t:0,_b:0}}, + data(){return{cat:'Tous',sel:null,picked:{},qty:1,cart:[],cartOpen:false,toast:null,added:false,bump:false,items:DEMO,live:false,_t:0,_b:0}}, + mounted(){this.load()}, computed:{ - cats(){return ['Tous'].concat([...new Set(DEMO.map(p=>p.cat))])}, - shown(){return this.cat==='Tous'?DEMO:DEMO.filter(p=>p.cat===this.cat)}, + cats(){return ['Tous'].concat([...new Set(this.items.map(p=>p.cat))])}, + shown(){return this.cat==='Tous'?this.items:this.items.filter(p=>p.cat===this.cat)}, count(){return this.cart.reduce((s,l)=>s+l.qty,0)}, total(){return this.cart.reduce((s,l)=>s+l.price*l.qty,0)}, curPrice(){ @@ -324,12 +327,14 @@ Vue.createApp({ }, curStock(){ if(!this.sel)return 1;if(this.sel.bundle)return 5; + if(!this.sel.options)return this.sel.stock!==undefined?this.sel.stock:99; let st=99;(this.sel.options||[]).forEach(o=>{const v=this.valOf(o);if(v&&v.stock!==undefined)st=Math.min(st,v.stock)});return st; }, stockClass(){const s=this.curStock;return s===0?'no':(s<=6?'low':'ok')}, stockLabel(){const s=this.curStock;return s===0?'Rupture de stock':(s<=6?('Plus que '+s+' en stock'):'En stock')} }, methods:{ + load(){fetch('/store/catalog').then(r=>r.json()).then(d=>{if(d&&d.products&&d.products.length){this.items=d.products;this.live=true}}).catch(()=>{})}, money(n){return (Math.round(n*100)/100).toFixed(2).replace('.',',')+' $'}, colorOf(p){return (p.options||[]).find(o=>o.type==='color')}, valOf(o){return o.values.find(v=>v.v===this.picked[o.name])}, @@ -369,12 +374,99 @@ Vue.createApp({ ` +// ── Catalogue live depuis ERPNext ────────────────────────────────────────── +// Curation = champ Item.show_in_store (case à cocher). Catégorie = item_group. +// Prix = Item Price (Standard Selling). Stock = somme Bin.actual_qty. Bundles = +// Product Bundle. Variantes mono-attribut → options additives (add + stock/valeur). +const ERP_HOST = 'https://erp.gigafibre.ca' +const PRICE_LIST = 'Standard Selling' +const COLOR_MAP = { blanc: '#f3f4f6', noir: '#1f2937', gris: '#9ca3af', bleu: '#2563eb', rouge: '#dc2626', vert: '#16a34a', jaune: '#eab308', orange: '#f97316', argent: '#cbd5e1' } +const GROUP_ICON = { 'Caméras': '📷', 'Boosters WiFi': '📶', 'Câbles réseau': '🔌', 'TP-Link': '📡', 'Accessoires': '⚡', 'Bundles': '📦' } +const round2 = n => Math.round((Number(n) || 0) * 100) / 100 +const stripHtml = s => (s || '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim() +const imgUrl = img => (img && img.startsWith('/files')) ? ERP_HOST + img : '' + +async function priceOf (code) { + const rows = await erp.list('Item Price', { filters: [['item_code', '=', code], ['price_list', '=', PRICE_LIST], ['selling', '=', 1]], fields: ['price_list_rate'], limit: 1 }) + return rows && rows[0] ? round2(rows[0].price_list_rate) : 0 +} +async function stockOf (code) { + const bins = await erp.list('Bin', { filters: [['item_code', '=', code]], fields: ['actual_qty'], limit: 50 }) + return Math.round((bins || []).reduce((s, b) => s + (Number(b.actual_qty) || 0), 0)) +} +async function nameOf (code) { + const it = await erp.get('Item', code, { fields: ['item_name'] }) + return (it && it.item_name) || code +} + +async function buildCatalog () { + const items = await erp.list('Item', { + filters: [['show_in_store', '=', 1], ['disabled', '=', 0]], + fields: ['name', 'item_name', 'item_group', 'image', 'description', 'standard_rate', 'has_variants', 'variant_of'], + limit: 200, + }) + const bundles = await erp.list('Product Bundle', { fields: ['name'], limit: 200 }) + const bundleSet = new Set((bundles || []).map(b => b.name)) + const out = [] + for (const it of (items || [])) { + if (it.variant_of) continue // les variantes enfant sont exposées via leur template + const p = { + id: it.name, cat: it.item_group || 'Boutique', name: it.item_name || it.name, + desc: stripHtml(it.description), icon: GROUP_ICON[it.item_group] || '📦', image: imgUrl(it.image), + } + if (bundleSet.has(it.name)) { + const pb = await erp.get('Product Bundle', it.name) + const comps = (pb && pb.items) || [] + let sum = 0; const list = [] + for (const c of (comps || [])) { + const q = Number(c.qty) || 1; sum += (await priceOf(c.item_code)) * q + list.push({ q, n: await nameOf(c.item_code) }) + } + p.bundle = true; p.sum = round2(sum); p.price = (await priceOf(it.name)) || p.sum; p.items = list + } else if (it.has_variants) { + const vs = await erp.list('Item', { filters: [['variant_of', '=', it.name], ['disabled', '=', 0]], fields: ['name'], limit: 100 }) + const info = [] + for (const v of (vs || [])) { + const vd = await erp.get('Item', v.name) + const attrs = (vd && vd.attributes) || [] + info.push({ attrs, price: await priceOf(v.name), stock: await stockOf(v.name) }) + } + const prices = info.map(v => v.price).filter(n => n > 0) + const baseP = prices.length ? Math.min(...prices) : (round2(it.standard_rate) || 0) + p.base = baseP + const attrNames = [...new Set(info.flatMap(v => v.attrs.map(a => a.attribute)))] + p.options = attrNames.map(an => { + const isColor = /couleur|color/i.test(an) + const vals = [...new Set(info.flatMap(v => v.attrs.filter(a => a.attribute === an).map(a => a.attribute_value)))] + return { + name: an, type: isColor ? 'color' : 'pill', + values: vals.map(val => { + const match = info.filter(v => v.attrs.some(a => a.attribute === an && a.attribute_value === val)) + const mp = match.map(v => v.price).filter(n => n > 0) + const o = { v: val, add: round2((mp.length ? Math.min(...mp) : baseP) - baseP), stock: match.reduce((s, v) => s + v.stock, 0) } + if (isColor) o.hex = COLOR_MAP[String(val).toLowerCase()] || '#9ca3af' + return o + }), + } + }) + } else { + p.base = (await priceOf(it.name)) || round2(it.standard_rate) || 0 + p.stock = await stockOf(it.name) + } + out.push(p) + } + return out +} + async function handle (req, res, method, path) { if (path === '/store' && method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) return res.end(PAGE) } - // À venir : GET /store/catalog → Items + Product Bundle d'ERPNext + if (path === '/store/catalog' && method === 'GET') { + try { return json(res, 200, { products: await buildCatalog() }) } + catch (e) { return json(res, 200, { products: [], error: e.message }) } + } return json(res, 404, { error: 'not found' }) }