From fc7afd1be85bf36ad2d786cf4207134453df8a84 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 27 Jul 2026 01:14:36 +0200 Subject: [PATCH] u --- .env.example | 3 + .gitignore | 3 +- API.md | 13 + package.json | 1 + scripts/generate-demo-catalog.mjs | 781 +++++++++++++++++++++++++ server.js | 26 +- src/queries/category-count.js | 6 + src/queries/category-list.js | 6 + src/queries/composite-product-count.js | 6 + src/queries/composite-product-list.js | 6 + src/queries/create-order.js | 6 + src/queries/customer-groups.js | 18 + src/queries/deleted-entity-count.js | 6 + src/queries/deleted-entity-list.js | 6 + src/queries/image.js | 6 + src/queries/max-order-id.js | 6 + src/queries/product-count.js | 6 + src/queries/product-list.js | 6 + 18 files changed, 902 insertions(+), 9 deletions(-) create mode 100644 scripts/generate-demo-catalog.mjs diff --git a/.env.example b/.env.example index cbb80b5..661b7f3 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# Demo catalog (skips MSSQL; requires `npm run demo:generate` first) +DEMO_MODE=false + # HTTPS POS server PORT=4443 AUTH_TOKEN=df40ad2067954646abb0499548a52241 diff --git a/.gitignore b/.gitignore index b9b5cdf..37355be 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ decompiledReference scripts/s3-backup/data/ scripts/s3-backup/tmp/ scripts/s3-backup/certs/ -scripts/minimal-db/data/ \ No newline at end of file +scripts/minimal-db/data/ +demo/ \ No newline at end of file diff --git a/API.md b/API.md index a12bc43..e1b2593 100644 --- a/API.md +++ b/API.md @@ -37,6 +37,19 @@ See [productSync.md](productSync.md) for the cursor / row-version model in detai --- +## Demo mode + +Node can serve a generated in-memory catalog without MSSQL. + +1. Generate artifacts once: `npm run demo:generate` + Writes `demo/catalog.json` and `demo/images/.jpg` (≥1000 products, 3–4 category levels, variants, real photos). +2. Set `DEMO_MODE=true` in `.env` (see `.env.example`). +3. Start the server as usual (`npm start`). Pairing and all sync endpoints work; orders are logged and return synthetic `OK` results. + +Demo mode is **opt-in only** — a failed MSSQL connection does not enable it. + +--- + ## `GET /v1/client` — Pairing Discovers the server and completes pairing with a 6-digit code. diff --git a/package.json b/package.json index d905cc9..5c6d19e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "restore:s3:quick": "node scripts/s3-backup/restore.mjs --skip-trust", "db:minimal": "node scripts/create-minimal-db.mjs", "db:minimal:extract": "node scripts/create-minimal-db.mjs extract", + "demo:generate": "node scripts/generate-demo-catalog.mjs", "start": "node --watch server.js", "test:client": "node test-client.js" }, diff --git a/scripts/generate-demo-catalog.mjs b/scripts/generate-demo-catalog.mjs new file mode 100644 index 0000000..470a768 --- /dev/null +++ b/scripts/generate-demo-catalog.mjs @@ -0,0 +1,781 @@ +#!/usr/bin/env node +/** + * Generates demo/catalog.json + demo/images/*.jpg for DEMO_MODE. + * Downloads real photos from LoremFlickr matched to category / product keywords + * (Picsum fallback). Variant siblings share a base photo and get a light tint. + * + * Usage: node scripts/generate-demo-catalog.mjs + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, '..'); +const DEMO_DIR = path.join(ROOT, 'demo'); +const IMAGES_DIR = path.join(DEMO_DIR, 'images'); + +const IMAGE_SIZE = 480; +const JPEG_QUALITY = 65; +const DOWNLOAD_CONCURRENCY = 8; +const TARGET_PRODUCTS = 1100; + +const COLORS = ['Red', 'Blue', 'Green', 'Black', 'White', 'Grey', 'Navy', 'Beige']; +const MATERIALS = ['Oak', 'Pine', 'Steel', 'Aluminium', 'Cotton', 'Leather', 'Plastic', 'Bamboo']; +const PACKAGE_SIZES = ['250g', '500g', '1kg', '2kg', '5kg', '10pcs', '20pcs', '50pcs']; + +const TINTS = { + Red: { r: 220, g: 60, b: 60 }, + Blue: { r: 50, g: 90, b: 200 }, + Green: { r: 40, g: 150, b: 70 }, + Black: { r: 30, g: 30, b: 30 }, + White: { r: 230, g: 230, b: 230 }, + Grey: { r: 120, g: 120, b: 120 }, + Navy: { r: 20, g: 40, b: 90 }, + Beige: { r: 210, g: 190, b: 150 }, +}; + +/** Flickr-friendly tags for catalog subjects (category / product type names). */ +const KEYWORD_ALIASES = { + 'Home & Living': 'livingroom,interior,home', + Furniture: 'furniture,home', + Seating: 'seating,chair', + Armchairs: 'armchair,chair', + Sofas: 'sofa,couch', + Stools: 'stool,chair', + Tables: 'table,furniture', + 'Coffee Tables': 'coffee,table', + 'Side Tables': 'side,table', + 'Dining Tables': 'dining,table', + Storage: 'storage,cabinet', + Shelves: 'shelf,bookshelf', + Cabinets: 'cabinet,cupboard', + Decor: 'decor,interior', + Lighting: 'lamp,lighting', + 'Floor Lamps': 'floor,lamp', + 'Table Lamps': 'table,lamp', + Pendants: 'pendant,lamp', + Textiles: 'textile,fabric', + Cushions: 'cushion,pillow', + Throws: 'blanket,throw', + Rugs: 'rug,carpet', + WallArt: 'wallart,painting', + Prints: 'poster,print', + Mirrors: 'mirror,reflection', + Office: 'office,workspace', + Desks: 'desk,office', + Standing: 'standing,desk', + 'Electric Desks': 'standing,desk', + 'Manual Desks': 'desk,office', + Sitting: 'desk,office', + 'Compact Desks': 'desk,workspace', + 'Executive Desks': 'desk,office', + Chairs: 'office,chair', + Ergonomic: 'ergonomic,chair', + 'Mesh Chairs': 'office,chair', + 'Leather Chairs': 'leather,chair', + Guest: 'guest,chair', + 'Stacking Chairs': 'stacking,chair', + 'Visitor Chairs': 'office,chair', + Supplies: 'office,supplies', + Paper: 'paper,stationery', + 'A4 Paper': 'paper,stack', + Notebooks: 'notebook,journal', + Writing: 'pen,writing', + Pens: 'pen,fountain', + Markers: 'marker,pen', + Organizers: 'desk,organizer', + Trays: 'tray,desk', + 'File Boxes': 'archive,box', + Outdoor: 'outdoor,garden', + Garden: 'garden,outdoors', + Tools: 'garden,tools', + 'Hand Tools': 'hand,tools', + 'Power Tools': 'power,tools', + Planters: 'planter,pot', + 'Ceramic Pots': 'ceramic,pot', + 'Hanging Baskets': 'hanging,basket', + Benches: 'bench,park', + Loungers: 'lounger,sunbed', + Sports: 'sports,fitness', + Fitness: 'fitness,gym', + Weights: 'dumbbell,weights', + Mats: 'yoga,mat', + Bands: 'resistance,band', + Recreation: 'recreation,sport', + Balls: 'ball,sport', + Rackets: 'tennis,racket', + Kitchen: 'kitchen,cooking', + Cookware: 'cookware,kitchen', + Pots: 'cooking,pot', + 'Sauce Pans': 'saucepan,pot', + 'Stock Pots': 'stockpot,pot', + Pans: 'frying,pan', + 'Frying Pans': 'frying,pan', + Woks: 'wok,pan', + Bakeware: 'bakeware,baking', + 'Baking Trays': 'baking,tray', + 'Cake Tins': 'cake,tin', + Tableware: 'tableware,dishes', + Plates: 'plate,dish', + 'Dinner Plates': 'dinner,plate', + 'Side Plates': 'plate,dish', + Drinkware: 'drinkware,cup', + Mugs: 'mug,coffee', + Glasses: 'glass,drink', + Cutlery: 'cutlery,silverware', + 'Fork Sets': 'fork,cutlery', + 'Knife Sets': 'knife,cutlery', + Food: 'food,grocery', + DryGoods: 'grocery,food', + Pasta: 'pasta,noodles', + Rice: 'rice,grain', + Beans: 'beans,legume', + Beverages: 'beverage,drink', + Coffee: 'coffee,beans', + Tea: 'tea,cup', + Juice: 'juice,orange', + Snacks: 'snack,food', + Nuts: 'nuts,almond', + Bars: 'granola,bar', +}; + +const PRODUCT_STEMS = [ + 'Classic', + 'Premium', + 'Essential', + 'Urban', + 'Nordic', + 'Studio', + 'Heritage', + 'Compact', + 'Pro', + 'Lite', + 'Max', + 'Basic', +]; + +const STEM_RE = new RegExp(`^(${PRODUCT_STEMS.join('|')})\\s+`, 'i'); + + +/** Department → groups → subgroups → leaves (3–4 levels). */ +const TREE = { + 'Home & Living': { + Furniture: { + Seating: ['Armchairs', 'Sofas', 'Stools'], + Tables: ['Coffee Tables', 'Side Tables', 'Dining Tables'], + Storage: ['Shelves', 'Cabinets'], + }, + Decor: { + Lighting: ['Floor Lamps', 'Table Lamps', 'Pendants'], + Textiles: ['Cushions', 'Throws', 'Rugs'], + WallArt: ['Prints', 'Mirrors'], + }, + }, + Office: { + Desks: { + Standing: ['Electric Desks', 'Manual Desks'], + Sitting: ['Compact Desks', 'Executive Desks'], + }, + Chairs: { + Ergonomic: ['Mesh Chairs', 'Leather Chairs'], + Guest: ['Stacking Chairs', 'Visitor Chairs'], + }, + Supplies: { + Paper: ['A4 Paper', 'Notebooks'], + Writing: ['Pens', 'Markers'], + Organizers: ['Trays', 'File Boxes'], + }, + }, + Outdoor: { + Garden: { + Tools: ['Hand Tools', 'Power Tools'], + Planters: ['Ceramic Pots', 'Hanging Baskets'], + Furniture: ['Benches', 'Loungers'], + }, + Sports: { + Fitness: ['Weights', 'Mats', 'Bands'], + Recreation: ['Balls', 'Rackets'], + }, + }, + Kitchen: { + Cookware: { + Pots: ['Sauce Pans', 'Stock Pots'], + Pans: ['Frying Pans', 'Woks'], + Bakeware: ['Baking Trays', 'Cake Tins'], + }, + Tableware: { + Plates: ['Dinner Plates', 'Side Plates'], + Drinkware: ['Mugs', 'Glasses'], + Cutlery: ['Fork Sets', 'Knife Sets'], + }, + Food: { + DryGoods: ['Pasta', 'Rice', 'Beans'], + Beverages: ['Coffee', 'Tea', 'Juice'], + Snacks: ['Nuts', 'Bars'], + }, + }, +}; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function hashBuffer(buf) { + return crypto.createHash('sha256').update(buf).digest('hex'); +} + +function padSku(n) { + return `DEMO-${String(n).padStart(5, '0')}`; +} + +function barcodeFor(n) { + return `200${String(n).padStart(10, '0')}`; +} + +function formatDateTime(date) { + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +function grossPrice(net, taxRate) { + return (Number(net) * (1 + Number(taxRate) / 100)).toFixed(2); +} + +function pick(arr, i) { + return arr[i % arr.length]; +} + +function variantCombos(axes) { + const keys = Object.keys(axes); + if (keys.length === 0) { + return [{}]; + } + let combos = [{}]; + for (const key of keys) { + const next = []; + for (const base of combos) { + for (const value of axes[key]) { + next.push({ ...base, [key]: value }); + } + } + combos = next; + } + return combos; +} + +function formatVariants(combo) { + return Object.entries(combo) + .map(([k, v]) => `${k}: ${v}`) + .join(' | '); +} + +async function mapPool(items, concurrency, fn) { + const results = new Array(items.length); + let index = 0; + + async function worker() { + while (index < items.length) { + const i = index++; + results[i] = await fn(items[i], i); + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); + return results; +} + +function subjectFromProductName(name) { + return String(name || '') + .replace(/\s*\([^)]*\)\s*$/, '') + .replace(STEM_RE, '') + .replace(/\s+Featured\s+\d+$/i, '') + .replace(/\s+Item$/i, '') + .replace(/\s+Starter Kit\s+\d+$/i, '') + .replace(/\s+Kit$/i, '') + .trim(); +} + +function keywordsForSubject(subject) { + if (!subject) { + return 'product'; + } + if (KEYWORD_ALIASES[subject]) { + return KEYWORD_ALIASES[subject]; + } + const cleaned = subjectFromProductName(subject); + if (KEYWORD_ALIASES[cleaned]) { + return KEYWORD_ALIASES[cleaned]; + } + const tags = cleaned + .replace(/&/g, ' ') + .split(/[\s/_-]+/) + .map((w) => w.toLowerCase().replace(/[^a-z0-9]/g, '')) + .filter((w) => w.length > 2 && !['the', 'and', 'set', 'sets'].includes(w)) + .slice(0, 3); + return tags.length > 0 ? tags.join(',') : 'product'; +} + +function lockFromSeed(seed) { + const hex = crypto.createHash('sha1').update(String(seed)).digest('hex').slice(0, 8); + return Number.parseInt(hex, 16) % 1_000_000_000 || 1; +} + +async function fetchBuffer(url, retries = 4) { + for (let attempt = 0; attempt < retries; attempt++) { + try { + const res = await fetch(url, { + redirect: 'follow', + headers: { 'User-Agent': 'jtlsrv-demo-catalog/1.0' }, + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.length < 1000) { + throw new Error('image too small'); + } + await sharp(buf).metadata(); + return buf; + } catch (err) { + if (attempt === retries - 1) { + throw err; + } + await sleep(250 * (attempt + 1)); + } + } + throw new Error('unreachable'); +} + +const rawImageCache = new Map(); + +async function fetchMatchingImage(keywords, lock) { + const cacheKey = `${keywords}|${lock}`; + if (rawImageCache.has(cacheKey)) { + return rawImageCache.get(cacheKey); + } + + const pathTags = String(keywords) + .split(',') + .map((tag) => encodeURIComponent(tag.trim())) + .filter(Boolean) + .join(','); + + try { + const flickrUrl = `https://loremflickr.com/${IMAGE_SIZE}/${IMAGE_SIZE}/${pathTags}?lock=${lock}`; + const buf = await fetchBuffer(flickrUrl); + rawImageCache.set(cacheKey, buf); + return buf; + } catch (err) { + console.warn(` loremflickr miss (${keywords}): ${err.message}; falling back to picsum`); + } + + const picsumUrl = `https://picsum.photos/seed/${encodeURIComponent(`${keywords}-${lock}`)}/${IMAGE_SIZE}/${IMAGE_SIZE}.jpg`; + const buf = await fetchBuffer(picsumUrl); + rawImageCache.set(cacheKey, buf); + return buf; +} + +async function processImage(raw, { tintKey = null, label = null } = {}) { + let pipeline = sharp(raw).resize(IMAGE_SIZE, IMAGE_SIZE, { fit: 'cover' }); + + if (tintKey && TINTS[tintKey]) { + const { r, g, b } = TINTS[tintKey]; + const overlay = await sharp({ + create: { + width: IMAGE_SIZE, + height: IMAGE_SIZE, + channels: 4, + background: { r, g, b, alpha: 0.28 }, + }, + }) + .png() + .toBuffer(); + pipeline = sharp(await pipeline.toBuffer()).composite([{ input: overlay, blend: 'over' }]); + } + + if (label) { + const safe = String(label) + .replace(/&/g, '&') + .replace(//g, '>') + .slice(0, 40); + const svg = Buffer.from(` + + + ${safe} + + `); + pipeline = sharp(await pipeline.toBuffer()).composite([{ input: svg, blend: 'over' }]); + } + + return pipeline.jpeg({ quality: JPEG_QUALITY, mozjpeg: true }).toBuffer(); +} + +async function saveImage(jpegBuffer) { + const hash = hashBuffer(jpegBuffer); + const filePath = path.join(IMAGES_DIR, `${hash}.jpg`); + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, jpegBuffer); + } + return hash; +} + +function buildCategoryTree() { + const categories = []; + let nextId = 1; + let sort = 0; + let lastChanged = 1; + + function add(name, pid, depth) { + const id = nextId++; + categories.push({ + _id: String(id), + name, + pid: pid === null ? '0' : String(pid), + sort: String(++sort), + lastChanged: String(lastChanged++), + imghash: null, + imgsrc: null, + discounts: [], + depth, + }); + return id; + } + + for (const [dept, groups] of Object.entries(TREE)) { + const deptId = add(dept, null, 1); + for (const [group, subgroups] of Object.entries(groups)) { + const groupId = add(group, deptId, 2); + for (const [subgroup, leaves] of Object.entries(subgroups)) { + const subgroupId = add(subgroup, groupId, 3); + for (const leaf of leaves) { + add(leaf, subgroupId, 4); + } + } + } + } + + return categories; +} + +function chooseAxes(leafName, familyIndex) { + const foodish = /Pasta|Rice|Beans|Coffee|Tea|Juice|Nuts|Bars|Paper|Notebooks/.test(leafName); + const furnitureish = /Chair|Sofa|Table|Desk|Shelf|Cabinet|Bench|Lounger|Lamp|Armchair|Stool/.test(leafName); + + const mode = familyIndex % 5; + if (foodish || mode === 0) { + return { Size: PACKAGE_SIZES.slice(0, 4 + (familyIndex % 3)) }; + } + if (furnitureish || mode === 1) { + return { + Color: COLORS.slice(0, 3 + (familyIndex % 3)), + Material: MATERIALS.slice(0, 2 + (familyIndex % 2)), + }; + } + if (mode === 2) { + return { Color: COLORS.slice(0, 4 + (familyIndex % 3)) }; + } + if (mode === 3) { + return { Material: MATERIALS.slice(0, 3 + (familyIndex % 3)) }; + } + return { + Color: COLORS.slice(0, 2 + (familyIndex % 2)), + Size: PACKAGE_SIZES.slice(0, 2 + (familyIndex % 2)), + }; +} + +const MIN_PRODUCTS_PER_CATEGORY = 5; + +function buildProducts(categories, customerGroupIds) { + const products = []; + const composites = []; + let nextId = 1; + let lastChanged = 1; + let familyIndex = 0; + const createdAt = formatDateTime(new Date('2024-01-15T10:00:00Z')); + const taxRate = 19; + const leafCategories = categories.filter((c) => c.depth === 4); + + function pricesFor(net) { + const base = grossPrice(net, taxRate); + const wholesaleNet = Number(net) * 0.85; + return customerGroupIds.map((customerGroupId, i) => ({ + customerGroupId: String(customerGroupId), + customerId: '0', + price: i === 0 ? base : grossPrice(wholesaleNet, taxRate), + quantity: '0', + })); + } + + function pushProduct(partial) { + const id = nextId++; + const net = partial.netPrice ?? 9.99 + (id % 80) * 1.25; + const product = { + _id: String(id), + sku: padSku(id), + barcode: barcodeFor(id), + name: partial.name, + tax_rate: String(taxRate), + price: grossPrice(net, taxRate), + created_at: createdAt, + lastChanged: String(lastChanged++), + categories_id: partial.categoryId, + categories: [{ categoryId: partial.categoryId }], + prices: pricesFor(net), + is_parent: partial.is_parent ?? '0', + parent: partial.parent ?? '0', + variants: partial.variants ?? '', + isCompositeProduct: partial.isCompositeProduct ?? '0', + attributes: [], + imghash: null, + imgsrc: null, + _tintKey: partial.tintKey ?? null, + _label: partial.label ?? null, + _seed: partial.seed, + }; + products.push(product); + return product; + } + + function countByCategory() { + const counts = new Map(); + for (const product of products) { + const id = product.categories_id; + counts.set(id, (counts.get(id) || 0) + 1); + } + return counts; + } + + // Variant families on leaf categories (majority of catalog) + for (const leaf of leafCategories) { + const familiesPerLeaf = 2 + (Number(leaf._id) % 3); // 2–4 families + for (let f = 0; f < familiesPerLeaf; f++) { + familyIndex++; + const stem = pick(PRODUCT_STEMS, familyIndex); + const baseName = `${stem} ${leaf.name}`; + const axes = chooseAxes(leaf.name, familyIndex); + const combos = variantCombos(axes).slice(0, 8); + + const parent = pushProduct({ + name: baseName, + categoryId: leaf._id, + is_parent: '1', + parent: '0', + variants: '', + seed: `parent-${leaf._id}-${f}`, + netPrice: 15 + (familyIndex % 40), + }); + + for (let c = 0; c < combos.length; c++) { + const combo = combos[c]; + const tintKey = combo.Color || null; + pushProduct({ + name: `${baseName} (${formatVariants(combo)})`, + categoryId: leaf._id, + is_parent: '0', + parent: parent._id, + variants: formatVariants(combo), + tintKey, + label: formatVariants(combo), + seed: `var-${leaf._id}-${f}-${c}`, + netPrice: 15 + (familyIndex % 40) + c * 0.5, + }); + } + + if (products.length >= TARGET_PRODUCTS) { + break; + } + } + if (products.length >= TARGET_PRODUCTS) { + break; + } + } + + // Dedicated simple kit products for BOM (not variant parents/children) + const kitLeaf = leafCategories[0]; + for (let i = 0; i < 8; i++) { + const kit = pushProduct({ + name: `${pick(PRODUCT_STEMS, i + 7)} Starter Kit ${i + 1}`, + categoryId: kitLeaf._id, + isCompositeProduct: '1', + seed: `kit-${i}`, + netPrice: 49 + i * 5, + }); + const components = products + .filter((p) => p.is_parent === '0' && p.parent !== '0' && p._id !== kit._id) + .slice(i * 3, i * 3 + 3); + for (const comp of components) { + composites.push({ + productId: kit._id, + productIdComponent: comp._id, + quantity: (1 + (i % 3)).toFixed(2), + lastChanged: kit.lastChanged, + }); + } + } + + // Fill remaining with simple products across leaves + let simpleIndex = 0; + while (products.length < TARGET_PRODUCTS) { + const leaf = leafCategories[simpleIndex % leafCategories.length]; + const stem = pick(PRODUCT_STEMS, simpleIndex + 3); + pushProduct({ + name: `${stem} ${leaf.name} Item`, + categoryId: leaf._id, + seed: `simple-${leaf._id}-${simpleIndex}`, + netPrice: 4.5 + (simpleIndex % 50), + }); + simpleIndex++; + } + + // Every category (including top-level / intermediate) gets ≥5 products + let ensureIndex = 0; + for (const category of categories) { + const counts = countByCategory(); + const have = counts.get(category._id) || 0; + for (let i = have; i < MIN_PRODUCTS_PER_CATEGORY; i++) { + const stem = pick(PRODUCT_STEMS, ensureIndex + i); + pushProduct({ + name: `${stem} ${category.name} Featured ${i + 1}`, + categoryId: category._id, + seed: `ensure-${category._id}-${i}`, + netPrice: 8 + ((ensureIndex + i) % 40), + }); + } + ensureIndex++; + } + + return { products, composites }; +} + +async function main() { + console.log('Generating demo catalog…'); + fs.mkdirSync(IMAGES_DIR, { recursive: true }); + + // Clear previous images + for (const file of fs.readdirSync(IMAGES_DIR)) { + fs.unlinkSync(path.join(IMAGES_DIR, file)); + } + + const categories = buildCategoryTree(); + const leafCategories = categories.filter((c) => c.depth === 4); + console.log(`Categories: ${categories.length} (leaves: ${leafCategories.length})`); + + const customerGroups = [ + { + customerGroupId: '1', + name: 'Standard', + standard: '1', + discountPercent: '0.00', + lastChanged: '1', + }, + { + customerGroupId: '2', + name: 'Wholesale', + standard: '0', + discountPercent: '10.00', + lastChanged: '2', + }, + ]; + + const { products, composites } = buildProducts( + categories, + customerGroups.map((g) => Number(g.customerGroupId)) + ); + const perCategory = new Map(); + for (const product of products) { + perCategory.set(product.categories_id, (perCategory.get(product.categories_id) || 0) + 1); + } + const minPerCategory = Math.min(...categories.map((c) => perCategory.get(c._id) || 0)); + console.log( + `Products: ${products.length} (composites links: ${composites.length}, min per category: ${minPerCategory})` + ); + + const categoryById = new Map(categories.map((c) => [c._id, c])); + + const imageJobs = [ + ...categories.map((c) => ({ + kind: 'category', + ref: c, + keywords: keywordsForSubject(c.name), + lock: lockFromSeed(`cat-${c._id}`), + tintKey: null, + label: c.name, + })), + ...products.map((p) => { + const category = categoryById.get(p.categories_id); + const subject = + p.isCompositeProduct === '1' + ? 'gift basket' + : category?.name || subjectFromProductName(p.name); + const lockSeed = p.parent !== '0' ? `parent-${p.parent}` : `product-${p._id}`; + return { + kind: 'product', + ref: p, + keywords: keywordsForSubject(subject), + lock: lockFromSeed(lockSeed), + tintKey: p._tintKey, + label: p.is_parent === '1' ? p.name : p._label, + }; + }), + ]; + + console.log(`Downloading / processing ${imageJobs.length} images (keyword-matched via LoremFlickr)…`); + let done = 0; + await mapPool(imageJobs, DOWNLOAD_CONCURRENCY, async (job) => { + const raw = await fetchMatchingImage(job.keywords, job.lock); + const jpeg = await processImage(raw, { tintKey: job.tintKey, label: job.label }); + const hash = await saveImage(jpeg); + job.ref.imghash = hash; + job.ref.imgsrc = hash; + done++; + if (done % 50 === 0 || done === imageJobs.length) { + console.log(` images ${done}/${imageJobs.length} (unique fetches: ${rawImageCache.size})`); + } + }); + + // Strip generator-only fields + for (const p of products) { + delete p._tintKey; + delete p._label; + delete p._seed; + } + for (const c of categories) { + delete c.depth; + } + + const deletedEntities = [ + { + entityId: '999001', + entityType: '1', + lastChanged: '1', + }, + ]; + + const catalog = { + version: 1, + generatedAt: new Date().toISOString(), + customerGroups, + categories, + products, + composites, + deletedEntities, + maxOrderId: 0, + }; + + const catalogPath = path.join(DEMO_DIR, 'catalog.json'); + fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2)); + + const imageCount = fs.readdirSync(IMAGES_DIR).length; + console.log(`Wrote ${catalogPath}`); + console.log(`Images on disk: ${imageCount}`); + console.log('Done.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server.js b/server.js index d40af10..9eff0db 100644 --- a/server.js +++ b/server.js @@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url'; import 'dotenv/config'; import { connectDb, closeDb } from './src/db.js'; +import { isDemoMode } from './src/demo/mode.js'; +import { loadDemoCatalog } from './src/demo/store.js'; import { createJtlPosServer } from './src/jtl-server.js'; import { createPairingStore } from './src/pairing.js'; import { closeOrderLog } from './src/order-log.js'; @@ -113,14 +115,22 @@ const httpsServer = https.createServer( ); async function start() { - try { - const pool = await connectDb(); - logger.success(`MSSQL connected: ${process.env.MSSQL_SERVER}/${process.env.MSSQL_DATABASE}`); - const activeShop = await fetchActiveShop(pool); - logger.info(`Active shop ID: ${activeShop}`); - } catch (err) { - logger.warn(`MSSQL connection skipped: ${err.message}`); - logger.warn('POS handshake will still work; sync from database is not available yet.'); + if (isDemoMode()) { + const stats = await loadDemoCatalog(); + logger.success( + `DEMO_MODE: loaded catalog (${stats.products} products, ${stats.categories} categories, ${stats.customerGroups} customer groups, ${stats.composites} composite links)` + ); + logger.info('MSSQL is skipped while DEMO_MODE=true'); + } else { + try { + const pool = await connectDb(); + logger.success(`MSSQL connected: ${process.env.MSSQL_SERVER}/${process.env.MSSQL_DATABASE}`); + const activeShop = await fetchActiveShop(pool); + logger.info(`Active shop ID: ${activeShop}`); + } catch (err) { + logger.warn(`MSSQL connection skipped: ${err.message}`); + logger.warn('POS handshake will still work; sync from database is not available yet.'); + } } httpsServer.listen(PORT, '0.0.0.0', () => { diff --git a/src/queries/category-count.js b/src/queries/category-count.js index 4bca338..ee17a31 100644 --- a/src/queries/category-count.js +++ b/src/queries/category-count.js @@ -1,4 +1,6 @@ import sql from 'mssql'; +import { isDemoMode } from '../demo/mode.js'; +import { getDemoCategoryCount } from '../demo/store.js'; import { getPool } from '../db.js'; import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js'; @@ -12,6 +14,10 @@ WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree) `; export async function getCategoryCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) { + if (isDemoMode()) { + return getDemoCategoryCount({ cursor }); + } + const result = await categoryTreeRequest(getPool(), rootCategoryId) .input('cursor', sql.BigInt, cursor) .query(CATEGORY_COUNT_SQL); diff --git a/src/queries/category-list.js b/src/queries/category-list.js index 5a56fc2..27c33a9 100644 --- a/src/queries/category-list.js +++ b/src/queries/category-list.js @@ -1,4 +1,6 @@ import sql from 'mssql'; +import { isDemoMode } from '../demo/mode.js'; +import { getDemoCategoryList } from '../demo/store.js'; import { getPool } from '../db.js'; import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js'; @@ -26,6 +28,10 @@ ORDER BY lastChanged ASC; `; export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId = getRootCategoryId() } = {}) { + if (isDemoMode()) { + return getDemoCategoryList({ cursor, limit }); + } + const result = await categoryTreeRequest(getPool(), rootCategoryId) .input('cursor', sql.BigInt, cursor) .input('limit', sql.Int, limit) diff --git a/src/queries/composite-product-count.js b/src/queries/composite-product-count.js index d0fd59d..f8d0ab9 100644 --- a/src/queries/composite-product-count.js +++ b/src/queries/composite-product-count.js @@ -1,4 +1,6 @@ import sql from 'mssql'; +import { isDemoMode } from '../demo/mode.js'; +import { getDemoCompositeProductCount } from '../demo/store.js'; import { getPool } from '../db.js'; import { getActiveShopId } from '../shop.js'; @@ -16,6 +18,10 @@ WHERE a.kStueckliste <> 0 `; export async function getCompositeProductCount({ cursor = 0 } = {}) { + if (isDemoMode()) { + return getDemoCompositeProductCount({ cursor }); + } + const result = await getPool() .request() .input('cursor', sql.BigInt, cursor) diff --git a/src/queries/composite-product-list.js b/src/queries/composite-product-list.js index cd514d8..910cf8b 100644 --- a/src/queries/composite-product-list.js +++ b/src/queries/composite-product-list.js @@ -1,4 +1,6 @@ import sql from 'mssql'; +import { isDemoMode } from '../demo/mode.js'; +import { getDemoCompositeProductList } from '../demo/store.js'; import { getPool } from '../db.js'; import { getActiveShopId } from '../shop.js'; @@ -21,6 +23,10 @@ ORDER BY lastChanged ASC; `; export async function getCompositeProductList({ cursor = 0, limit = 100 } = {}) { + if (isDemoMode()) { + return getDemoCompositeProductList({ cursor, limit }); + } + const result = await getPool() .request() .input('cursor', sql.BigInt, cursor) diff --git a/src/queries/create-order.js b/src/queries/create-order.js index 4e71a0c..560a3ee 100644 --- a/src/queries/create-order.js +++ b/src/queries/create-order.js @@ -1,4 +1,6 @@ import sql from 'mssql'; +import { isDemoMode } from '../demo/mode.js'; +import { createDemoOrder } from '../demo/store.js'; import { getPool } from '../db.js'; import { getActiveShopId, getActiveShopSubshopId } from '../shop.js'; import { deliverOrder } from './delivery/index.js'; @@ -653,6 +655,10 @@ async function insertPayment(transaction, kAuftrag, payment, order, orderDate, z } export async function createOrder(order) { + if (isDemoMode()) { + return createDemoOrder(order); + } + const kPosAuftrag = Number.parseInt(order.externalId, 10); const externalOrderNumber = order.externalOrderNumber || ''; if (Number.isInteger(kPosAuftrag) && kPosAuftrag > 0) { diff --git a/src/queries/customer-groups.js b/src/queries/customer-groups.js index 972cf6c..8333d77 100644 --- a/src/queries/customer-groups.js +++ b/src/queries/customer-groups.js @@ -1,4 +1,10 @@ import sql from 'mssql'; +import { isDemoMode } from '../demo/mode.js'; +import { + getDemoCustomerGroupCount, + getDemoCustomerGroupIds, + getDemoCustomerGroupList, +} from '../demo/store.js'; import { getPool } from '../db.js'; const CUSTOMER_GROUP_IDS_SQL = ` @@ -26,11 +32,19 @@ WHERE CONVERT(BIGINT, bRowversion) > @cursor; `; export async function getCustomerGroupIds() { + if (isDemoMode()) { + return getDemoCustomerGroupIds(); + } + const result = await getPool().request().query(CUSTOMER_GROUP_IDS_SQL); return result.recordset.map((row) => row.kKundenGruppe); } export async function getCustomerGroupList({ cursor = 0 } = {}) { + if (isDemoMode()) { + return getDemoCustomerGroupList({ cursor }); + } + const result = await getPool().request().input('cursor', sql.BigInt, cursor).query(CUSTOMER_GROUP_LIST_SQL); return result.recordset.map((row) => ({ @@ -43,6 +57,10 @@ export async function getCustomerGroupList({ cursor = 0 } = {}) { } export async function getCustomerGroupCount({ cursor = 0 } = {}) { + if (isDemoMode()) { + return getDemoCustomerGroupCount({ cursor }); + } + const result = await getPool().request().input('cursor', sql.BigInt, cursor).query(CUSTOMER_GROUP_COUNT_SQL); return result.recordset[0]?.CustomerGroupCount ?? 0; } diff --git a/src/queries/deleted-entity-count.js b/src/queries/deleted-entity-count.js index ab2f9c1..e169e53 100644 --- a/src/queries/deleted-entity-count.js +++ b/src/queries/deleted-entity-count.js @@ -1,4 +1,6 @@ import sql from 'mssql'; +import { isDemoMode } from '../demo/mode.js'; +import { getDemoDeletedEntityCount } from '../demo/store.js'; import { getPool } from '../db.js'; const DELETED_ENTITY_COUNT_SQL = ` @@ -8,6 +10,10 @@ WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > @cursor; `; export async function getDeletedEntityCount({ cursor = 0 } = {}) { + if (isDemoMode()) { + return getDemoDeletedEntityCount({ cursor }); + } + const result = await getPool() .request() .input('cursor', sql.BigInt, cursor) diff --git a/src/queries/deleted-entity-list.js b/src/queries/deleted-entity-list.js index 071259b..88bc38d 100644 --- a/src/queries/deleted-entity-list.js +++ b/src/queries/deleted-entity-list.js @@ -1,4 +1,6 @@ import sql from 'mssql'; +import { isDemoMode } from '../demo/mode.js'; +import { getDemoDeletedEntityList } from '../demo/store.js'; import { getPool } from '../db.js'; const DELETED_ENTITY_LIST_SQL = ` @@ -12,6 +14,10 @@ ORDER BY lastChanged ASC; `; export async function getDeletedEntityList({ cursor = 0, limit = 600 } = {}) { + if (isDemoMode()) { + return getDemoDeletedEntityList({ cursor, limit }); + } + const result = await getPool() .request() .input('cursor', sql.BigInt, cursor) diff --git a/src/queries/image.js b/src/queries/image.js index c6fd14c..7bc65f8 100644 --- a/src/queries/image.js +++ b/src/queries/image.js @@ -1,4 +1,6 @@ import sql from 'mssql'; +import { isDemoMode } from '../demo/mode.js'; +import { getDemoImageByHash } from '../demo/store.js'; import { getPool } from '../db.js'; const IMAGE_BY_HASH_SQL = ` @@ -24,6 +26,10 @@ function contentTypeFor(cQuelle) { } export async function getImageByHash(hash, size) { + if (isDemoMode()) { + return getDemoImageByHash(hash); + } + const result = await getPool().request().input('hash', sql.NVarChar, hash).query(IMAGE_BY_HASH_SQL); const row = result.recordset[0]; diff --git a/src/queries/max-order-id.js b/src/queries/max-order-id.js index e0ce660..08d31a4 100644 --- a/src/queries/max-order-id.js +++ b/src/queries/max-order-id.js @@ -1,4 +1,6 @@ import sql from 'mssql'; +import { isDemoMode } from '../demo/mode.js'; +import { getDemoMaxOrderIdCount } from '../demo/store.js'; import { getPool } from '../db.js'; import { getActiveShopSubshopId } from '../shop.js'; @@ -9,6 +11,10 @@ WHERE kShopSubShop = @kShopSubShop; `; export async function getMaxOrderIdCount() { + if (isDemoMode()) { + return getDemoMaxOrderIdCount(); + } + const kShopSubShop = getActiveShopSubshopId(); if (!kShopSubShop) { return 0; diff --git a/src/queries/product-count.js b/src/queries/product-count.js index 9ae1bff..8c307d8 100644 --- a/src/queries/product-count.js +++ b/src/queries/product-count.js @@ -1,4 +1,6 @@ import sql from 'mssql'; +import { isDemoMode } from '../demo/mode.js'; +import { getDemoProductCount } from '../demo/store.js'; import { getPool } from '../db.js'; import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js'; @@ -22,6 +24,10 @@ WHERE a.cAktiv = 'Y' `; export async function getProductCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) { + if (isDemoMode()) { + return getDemoProductCount({ cursor }); + } + const result = await categoryTreeRequest(getPool(), rootCategoryId) .input('cursor', sql.BigInt, cursor) .query(PRODUCT_COUNT_SQL); diff --git a/src/queries/product-list.js b/src/queries/product-list.js index 0c8f47f..3f599d7 100644 --- a/src/queries/product-list.js +++ b/src/queries/product-list.js @@ -1,4 +1,6 @@ import sql from 'mssql'; +import { isDemoMode } from '../demo/mode.js'; +import { getDemoProductList } from '../demo/store.js'; import { getPool } from '../db.js'; import { getCustomerGroupIds } from './customer-groups.js'; import { getProductAttributes } from './product-attributes.js'; @@ -93,6 +95,10 @@ function grossPrice(netPrice, taxRate) { } export async function getProductList({ cursor = 0, limit = 20 } = {}) { + if (isDemoMode()) { + return getDemoProductList({ cursor, limit }); + } + const pool = getPool(); const [productResult, customerGroupIds] = await Promise.all([