u
This commit is contained in:
@@ -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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,4 +7,5 @@ decompiledReference
|
||||
scripts/s3-backup/data/
|
||||
scripts/s3-backup/tmp/
|
||||
scripts/s3-backup/certs/
|
||||
scripts/minimal-db/data/
|
||||
scripts/minimal-db/data/
|
||||
demo/
|
||||
13
API.md
13
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/<hash>.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.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
781
scripts/generate-demo-catalog.mjs
Normal file
781
scripts/generate-demo-catalog.mjs
Normal file
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.slice(0, 40);
|
||||
const svg = Buffer.from(`
|
||||
<svg width="${IMAGE_SIZE}" height="${IMAGE_SIZE}">
|
||||
<rect x="0" y="${IMAGE_SIZE - 48}" width="${IMAGE_SIZE}" height="48" fill="rgba(0,0,0,0.45)"/>
|
||||
<text x="16" y="${IMAGE_SIZE - 18}" font-family="sans-serif" font-size="22" fill="white">${safe}</text>
|
||||
</svg>
|
||||
`);
|
||||
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);
|
||||
});
|
||||
26
server.js
26
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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user