Store: catalogue live depuis ERPNext (GET /store/catalog)
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
87949f933d
commit
5807d58913
|
|
@ -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 = `<!doctype html><html lang=fr><head>
|
||||
<meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
|
||||
|
|
@ -146,7 +147,8 @@ a{color:inherit}
|
|||
</div></div>
|
||||
|
||||
<div class=wrap>
|
||||
<div class=demo>⚙️ <b>Page modèle</b> — données démo. Branchement au catalogue ERPNext (Items + bundles + prix) à l'étape suivante.</div>
|
||||
<div class=demo v-if="!live">⚙️ <b>Page modèle</b> — données démo (catalogue ERPNext vide ou indisponible). Cochez « Afficher dans la boutique » sur des Items pour les voir ici.</div>
|
||||
<div class=demo v-else style="background:#f0fdf4;border-color:#bbf7d0;color:#15803d">✓ <b>Catalogue ERPNext en direct</b> — produits cochés « Afficher dans la boutique » · prix Item Price · stock live.</div>
|
||||
<div class=cats>
|
||||
<div class=cat v-for="c in cats" :class="{on:cat===c}" @click="cat=c">{{ c }}</div>
|
||||
</div>
|
||||
|
|
@ -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({
|
|||
</script>
|
||||
</body></html>`
|
||||
|
||||
// ── 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' })
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user