Compare commits
9 Commits
538ecf7a80
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6312eaec48 | ||
|
|
3e8ea1ef76 | ||
|
|
e9f29dc569 | ||
|
|
10c4269da0 | ||
|
|
94e44e8e61 | ||
|
|
b9c574074f | ||
|
|
ba705c7d08 | ||
|
|
fc7afd1be8 | ||
|
|
c27ad52e2a |
@@ -1,3 +1,6 @@
|
|||||||
|
# Demo catalog (skips MSSQL; requires `npm run demo:generate` first)
|
||||||
|
DEMO_MODE=false
|
||||||
|
|
||||||
# HTTPS POS server
|
# HTTPS POS server
|
||||||
PORT=4443
|
PORT=4443
|
||||||
AUTH_TOKEN=df40ad2067954646abb0499548a52241
|
AUTH_TOKEN=df40ad2067954646abb0499548a52241
|
||||||
@@ -5,11 +8,6 @@ PAIRING_CODE=307018
|
|||||||
LOG_FILE=logs/requests.log
|
LOG_FILE=logs/requests.log
|
||||||
ORDER_LOG_FILE=logs/orders.log
|
ORDER_LOG_FILE=logs/orders.log
|
||||||
|
|
||||||
# TLS certificate metadata returned during pairing
|
|
||||||
CERTIFICATE_FINGERPRINT=BC2114CF407A42724BEEF417960F76DCBF9DE879
|
|
||||||
CERTIFICATE_SERIAL_NUMBER=00BFC8BEACDB981B165210EF111CB9D3
|
|
||||||
SERVER_FINGERPRINT=39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE
|
|
||||||
|
|
||||||
# Mandant
|
# Mandant
|
||||||
MANDANT_ID=1
|
MANDANT_ID=1
|
||||||
MANDANT_NAME=eB-Standard
|
MANDANT_NAME=eB-Standard
|
||||||
|
|||||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -1,10 +1,12 @@
|
|||||||
node_modules/
|
/node_modules/
|
||||||
.env
|
/.env
|
||||||
certs/
|
/certs/
|
||||||
logs/
|
/logs/
|
||||||
capturedDataReference
|
/capturedDataReference
|
||||||
decompiledReference
|
/decompiledReference
|
||||||
scripts/s3-backup/data/
|
/scripts/s3-backup/data/
|
||||||
scripts/s3-backup/tmp/
|
/scripts/s3-backup/tmp/
|
||||||
scripts/s3-backup/certs/
|
/scripts/s3-backup/certs/
|
||||||
scripts/minimal-db/data/
|
/scripts/minimal-db/data/
|
||||||
|
# Generated demo catalog assets (keep src/demo/ source tracked)
|
||||||
|
/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
|
## `GET /v1/client` — Pairing
|
||||||
|
|
||||||
Discovers the server and completes pairing with a 6-digit code.
|
Discovers the server and completes pairing with a 6-digit code.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -12,10 +13,50 @@ const certPath = path.join(certsDir, 'cert.pem');
|
|||||||
|
|
||||||
fs.mkdirSync(certsDir, { recursive: true });
|
fs.mkdirSync(certsDir, { recursive: true });
|
||||||
|
|
||||||
// ECDSA P-256 keeps pairing QR codes much smaller than RSA-2048
|
function isIp(value) {
|
||||||
const subject = '/CN=localhost/O=JTL POS Sync/C=DE';
|
return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(':');
|
||||||
const san = 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0';
|
}
|
||||||
|
|
||||||
|
function localIpv4s() {
|
||||||
|
const ips = [];
|
||||||
|
for (const entries of Object.values(os.networkInterfaces())) {
|
||||||
|
for (const entry of entries || []) {
|
||||||
|
if (entry.family !== 'IPv4' || entry.internal) continue;
|
||||||
|
// Skip link-local / docker / libvirt bridge noise by default — keep LAN + extras via args
|
||||||
|
if (entry.address.startsWith('169.254.')) continue;
|
||||||
|
if (entry.address.startsWith('172.17.')) continue;
|
||||||
|
if (entry.address.startsWith('192.168.122.')) continue;
|
||||||
|
ips.push(entry.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dnsNames = new Set(['localhost']);
|
||||||
|
const ipAddrs = new Set(['127.0.0.1', '0.0.0.0']);
|
||||||
|
|
||||||
|
for (const ip of localIpv4s()) {
|
||||||
|
ipAddrs.add(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extras = [
|
||||||
|
...(process.env.CERT_SAN || '').split(/[,\s]+/).filter(Boolean),
|
||||||
|
...process.argv.slice(2),
|
||||||
|
];
|
||||||
|
for (const value of extras) {
|
||||||
|
if (isIp(value)) ipAddrs.add(value);
|
||||||
|
else dnsNames.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanParts = [
|
||||||
|
...[...dnsNames].map((name) => `DNS:${name}`),
|
||||||
|
...[...ipAddrs].map((ip) => `IP:${ip}`),
|
||||||
|
];
|
||||||
|
const san = `subjectAltName=${sanParts.join(',')}`;
|
||||||
|
const cn = [...dnsNames][0] || 'localhost';
|
||||||
|
const subject = `/CN=${cn}/O=JTL POS Sync/C=DE`;
|
||||||
|
|
||||||
|
// ECDSA P-256 keeps pairing QR codes much smaller than RSA-2048
|
||||||
execSync(
|
execSync(
|
||||||
`openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout "${keyPath}" -out "${certPath}" -days 3650 -subj "${subject}" -addext "${san}"`,
|
`openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout "${keyPath}" -out "${certPath}" -days 3650 -subj "${subject}" -addext "${san}"`,
|
||||||
{ stdio: 'inherit' }
|
{ stdio: 'inherit' }
|
||||||
@@ -34,6 +75,6 @@ const serial = execSync(`openssl x509 -in "${certPath}" -noout -serial`, {
|
|||||||
|
|
||||||
logger.success(`Wrote ${keyPath}`);
|
logger.success(`Wrote ${keyPath}`);
|
||||||
logger.success(`Wrote ${certPath}`);
|
logger.success(`Wrote ${certPath}`);
|
||||||
logger.info(`CERTIFICATE_FINGERPRINT=${sha1.replace(/:/g, '')}`);
|
logger.info(`SAN: ${sanParts.join(', ')}`);
|
||||||
logger.info(`CERTIFICATE_SERIAL_NUMBER=${serial}`);
|
logger.info(`Fingerprint: ${sha1.replace(/:/g, '')}`);
|
||||||
logger.info(`SERVER_FINGERPRINT=${sha1.replace(/:/g, '-')}`);
|
logger.info(`Serial: ${serial}`);
|
||||||
|
|||||||
@@ -79,17 +79,25 @@ The server starts without MSSQL if `MSSQL_USER` is unset or the connection fails
|
|||||||
|
|
||||||
## TLS certificates
|
## TLS certificates
|
||||||
|
|
||||||
Place a certificate and key at `certs/cert.pem` and `certs/key.pem` (relative to the working directory when you run the binary).
|
From the repo root (preferred — picks up LAN IPs automatically):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run cert
|
||||||
|
# optional extras:
|
||||||
|
npm run cert -- 192.168.188.22 sync.quixpos.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p certs
|
mkdir -p certs
|
||||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
|
||||||
-keyout certs/key.pem -out certs/cert.pem -days 3650 \
|
-keyout certs/key.pem -out certs/cert.pem -days 3650 \
|
||||||
-subj '/CN=localhost/O=JTL POS Sync/C=DE' \
|
-subj '/CN=localhost/O=JTL POS Sync/C=DE' \
|
||||||
-addext 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0'
|
-addext 'subjectAltName=DNS:localhost,DNS:sync.quixpos.com,IP:127.0.0.1,IP:0.0.0.0,IP:192.168.188.22'
|
||||||
```
|
```
|
||||||
|
|
||||||
On startup the server prints the pairing code and whether MSSQL connected.
|
Place `certs/cert.pem` and `certs/key.pem` relative to the working directory when you run the binary. The browser hostname check requires the address you open (`192.168.x.x` or a DNS name) to appear in the certificate SAN — trusting a CA alone is not enough.
|
||||||
|
|
||||||
## API endpoints
|
## API endpoints
|
||||||
|
|
||||||
|
|||||||
28
jtlsrv-cpp/logs/orders.log
Normal file
28
jtlsrv-cpp/logs/orders.log
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
2026-07-14T20:11:01 #1 externalId=79 {"ShippingDate":"","amountBack":"0","amountGiven":"0.08","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-13 07:40:35","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"79","externalOrderNumber":"R00081","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"126","isReturn":"0","name":"BioBizz Lightmix 50 Liter","note":"","orderItemId":"","priceGross":"0.08","priceNet":"0.08","purchasePriceNet":"0.0","quantity":"1","sku":"4","totalPriceGross":"0.08","totalPriceNet":"0.08","type":"1","unit":"","vat":"0.00"}],"paymentMethodName":"BAR","payments":[{"amount":"0.08","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"0.08","totalNet":"0.08","type":"0","username":"admin"}
|
||||||
|
2026-07-14T20:14:29 #1 externalId=80 {"ShippingDate":"","amountBack":"0","amountGiven":"11","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:12:47","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"80","externalOrderNumber":"R00082","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"127","isReturn":"0","name":"Peach Cotton Candy 1g","note":"","orderItemId":"","priceGross":"11","priceNet":"9.243697478991596","purchasePriceNet":"0.0","quantity":"1","sku":"42001111-1","totalPriceGross":"11","totalPriceNet":"9.24","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"11","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"11","totalNet":"9.24","type":"0","username":"admin"}
|
||||||
|
2026-07-14T20:15:38 #1 externalId=99999 {"currencyIso":"EUR","externalId":"99999","orderItems":[],"paymentMethodName":"Bar"}
|
||||||
|
2026-07-14T20:16:01 #1 externalId=81 {"ShippingDate":"","amountBack":"0","amountGiven":"45","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:14:12","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"81","externalOrderNumber":"R00083","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"128","isReturn":"0","name":"Vending Machine 5g","note":"","orderItemId":"","priceGross":"45","priceNet":"37.81512605042017","purchasePriceNet":"0.0","quantity":"1","sku":"42001099-9","totalPriceGross":"45","totalPriceNet":"37.82","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"45","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"45","totalNet":"37.82","type":"0","username":"admin"}
|
||||||
|
2026-07-14T20:16:45 #1 externalId=81 {"ShippingDate":"","amountBack":"0","amountGiven":"45","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:14:12","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"81","externalOrderNumber":"R00083","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"128","isReturn":"0","name":"Vending Machine 5g","note":"","orderItemId":"","priceGross":"45","priceNet":"37.81512605042017","purchasePriceNet":"0.0","quantity":"1","sku":"42001099-9","totalPriceGross":"45","totalPriceNet":"37.82","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"45","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"45","totalNet":"37.82","type":"0","username":"admin"}
|
||||||
|
2026-07-14T20:17:02 #1 externalId=81 {"ShippingDate":"","amountBack":"0","amountGiven":"45","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:14:12","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"81","externalOrderNumber":"R00083","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"128","isReturn":"0","name":"Vending Machine 5g","note":"","orderItemId":"","priceGross":"45","priceNet":"37.81512605042017","purchasePriceNet":"0.0","quantity":"1","sku":"42001099-9","totalPriceGross":"45","totalPriceNet":"37.82","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"45","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"45","totalNet":"37.82","type":"0","username":"admin"}
|
||||||
|
2026-07-14T20:18:27 #1 externalId=81 {"ShippingDate":"","amountBack":"0","amountGiven":"45","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:14:12","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"81","externalOrderNumber":"R00083","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"128","isReturn":"0","name":"Vending Machine 5g","note":"","orderItemId":"","priceGross":"45","priceNet":"37.81512605042017","purchasePriceNet":"0.0","quantity":"1","sku":"42001099-9","totalPriceGross":"45","totalPriceNet":"37.82","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"45","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"45","totalNet":"37.82","type":"0","username":"admin"}
|
||||||
|
2026-07-14T20:19:12 #1 externalId=83 {"ShippingDate":"","amountBack":"0","amountGiven":"45","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:14:12","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"83","externalOrderNumber":"R00085","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"130","isReturn":"0","name":"Vending Machine 5g","note":"","orderItemId":"","priceGross":"45","priceNet":"37.81512605042017","purchasePriceNet":"0.0","quantity":"1","sku":"42001099-9","totalPriceGross":"45","totalPriceNet":"37.82","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"45","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"45","totalNet":"37.82","type":"0","username":"admin"}
|
||||||
|
2026-07-14T20:22:19 #1 externalId=82 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:16:01","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"82","externalOrderNumber":"R00084","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"129","isReturn":"0","name":"King Palm Mars Grinder The Gift","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001021-5","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
|
2026-07-14T20:23:56 #1 externalId=84 {"billingAddress":{"countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","lastName":"Laufkunde"},"creationDate":"2026-07-14 18:14:12","currencyIso":"EUR","customerNumber":"420strainz","externalId":"84","externalOrderNumber":"R00086","orderItems":[{"discountPercent":"0","externalId":"131","name":"Vending Machine 5g","priceGross":"45","priceNet":"37.82","quantity":"1","sku":"42001099-9","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"45","paymentMethodName":"BAR"}],"settings":{"deliver":"1"},"shippingAddress":{"countryIso":"DE","lastName":"Laufkunde"},"totalGross":"45","totalNet":"37.82"}
|
||||||
|
2026-07-14T20:24:45 #1 externalId=83 {"ShippingDate":"","amountBack":"0","amountGiven":"15","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:24:41","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"83","externalOrderNumber":"R00085","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"130","isReturn":"0","name":"Cannasseur Lupe","note":"","orderItemId":"","priceGross":"15","priceNet":"12.605042016806722","purchasePriceNet":"0.0","quantity":"1","sku":"42001165","totalPriceGross":"15","totalPriceNet":"12.61","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"15","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"15","totalNet":"12.61","type":"0","username":"admin"}
|
||||||
|
2026-07-14T20:25:17 #2 externalId=84 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:25:13","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"84","externalOrderNumber":"R00086","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"131","isReturn":"0","name":"Cannasseur Cooling Case","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001166","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
|
2026-07-14T20:39:28 #1 externalId=83 {"currencyIso":"EUR","customerNumber":"420strainz","externalId":"83","orderItems":[],"paymentMethodName":"BAR"}
|
||||||
|
2026-07-14T22:54:26 #1 externalId=85 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 20:54:06","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"85","externalOrderNumber":"R00087","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"132","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
|
2026-07-14T23:03:00 #1 externalId=86 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 21:02:50","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"86","externalOrderNumber":"R00088","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"133","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
|
2026-07-14T23:10:48 #2 externalId=87 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 21:10:45","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"87","externalOrderNumber":"R00089","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"134","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
|
2026-07-14T23:11:59 #3 externalId=88 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 21:11:57","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"88","externalOrderNumber":"R00090","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"135","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
|
2026-07-14T23:14:38 #4 externalId=89 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 21:14:35","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"89","externalOrderNumber":"T00090","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"136","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
|
2026-07-15T00:53:23 #1 externalId=90 {"ShippingDate":"","amountBack":"0","amountGiven":"14.90","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 22:31:13","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"90","externalOrderNumber":"T00091","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"137","isReturn":"0","name":"RAW Donut Tray","note":"","orderItemId":"","priceGross":"14.90","priceNet":"12.521008403361344","purchasePriceNet":"0.0","quantity":"1","sku":"42001172","totalPriceGross":"14.90","totalPriceNet":"12.52","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"14.90","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"14.90","totalNet":"12.52","type":"0","username":"admin"}
|
||||||
|
2026-07-15T00:55:25 #2 externalId=91 {"ShippingDate":"","amountBack":"0","amountGiven":"89.40","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 22:55:16","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"91","externalOrderNumber":"T00092","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"138","isReturn":"0","name":"RAW Donut Tray","note":"","orderItemId":"","priceGross":"14.90","priceNet":"12.521008403361344","purchasePriceNet":"0.0","quantity":"6","sku":"42001172","totalPriceGross":"89.40","totalPriceNet":"75.13","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"89.40","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"89.40","totalNet":"75.13","type":"0","username":"admin"}
|
||||||
|
2026-07-15T00:56:08 #3 externalId=92 {"ShippingDate":"","amountBack":"0","amountGiven":"89.40","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 22:55:56","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"92","externalOrderNumber":"T00093","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"139","isReturn":"0","name":"RAW Donut Tray","note":"","orderItemId":"","priceGross":"14.90","priceNet":"12.521008403361344","purchasePriceNet":"0.0","quantity":"6","sku":"42001172","totalPriceGross":"89.40","totalPriceNet":"75.13","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"89.40","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"89.40","totalNet":"75.13","type":"0","username":"admin"}
|
||||||
|
2026-07-15T01:14:05 #1 externalId=93 {"ShippingDate":"","amountBack":"0","amountGiven":"89.40","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 23:13:57","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"93","externalOrderNumber":"T00094","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"140","isReturn":"0","name":"RAW Donut Tray","note":"","orderItemId":"","priceGross":"14.90","priceNet":"12.521008403361344","purchasePriceNet":"0.0","quantity":"6","sku":"42001172","totalPriceGross":"89.40","totalPriceNet":"75.13","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"89.40","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"89.40","totalNet":"75.13","type":"0","username":"admin"}
|
||||||
|
2026-07-15T02:19:52 #1 externalId=94 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-15 00:19:47","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"94","externalOrderNumber":"T00095","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"141","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
|
2026-07-15T02:21:57 #1 externalId=94 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-15 00:19:47","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"94","externalOrderNumber":"T00095","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"141","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
|
2026-07-15T02:25:07 #1 externalId=95 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-15 00:25:02","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"95","externalOrderNumber":"T00096","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"142","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
|
2026-07-15T02:27:05 #2 externalId=96 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-15 00:27:02","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"96","externalOrderNumber":"T00097","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"143","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
|
2026-07-15T02:37:40 #1 externalId=97 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-15 00:37:31","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"97","externalOrderNumber":"T00098","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"144","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
|
2026-07-15T02:42:47 #1 externalId=98 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-15 00:42:41","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"98","externalOrderNumber":"T00099","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"145","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||||
2862
jtlsrv-cpp/logs/requests.log
Normal file
2862
jtlsrv-cpp/logs/requests.log
Normal file
File diff suppressed because one or more lines are too long
@@ -70,6 +70,12 @@ int64_t HttpRequest::get_query_int64(const std::string& key, int64_t def) const
|
|||||||
// HttpResponse
|
// HttpResponse
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static const char* CORS_HEADERS =
|
||||||
|
"Access-Control-Allow-Origin: *\r\n"
|
||||||
|
"Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
|
||||||
|
"Access-Control-Allow-Headers: Content-Type, Authorization\r\n"
|
||||||
|
"Access-Control-Max-Age: 86400\r\n";
|
||||||
|
|
||||||
void HttpResponse::send_json(int code, const json& body) {
|
void HttpResponse::send_json(int code, const json& body) {
|
||||||
if (headers_sent) return;
|
if (headers_sent) return;
|
||||||
status_code = code;
|
status_code = code;
|
||||||
@@ -80,6 +86,7 @@ void HttpResponse::send_json(int code, const json& body) {
|
|||||||
"Content-Type: application/json; charset=utf-8\r\n"
|
"Content-Type: application/json; charset=utf-8\r\n"
|
||||||
"Content-Length: " + std::to_string(body_str.size()) + "\r\n"
|
"Content-Length: " + std::to_string(body_str.size()) + "\r\n"
|
||||||
"Connection: keep-alive\r\n"
|
"Connection: keep-alive\r\n"
|
||||||
|
+ std::string(CORS_HEADERS) +
|
||||||
"\r\n"
|
"\r\n"
|
||||||
+ body_str;
|
+ body_str;
|
||||||
|
|
||||||
@@ -95,6 +102,7 @@ void HttpResponse::send_binary(int code, const std::vector<uint8_t>& data, const
|
|||||||
"Content-Type: " + content_type + "\r\n"
|
"Content-Type: " + content_type + "\r\n"
|
||||||
"Content-Length: " + std::to_string(data.size()) + "\r\n"
|
"Content-Length: " + std::to_string(data.size()) + "\r\n"
|
||||||
"Connection: keep-alive\r\n"
|
"Connection: keep-alive\r\n"
|
||||||
|
+ std::string(CORS_HEADERS) +
|
||||||
"\r\n";
|
"\r\n";
|
||||||
|
|
||||||
session_write_binary(session, header, data);
|
session_write_binary(session, header, data);
|
||||||
@@ -108,6 +116,7 @@ void HttpResponse::send_empty(int code) {
|
|||||||
std::string resp = "HTTP/1.1 " + std::to_string(code) + " " + reason_phrase(code) + "\r\n"
|
std::string resp = "HTTP/1.1 " + std::to_string(code) + " " + reason_phrase(code) + "\r\n"
|
||||||
"Content-Length: 0\r\n"
|
"Content-Length: 0\r\n"
|
||||||
"Connection: keep-alive\r\n"
|
"Connection: keep-alive\r\n"
|
||||||
|
+ std::string(CORS_HEADERS) +
|
||||||
"\r\n";
|
"\r\n";
|
||||||
|
|
||||||
session_write(session, resp);
|
session_write(session, resp);
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
#include <uv.h>
|
#include <uv.h>
|
||||||
|
#include <openssl/pem.h>
|
||||||
|
#include <openssl/x509.h>
|
||||||
|
#include <openssl/evp.h>
|
||||||
|
#include <openssl/bn.h>
|
||||||
|
|
||||||
#include "config.hpp"
|
#include "config.hpp"
|
||||||
#include "log.hpp"
|
#include "log.hpp"
|
||||||
@@ -29,12 +34,60 @@ static Router router;
|
|||||||
static PairingStore pairing_store;
|
static PairingStore pairing_store;
|
||||||
static RequestLog request_log;
|
static RequestLog request_log;
|
||||||
|
|
||||||
static json build_config() {
|
static bool read_cert_metadata(const char* cert_path,
|
||||||
|
std::string& fingerprint,
|
||||||
|
std::string& serial,
|
||||||
|
std::string& server_fingerprint) {
|
||||||
|
FILE* fp = std::fopen(cert_path, "r");
|
||||||
|
if (!fp) return false;
|
||||||
|
X509* cert = PEM_read_X509(fp, nullptr, nullptr, nullptr);
|
||||||
|
std::fclose(fp);
|
||||||
|
if (!cert) return false;
|
||||||
|
|
||||||
|
unsigned char md[EVP_MAX_MD_SIZE];
|
||||||
|
unsigned int md_len = 0;
|
||||||
|
if (X509_digest(cert, EVP_sha1(), md, &md_len) != 1) {
|
||||||
|
X509_free(cert);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char* hex = "0123456789ABCDEF";
|
||||||
|
fingerprint.clear();
|
||||||
|
fingerprint.reserve(md_len * 2);
|
||||||
|
server_fingerprint.clear();
|
||||||
|
server_fingerprint.reserve(md_len * 3 - 1);
|
||||||
|
for (unsigned int i = 0; i < md_len; ++i) {
|
||||||
|
fingerprint.push_back(hex[(md[i] >> 4) & 0xF]);
|
||||||
|
fingerprint.push_back(hex[md[i] & 0xF]);
|
||||||
|
if (i) server_fingerprint.push_back('-');
|
||||||
|
server_fingerprint.push_back(hex[(md[i] >> 4) & 0xF]);
|
||||||
|
server_fingerprint.push_back(hex[md[i] & 0xF]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASN1_INTEGER* asn1_serial = X509_get0_serialNumber(cert);
|
||||||
|
BIGNUM* bn = ASN1_INTEGER_to_BN(asn1_serial, nullptr);
|
||||||
|
char* hex_serial = bn ? BN_bn2hex(bn) : nullptr;
|
||||||
|
bool ok = hex_serial != nullptr;
|
||||||
|
if (ok) {
|
||||||
|
serial = hex_serial;
|
||||||
|
for (char& c : serial) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||||
|
OPENSSL_free(hex_serial);
|
||||||
|
}
|
||||||
|
BN_free(bn);
|
||||||
|
X509_free(cert);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
static json build_config(const char* cert_path) {
|
||||||
|
std::string fingerprint, serial, server_fingerprint;
|
||||||
|
if (!read_cert_metadata(cert_path, fingerprint, serial, server_fingerprint)) {
|
||||||
|
logc::error("failed to read certificate metadata from %s", cert_path);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
{"authToken", config::get("AUTH_TOKEN", "df40ad2067954646abb0499548a52241")},
|
{"authToken", config::get("AUTH_TOKEN", "df40ad2067954646abb0499548a52241")},
|
||||||
{"certificateFingerprint", config::get("CERTIFICATE_FINGERPRINT", "BC2114CF407A42724BEEF417960F76DCBF9DE879")},
|
{"certificateFingerprint", fingerprint},
|
||||||
{"certificateSerialNumber", config::get("CERTIFICATE_SERIAL_NUMBER", "00BFC8BEACDB981B165210EF111CB9D3")},
|
{"certificateSerialNumber", serial},
|
||||||
{"serverFingerprint", config::get("SERVER_FINGERPRINT", "39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE")},
|
{"serverFingerprint", server_fingerprint},
|
||||||
{"mandantId", config::get("MANDANT_ID", "1")},
|
{"mandantId", config::get("MANDANT_ID", "1")},
|
||||||
{"mandantName", config::get("MANDANT_NAME", "eB-Standard")},
|
{"mandantName", config::get("MANDANT_NAME", "eB-Standard")},
|
||||||
{"mandantDatabase", config::get("MANDANT_DATABASE", "eazybusiness")},
|
{"mandantDatabase", config::get("MANDANT_DATABASE", "eazybusiness")},
|
||||||
@@ -125,7 +178,7 @@ int main(int /*argc*/, char* argv[]) {
|
|||||||
|
|
||||||
loop = uv_default_loop();
|
loop = uv_default_loop();
|
||||||
|
|
||||||
server_config = build_config();
|
server_config = build_config(cert_path.c_str());
|
||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
router.add_route("GET", "/v1/client", handle_client);
|
router.add_route("GET", "/v1/client", handle_client);
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ void Router::dispatch(tls_session* sess, PairingStore& pairing, const json& conf
|
|||||||
full_url += "?" + req.query_string;
|
full_url += "?" + req.query_string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method == "OPTIONS") {
|
||||||
|
resp.send_empty(204);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
std::string route_key = req.method + " " + req.path;
|
std::string route_key = req.method + " " + req.path;
|
||||||
auto it = routes_.find(route_key);
|
auto it = routes_.find(route_key);
|
||||||
if (it != routes_.end()) {
|
if (it != routes_.end()) {
|
||||||
|
|||||||
@@ -11,8 +11,11 @@
|
|||||||
"qr": "node scripts/create-pairing-qr.mjs",
|
"qr": "node scripts/create-pairing-qr.mjs",
|
||||||
"backup:s3": "node scripts/s3-backup/backup.mjs",
|
"backup:s3": "node scripts/s3-backup/backup.mjs",
|
||||||
"backup:s3:quick": "node scripts/s3-backup/backup.mjs --skip-trust",
|
"backup:s3:quick": "node scripts/s3-backup/backup.mjs --skip-trust",
|
||||||
|
"restore:s3": "node scripts/s3-backup/restore.mjs",
|
||||||
|
"restore:s3:quick": "node scripts/s3-backup/restore.mjs --skip-trust",
|
||||||
"db:minimal": "node scripts/create-minimal-db.mjs",
|
"db:minimal": "node scripts/create-minimal-db.mjs",
|
||||||
"db:minimal:extract": "node scripts/create-minimal-db.mjs extract",
|
"db:minimal:extract": "node scripts/create-minimal-db.mjs extract",
|
||||||
|
"demo:generate": "node scripts/generate-demo-catalog.mjs",
|
||||||
"start": "node --watch server.js",
|
"start": "node --watch server.js",
|
||||||
"test:client": "node test-client.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);
|
||||||
|
});
|
||||||
@@ -16,9 +16,12 @@ First run installs the CA into the `mssql` Docker container (`/var/opt/mssql/sec
|
|||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `npm run backup:s3` | Start S3 endpoint + backup `MSSQL_DATABASE` from `.env` |
|
| `npm run backup:s3` | Start S3 endpoint + backup `MSSQL_DATABASE` from `.env` |
|
||||||
| `npm run backup:s3:quick` | Same, but skip PAL CA install/restart (after first setup) |
|
| `npm run backup:s3:quick` | Same, but skip PAL CA install/restart (after first setup) |
|
||||||
|
| `npm run restore:s3` | Start S3 endpoint + restore latest `.bak` for `MSSQL_DATABASE` |
|
||||||
|
| `npm run restore:s3:quick` | Same, but skip PAL CA install/restart (after first setup) |
|
||||||
| `node scripts/s3-backup/backup.mjs --all` | Backup `eazybusiness` and `Mandant_3` |
|
| `node scripts/s3-backup/backup.mjs --all` | Backup `eazybusiness` and `Mandant_3` |
|
||||||
| `node scripts/s3-backup/backup.mjs --server-only` | Run endpoint only |
|
| `node scripts/s3-backup/backup.mjs --server-only` | Run endpoint only |
|
||||||
| `npm run backup:s3 -- --skip-trust` | Skip CA install (npm needs `--` before script args) |
|
| `npm run backup:s3 -- --skip-trust` | Skip CA install (npm needs `--` before script args) |
|
||||||
|
| `npm run restore:s3:quick -- <file.bak>` | Restore a specific backup file |
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -26,6 +29,7 @@ First run installs the CA into the `mssql` Docker container (`/var/opt/mssql/sec
|
|||||||
scripts/s3-backup/
|
scripts/s3-backup/
|
||||||
server.mjs S3-compatible HTTPS server (SigV4, multipart upload)
|
server.mjs S3-compatible HTTPS server (SigV4, multipart upload)
|
||||||
backup.mjs Orchestrator: trust CA → start server → sqlcmd BACKUP
|
backup.mjs Orchestrator: trust CA → start server → sqlcmd BACKUP
|
||||||
|
restore.mjs Orchestrator: trust CA → start server → sqlcmd RESTORE
|
||||||
config.mjs Host, port, credentials
|
config.mjs Host, port, credentials
|
||||||
ensure-certs.mjs TLS certs + Docker MSSQL PAL trust
|
ensure-certs.mjs TLS certs + Docker MSSQL PAL trust
|
||||||
sigv4.mjs AWS Signature V4 verification
|
sigv4.mjs AWS Signature V4 verification
|
||||||
|
|||||||
335
scripts/s3-backup/restore.mjs
Normal file
335
scripts/s3-backup/restore.mjs
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { fork, spawn, spawnSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import https from 'node:https';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ACCESS_KEY,
|
||||||
|
BUCKET,
|
||||||
|
DATA_DIR,
|
||||||
|
HOST,
|
||||||
|
PORT,
|
||||||
|
SECRET_KEY,
|
||||||
|
SERVER_CERT,
|
||||||
|
s3BaseUrl,
|
||||||
|
} from './config.mjs';
|
||||||
|
import { caTrustStatus, ensureCerts, installCaTrust } from './ensure-certs.mjs';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
dotenv.config({ path: path.join(root, '.env') });
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const args = new Set(argv.filter((a) => a.startsWith('--')));
|
||||||
|
const positional = argv.filter((a) => !a.startsWith('--'));
|
||||||
|
|
||||||
|
const serverOnly = args.has('--server-only');
|
||||||
|
const skipTrust = args.has('--skip-trust');
|
||||||
|
const useLatest = args.has('--latest');
|
||||||
|
const replace = !args.has('--no-replace');
|
||||||
|
|
||||||
|
const databaseArg = argv.find((a, i) => argv[i - 1] === '--database');
|
||||||
|
const defaultDb = process.env.MSSQL_DATABASE || 'eazybusiness';
|
||||||
|
const database = databaseArg || defaultDb;
|
||||||
|
|
||||||
|
function sqlcmd(query) {
|
||||||
|
const server = process.env.MSSQL_SERVER || 'localhost';
|
||||||
|
const port = process.env.MSSQL_PORT || '1433';
|
||||||
|
const user = process.env.MSSQL_USER || 'sa';
|
||||||
|
const password = process.env.MSSQL_PASSWORD || '';
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
'sqlcmd',
|
||||||
|
['-W', '-C', '-S', `${server},${port}`, '-U', user, '-P', password, '-Q', query],
|
||||||
|
{ encoding: 'utf8' }
|
||||||
|
);
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout?.on('data', (chunk) => {
|
||||||
|
stdout += chunk;
|
||||||
|
process.stdout.write(chunk);
|
||||||
|
});
|
||||||
|
child.stderr?.on('data', (chunk) => {
|
||||||
|
stderr += chunk;
|
||||||
|
process.stderr.write(chunk);
|
||||||
|
});
|
||||||
|
child.on('close', (code) => {
|
||||||
|
const output = `${stdout}${stderr}`;
|
||||||
|
if (code !== 0 || /^\s*Msg \d+,/m.test(output)) {
|
||||||
|
reject(new Error(output.trim() || 'sqlcmd failed'));
|
||||||
|
} else {
|
||||||
|
resolve(stdout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listBackups(prefix) {
|
||||||
|
if (!fs.existsSync(DATA_DIR)) return [];
|
||||||
|
return fs
|
||||||
|
.readdirSync(DATA_DIR)
|
||||||
|
.filter((name) => name.endsWith('.bak') && name.startsWith(`${prefix}-`))
|
||||||
|
.map((name) => ({
|
||||||
|
name,
|
||||||
|
path: path.join(DATA_DIR, name),
|
||||||
|
mtime: fs.statSync(path.join(DATA_DIR, name)).mtimeMs,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackupFile() {
|
||||||
|
if (positional.length > 1) {
|
||||||
|
throw new Error(`Expected at most one backup file, got: ${positional.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (positional.length === 1) {
|
||||||
|
const input = positional[0];
|
||||||
|
if (path.isAbsolute(input) || input.includes('/')) {
|
||||||
|
const resolved = path.resolve(input);
|
||||||
|
if (!fs.existsSync(resolved)) {
|
||||||
|
throw new Error(`Backup file not found: ${resolved}`);
|
||||||
|
}
|
||||||
|
const base = path.basename(resolved);
|
||||||
|
const target = path.join(DATA_DIR, base);
|
||||||
|
if (resolved !== target) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
fs.copyFileSync(resolved, target);
|
||||||
|
console.log(`Copied ${resolved} -> ${target}`);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
const onDisk = path.join(DATA_DIR, input);
|
||||||
|
if (!fs.existsSync(onDisk)) {
|
||||||
|
throw new Error(`Backup file not found: ${onDisk}`);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useLatest || positional.length === 0) {
|
||||||
|
const matches = listBackups(database);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
throw new Error(`No backups found for ${database} in ${DATA_DIR}`);
|
||||||
|
}
|
||||||
|
console.log(`Using latest backup: ${matches[0].name}`);
|
||||||
|
return matches[0].name;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Specify a backup file or pass --latest');
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseFromBackup(file) {
|
||||||
|
const match = path.basename(file).match(/^(.+)-\d{4}-\d{2}-\d{2}T/);
|
||||||
|
return match ? match[1] : database;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isServerUp() {
|
||||||
|
const probeHost = /^\d+\./.test(HOST) ? '127.0.0.1' : HOST;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const opts = {
|
||||||
|
host: probeHost,
|
||||||
|
port: PORT,
|
||||||
|
path: '/',
|
||||||
|
method: 'GET',
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
};
|
||||||
|
if (!/^\d+\./.test(HOST)) {
|
||||||
|
opts.servername = HOST;
|
||||||
|
}
|
||||||
|
const req = https.request(opts, (res) => {
|
||||||
|
res.resume();
|
||||||
|
resolve(res.statusCode === 403 || res.statusCode === 200);
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve(false));
|
||||||
|
req.setTimeout(1000, () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForServer() {
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if (await isServerUp()) return;
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
throw new Error(`S3 endpoint did not start on https://${HOST}:${PORT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pidOnPort(port) {
|
||||||
|
const result = spawnSync('ss', ['-tlnp'], { encoding: 'utf8' });
|
||||||
|
const match = result.stdout?.match(new RegExp(`:${port}\\s+.*?pid=(\\d+)`));
|
||||||
|
return match ? Number(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopPortListener(port) {
|
||||||
|
const stale = pidOnPort(port);
|
||||||
|
if (!stale) return;
|
||||||
|
try {
|
||||||
|
process.kill(stale);
|
||||||
|
} catch {
|
||||||
|
spawnSync('fuser', ['-k', `${port}/tcp`], { stdio: 'pipe' });
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureServerProcess() {
|
||||||
|
await stopPortListener(PORT);
|
||||||
|
return startServerProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCredential() {
|
||||||
|
const cred = s3BaseUrl();
|
||||||
|
return sqlcmd(`
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.credentials WHERE name = N'${cred}')
|
||||||
|
BEGIN
|
||||||
|
CREATE CREDENTIAL [${cred}]
|
||||||
|
WITH IDENTITY = 'S3 Access Key',
|
||||||
|
SECRET = '${ACCESS_KEY}:${SECRET_KEY}';
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreDatabase(db, file) {
|
||||||
|
const url = `${s3BaseUrl()}/${file}`;
|
||||||
|
const onDisk = path.join(DATA_DIR, file);
|
||||||
|
if (!fs.existsSync(onDisk)) {
|
||||||
|
throw new Error(`Backup file missing on disk: ${onDisk}`);
|
||||||
|
}
|
||||||
|
const mb = (fs.statSync(onDisk).size / (1024 * 1024)).toFixed(1);
|
||||||
|
console.log(`Restoring ${db} <- ${url} (${mb} MB)`);
|
||||||
|
|
||||||
|
const replaceClause = replace ? ', REPLACE' : '';
|
||||||
|
await sqlcmd(`
|
||||||
|
IF DB_ID(N'${db}') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER DATABASE [${db}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||||
|
END
|
||||||
|
RESTORE DATABASE [${db}]
|
||||||
|
FROM URL = '${url}'
|
||||||
|
WITH STATS = 10, MAXTRANSFERSIZE = 20971520${replaceClause};
|
||||||
|
ALTER DATABASE [${db}] SET MULTI_USER;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForSql() {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const result = spawnSync(
|
||||||
|
'sqlcmd',
|
||||||
|
[
|
||||||
|
'-W',
|
||||||
|
'-C',
|
||||||
|
'-S',
|
||||||
|
`${process.env.MSSQL_SERVER || 'localhost'},${process.env.MSSQL_PORT || '1433'}`,
|
||||||
|
'-U',
|
||||||
|
process.env.MSSQL_USER || 'sa',
|
||||||
|
'-P',
|
||||||
|
process.env.MSSQL_PASSWORD || '',
|
||||||
|
'-Q',
|
||||||
|
'SELECT 1',
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' }
|
||||||
|
);
|
||||||
|
if (result.status === 0 && !/Msg \d+,/.test(result.stdout || '')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawnSync('sleep', ['2']);
|
||||||
|
}
|
||||||
|
throw new Error('MSSQL did not become ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServerProcess() {
|
||||||
|
const child = fork(new URL('./server.mjs', import.meta.url), {
|
||||||
|
env: { ...process.env, S3_BACKUP_CHILD: '1' },
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (skipTrust) {
|
||||||
|
const status = caTrustStatus();
|
||||||
|
if (!status.inSync) {
|
||||||
|
throw new Error(
|
||||||
|
'PAL CA is out of sync with scripts/s3-backup/certs/ca.pem. Run: npm run backup:s3'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('Skipping PAL CA install (--skip-trust)');
|
||||||
|
if (!fs.existsSync(SERVER_CERT)) {
|
||||||
|
throw new Error('No TLS certs found. Run: npm run backup:s3');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ensureCerts(HOST);
|
||||||
|
const trust = installCaTrust();
|
||||||
|
if (!trust.ok) {
|
||||||
|
console.warn('Could not install CA into MSSQL container. Run:');
|
||||||
|
console.warn(' docker exec -u root mssql mkdir -p /var/opt/mssql/security/ca-certificates');
|
||||||
|
console.warn(' docker cp scripts/s3-backup/certs/ca.pem mssql:/var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem');
|
||||||
|
console.warn(' docker exec -u root mssql chown mssql:mssql /var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem');
|
||||||
|
console.warn(' docker restart mssql');
|
||||||
|
} else {
|
||||||
|
console.log('Installed S3 CA into MSSQL PAL trust store');
|
||||||
|
if (trust.restarted) {
|
||||||
|
console.log('Waiting for MSSQL to restart...');
|
||||||
|
waitForSql();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverChild = await ensureServerProcess();
|
||||||
|
try {
|
||||||
|
await waitForServer();
|
||||||
|
|
||||||
|
if (serverOnly) {
|
||||||
|
console.log('Server running (--server-only). Ctrl+C to stop.');
|
||||||
|
await new Promise((resolve) => serverChild.on('exit', resolve));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = resolveBackupFile();
|
||||||
|
const db = databaseArg || databaseFromBackup(file);
|
||||||
|
await ensureCredential();
|
||||||
|
await restoreDatabase(db, file);
|
||||||
|
console.log(`\nRestored ${db} from ${file}`);
|
||||||
|
} finally {
|
||||||
|
if (!serverOnly) {
|
||||||
|
serverChild.kill();
|
||||||
|
await stopPortListener(PORT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.has('--help')) {
|
||||||
|
console.log(`Usage: node scripts/s3-backup/restore.mjs [file.bak] [options]
|
||||||
|
|
||||||
|
Starts the local S3-compatible HTTPS endpoint and restores MSSQL from a .bak on disk.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
file.bak Backup filename in data/${BUCKET}/, or a path to copy from
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--latest Use newest backup matching --database (default if no file given)
|
||||||
|
--database <name> Target database (default: MSSQL_DATABASE or name parsed from file)
|
||||||
|
--no-replace Do not pass REPLACE to RESTORE
|
||||||
|
--server-only Start endpoint only, no restore
|
||||||
|
--skip-trust Skip installing CA cert into system trust store
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
npm run restore:s3:quick
|
||||||
|
npm run restore:s3:quick -- eazybusiness-2026-07-23T19-02-44-903Z.bak
|
||||||
|
npm run restore:s3:quick -- --database eazybusiness --latest
|
||||||
|
|
||||||
|
Reads MSSQL_* from .env in repo root.
|
||||||
|
Backups are read from scripts/s3-backup/data/${BUCKET}/
|
||||||
|
`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -214,13 +214,36 @@ async function handle(req, res) {
|
|||||||
if (!fs.existsSync(file)) {
|
if (!fs.existsSync(file)) {
|
||||||
return send(res, 404, xml('<Error><Code>NoSuchKey</Code><Message>Not Found</Message></Error>'));
|
return send(res, 404, xml('<Error><Code>NoSuchKey</Code><Message>Not Found</Message></Error>'));
|
||||||
}
|
}
|
||||||
const data = fs.readFileSync(file);
|
const stat = fs.statSync(file);
|
||||||
|
const etag = etagFor(fs.readFileSync(file));
|
||||||
|
const range = req.headers.range;
|
||||||
|
if (range) {
|
||||||
|
const match = /^bytes=(\d+)-(\d*)$/i.exec(range);
|
||||||
|
if (match) {
|
||||||
|
const start = Number(match[1]);
|
||||||
|
const end = match[2] ? Number(match[2]) : stat.size - 1;
|
||||||
|
if (start >= stat.size || end < start) {
|
||||||
|
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` });
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
const length = end - start + 1;
|
||||||
|
res.writeHead(206, {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': length,
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
ETag: etag,
|
||||||
|
});
|
||||||
|
return fs.createReadStream(file, { start, end }).pipe(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
'Content-Length': data.length,
|
'Content-Length': stat.size,
|
||||||
ETag: etagFor(data),
|
'Accept-Ranges': 'bytes',
|
||||||
|
ETag: etag,
|
||||||
});
|
});
|
||||||
return res.end(data);
|
return fs.createReadStream(file).pipe(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'HEAD' && key) {
|
if (req.method === 'HEAD' && key) {
|
||||||
@@ -231,6 +254,7 @@ async function handle(req, res) {
|
|||||||
const stat = fs.statSync(file);
|
const stat = fs.statSync(file);
|
||||||
return send(res, 200, '', {
|
return send(res, 200, '', {
|
||||||
'Content-Length': stat.size,
|
'Content-Length': stat.size,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
ETag: etagFor(fs.readFileSync(file)),
|
ETag: etagFor(fs.readFileSync(file)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
24
server.js
24
server.js
@@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
import { connectDb, closeDb } from './src/db.js';
|
import { connectDb, closeDb } from './src/db.js';
|
||||||
|
import { isDemoMode } from './src/demo/mode.js';
|
||||||
|
import { loadDemoCatalog } from './src/demo/store.js';
|
||||||
|
import { readCertMetadata } from './src/cert-meta.js';
|
||||||
import { createJtlPosServer } from './src/jtl-server.js';
|
import { createJtlPosServer } from './src/jtl-server.js';
|
||||||
import { createPairingStore } from './src/pairing.js';
|
import { createPairingStore } from './src/pairing.js';
|
||||||
import { closeOrderLog } from './src/order-log.js';
|
import { closeOrderLog } from './src/order-log.js';
|
||||||
@@ -57,11 +60,18 @@ function formatBody(buffer) {
|
|||||||
return `[binary ${buffer.length} bytes]`;
|
return `[binary ${buffer.length} bytes]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const certPem = fs.readFileSync(certPath);
|
||||||
|
const keyPem = fs.readFileSync(keyPath);
|
||||||
|
const certMeta = readCertMetadata(certPem);
|
||||||
|
|
||||||
const pairingStore = createPairingStore();
|
const pairingStore = createPairingStore();
|
||||||
pairingStore.setPairingCode(PAIRING_CODE, 'JTL-POS');
|
pairingStore.setPairingCode(PAIRING_CODE, 'JTL-POS');
|
||||||
pairingStore.registerDevice(AUTH_TOKEN, 'JTL-POS');
|
pairingStore.registerDevice(AUTH_TOKEN, 'JTL-POS');
|
||||||
|
|
||||||
const jtlHandler = createJtlPosServer(pairingStore, { authToken: AUTH_TOKEN });
|
const jtlHandler = createJtlPosServer(pairingStore, {
|
||||||
|
authToken: AUTH_TOKEN,
|
||||||
|
...certMeta,
|
||||||
|
});
|
||||||
|
|
||||||
const loggedJtlHandler = async (req, res) => {
|
const loggedJtlHandler = async (req, res) => {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
@@ -106,13 +116,20 @@ const loggedJtlHandler = async (req, res) => {
|
|||||||
|
|
||||||
const httpsServer = https.createServer(
|
const httpsServer = https.createServer(
|
||||||
{
|
{
|
||||||
key: fs.readFileSync(keyPath),
|
key: keyPem,
|
||||||
cert: fs.readFileSync(certPath),
|
cert: certPem,
|
||||||
},
|
},
|
||||||
loggedJtlHandler
|
loggedJtlHandler
|
||||||
);
|
);
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
|
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 {
|
try {
|
||||||
const pool = await connectDb();
|
const pool = await connectDb();
|
||||||
logger.success(`MSSQL connected: ${process.env.MSSQL_SERVER}/${process.env.MSSQL_DATABASE}`);
|
logger.success(`MSSQL connected: ${process.env.MSSQL_SERVER}/${process.env.MSSQL_DATABASE}`);
|
||||||
@@ -122,6 +139,7 @@ async function start() {
|
|||||||
logger.warn(`MSSQL connection skipped: ${err.message}`);
|
logger.warn(`MSSQL connection skipped: ${err.message}`);
|
||||||
logger.warn('POS handshake will still work; sync from database is not available yet.');
|
logger.warn('POS handshake will still work; sync from database is not available yet.');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
httpsServer.listen(PORT, '0.0.0.0', () => {
|
httpsServer.listen(PORT, '0.0.0.0', () => {
|
||||||
logger.success(`HTTPS POS server listening on https://0.0.0.0:${PORT}`);
|
logger.success(`HTTPS POS server listening on https://0.0.0.0:${PORT}`);
|
||||||
|
|||||||
12
src/cert-meta.js
Normal file
12
src/cert-meta.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { X509Certificate } from 'node:crypto';
|
||||||
|
|
||||||
|
/** Derive pairing metadata from a PEM-encoded TLS certificate. */
|
||||||
|
export function readCertMetadata(certPem) {
|
||||||
|
const x509 = new X509Certificate(certPem);
|
||||||
|
const sha1 = x509.fingerprint; // colon-separated uppercase hex
|
||||||
|
return {
|
||||||
|
certificateFingerprint: sha1.replace(/:/g, ''),
|
||||||
|
certificateSerialNumber: x509.serialNumber,
|
||||||
|
serverFingerprint: sha1.replace(/:/g, '-'),
|
||||||
|
};
|
||||||
|
}
|
||||||
3
src/demo/mode.js
Normal file
3
src/demo/mode.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function isDemoMode() {
|
||||||
|
return String(process.env.DEMO_MODE || '').toLowerCase() === 'true';
|
||||||
|
}
|
||||||
139
src/demo/store.js
Normal file
139
src/demo/store.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { setActiveShop, setActiveShopSubshop } from '../shop.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DEMO_ROOT = path.join(__dirname, '..', '..', 'demo');
|
||||||
|
const CATALOG_PATH = path.join(DEMO_ROOT, 'catalog.json');
|
||||||
|
const IMAGES_DIR = path.join(DEMO_ROOT, 'images');
|
||||||
|
|
||||||
|
let catalog = null;
|
||||||
|
let nextDemoOrderId = 1;
|
||||||
|
|
||||||
|
function ensureLoaded() {
|
||||||
|
if (!catalog) {
|
||||||
|
throw new Error('Demo catalog is not loaded. Call loadDemoCatalog() at startup.');
|
||||||
|
}
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterCursor(rows, cursor, lastChangedKey = 'lastChanged') {
|
||||||
|
const c = Number(cursor) || 0;
|
||||||
|
return rows
|
||||||
|
.filter((row) => Number(row[lastChangedKey]) > c)
|
||||||
|
.sort((a, b) => Number(a[lastChangedKey]) - Number(b[lastChangedKey]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDemoCatalog() {
|
||||||
|
if (!fs.existsSync(CATALOG_PATH)) {
|
||||||
|
throw new Error(
|
||||||
|
`Demo catalog missing at ${CATALOG_PATH}. Run: npm run demo:generate`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(CATALOG_PATH, 'utf8');
|
||||||
|
catalog = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (!Array.isArray(catalog.products) || catalog.products.length === 0) {
|
||||||
|
throw new Error('Demo catalog has no products.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveShop(1);
|
||||||
|
setActiveShopSubshop(1);
|
||||||
|
nextDemoOrderId = Number(catalog.maxOrderId || 0) + 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories: catalog.categories.length,
|
||||||
|
products: catalog.products.length,
|
||||||
|
customerGroups: catalog.customerGroups.length,
|
||||||
|
composites: catalog.composites.length,
|
||||||
|
imagesDir: IMAGES_DIR,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoProductList({ cursor = 0, limit = 20 } = {}) {
|
||||||
|
return afterCursor(ensureLoaded().products, cursor).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoProductCount({ cursor = 0 } = {}) {
|
||||||
|
return afterCursor(ensureLoaded().products, cursor).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoCategoryList({ cursor = 0, limit = 20 } = {}) {
|
||||||
|
const data = ensureLoaded();
|
||||||
|
return afterCursor(data.categories, cursor).slice(0, limit).map((category) => ({
|
||||||
|
_id: category._id,
|
||||||
|
imghash: category.imghash,
|
||||||
|
imgsrc: category.imgsrc,
|
||||||
|
name: category.name,
|
||||||
|
pid: category.pid,
|
||||||
|
discounts: category.discounts ?? [],
|
||||||
|
sort: category.sort,
|
||||||
|
lastChanged: category.lastChanged,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoCategoryCount({ cursor = 0 } = {}) {
|
||||||
|
return afterCursor(ensureLoaded().categories, cursor).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoCustomerGroupIds() {
|
||||||
|
return ensureLoaded().customerGroups.map((g) => Number(g.customerGroupId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoCustomerGroupList({ cursor = 0 } = {}) {
|
||||||
|
return afterCursor(ensureLoaded().customerGroups, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoCustomerGroupCount({ cursor = 0 } = {}) {
|
||||||
|
return afterCursor(ensureLoaded().customerGroups, cursor).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoCompositeProductList({ cursor = 0, limit = 100 } = {}) {
|
||||||
|
return afterCursor(ensureLoaded().composites, cursor).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoCompositeProductCount({ cursor = 0 } = {}) {
|
||||||
|
// Match MSSQL semantics: count distinct composite parent products after cursor
|
||||||
|
const rows = afterCursor(ensureLoaded().composites, cursor);
|
||||||
|
return new Set(rows.map((r) => r.productId)).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoDeletedEntityList({ cursor = 0, limit = 600 } = {}) {
|
||||||
|
return afterCursor(ensureLoaded().deletedEntities, cursor).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoDeletedEntityCount({ cursor = 0 } = {}) {
|
||||||
|
return afterCursor(ensureLoaded().deletedEntities, cursor).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoMaxOrderIdCount() {
|
||||||
|
return Number(ensureLoaded().maxOrderId || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDemoImageByHash(hash) {
|
||||||
|
ensureLoaded();
|
||||||
|
if (!hash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(IMAGES_DIR, `${hash}.jpg`);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
return { buffer, contentType: 'image/jpeg' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDemoOrder(order) {
|
||||||
|
ensureLoaded();
|
||||||
|
const orderId = nextDemoOrderId++;
|
||||||
|
const externalId = String(order?.externalId ?? orderId);
|
||||||
|
return {
|
||||||
|
orderId: String(orderId),
|
||||||
|
orderNumber: `DEMO-${externalId}`,
|
||||||
|
alreadyExists: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -60,7 +60,6 @@ export function handle(req, res, { url, pairingStore, config }) {
|
|||||||
|
|
||||||
if (authCode.length === 6) {
|
if (authCode.length === 6) {
|
||||||
if (pairingStore.hasPairingCode(authCode)) {
|
if (pairingStore.hasPairingCode(authCode)) {
|
||||||
pairingStore.revokePairingCode(authCode);
|
|
||||||
pairingStore.registerDevice(config.authToken, name);
|
pairingStore.registerDevice(config.authToken, name);
|
||||||
return sendJson(res, 200, buildClientStep2(authCode, config));
|
return sendJson(res, 200, buildClientStep2(authCode, config));
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/http.js
17
src/http.js
@@ -13,11 +13,19 @@ export function readBody(req) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CORS_HEADERS = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
'Access-Control-Max-Age': '86400',
|
||||||
|
};
|
||||||
|
|
||||||
export function sendJson(res, statusCode, body) {
|
export function sendJson(res, statusCode, body) {
|
||||||
const responseBody = JSON.stringify(body);
|
const responseBody = JSON.stringify(body);
|
||||||
res.writeHead(statusCode, {
|
res.writeHead(statusCode, {
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
'Content-Length': Buffer.byteLength(responseBody),
|
'Content-Length': Buffer.byteLength(responseBody),
|
||||||
|
...CORS_HEADERS,
|
||||||
});
|
});
|
||||||
res.end(responseBody);
|
res.end(responseBody);
|
||||||
}
|
}
|
||||||
@@ -26,10 +34,19 @@ export function sendBinary(res, statusCode, buffer, contentType) {
|
|||||||
res.writeHead(statusCode, {
|
res.writeHead(statusCode, {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'Content-Length': buffer.length,
|
'Content-Length': buffer.length,
|
||||||
|
...CORS_HEADERS,
|
||||||
});
|
});
|
||||||
res.end(buffer);
|
res.end(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sendCorsPreflight(res) {
|
||||||
|
res.writeHead(204, {
|
||||||
|
'Content-Length': 0,
|
||||||
|
...CORS_HEADERS,
|
||||||
|
});
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizePath(pathname) {
|
export function normalizePath(pathname) {
|
||||||
return pathname.replace(/^\/api(?=\/v1\/)/, '');
|
return pathname.replace(/^\/api(?=\/v1\/)/, '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import { endpoints } from './endpoints/index.js';
|
import { endpoints } from './endpoints/index.js';
|
||||||
import { normalizePath, readBody, sendJson } from './http.js';
|
import { normalizePath, readBody, sendCorsPreflight, sendJson } from './http.js';
|
||||||
|
|
||||||
function buildConfig(config = {}) {
|
function buildConfig(config = {}) {
|
||||||
return {
|
return {
|
||||||
authToken: config.authToken || process.env.AUTH_TOKEN || 'df40ad2067954646abb0499548a52241',
|
authToken: config.authToken || process.env.AUTH_TOKEN || 'df40ad2067954646abb0499548a52241',
|
||||||
certificateFingerprint:
|
certificateFingerprint: config.certificateFingerprint || '',
|
||||||
config.certificateFingerprint ||
|
certificateSerialNumber: config.certificateSerialNumber || '',
|
||||||
process.env.CERTIFICATE_FINGERPRINT ||
|
serverFingerprint: config.serverFingerprint || '',
|
||||||
'BC2114CF407A42724BEEF417960F76DCBF9DE879',
|
|
||||||
certificateSerialNumber:
|
|
||||||
config.certificateSerialNumber ||
|
|
||||||
process.env.CERTIFICATE_SERIAL_NUMBER ||
|
|
||||||
'00BFC8BEACDB981B165210EF111CB9D3',
|
|
||||||
serverFingerprint:
|
|
||||||
config.serverFingerprint ||
|
|
||||||
process.env.SERVER_FINGERPRINT ||
|
|
||||||
'39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE',
|
|
||||||
mandantId: config.mandantId || process.env.MANDANT_ID || '1',
|
mandantId: config.mandantId || process.env.MANDANT_ID || '1',
|
||||||
mandantName: config.mandantName || process.env.MANDANT_NAME || 'eB-Standard',
|
mandantName: config.mandantName || process.env.MANDANT_NAME || 'eB-Standard',
|
||||||
mandantDatabase: config.mandantDatabase || process.env.MANDANT_DATABASE || 'eazybusiness',
|
mandantDatabase: config.mandantDatabase || process.env.MANDANT_DATABASE || 'eazybusiness',
|
||||||
@@ -27,6 +18,10 @@ export function createJtlPosServer(pairingStore, config = {}) {
|
|||||||
const routes = new Map(endpoints.map((endpoint) => [`${endpoint.method} ${endpoint.path}`, endpoint]));
|
const routes = new Map(endpoints.map((endpoint) => [`${endpoint.method} ${endpoint.path}`, endpoint]));
|
||||||
|
|
||||||
async function handle(req, res) {
|
async function handle(req, res) {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return sendCorsPreflight(res);
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(req.url, 'https://localhost');
|
const url = new URL(req.url, 'https://localhost');
|
||||||
const pathname = normalizePath(url.pathname);
|
const pathname = normalizePath(url.pathname);
|
||||||
const routeKey = `${req.method} ${pathname}`;
|
const routeKey = `${req.method} ${pathname}`;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { getDemoCategoryCount } from '../demo/store.js';
|
||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.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() } = {}) {
|
export async function getCategoryCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return getDemoCategoryCount({ cursor });
|
||||||
|
}
|
||||||
|
|
||||||
const result = await categoryTreeRequest(getPool(), rootCategoryId)
|
const result = await categoryTreeRequest(getPool(), rootCategoryId)
|
||||||
.input('cursor', sql.BigInt, cursor)
|
.input('cursor', sql.BigInt, cursor)
|
||||||
.query(CATEGORY_COUNT_SQL);
|
.query(CATEGORY_COUNT_SQL);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { getDemoCategoryList } from '../demo/store.js';
|
||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.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() } = {}) {
|
export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId = getRootCategoryId() } = {}) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return getDemoCategoryList({ cursor, limit });
|
||||||
|
}
|
||||||
|
|
||||||
const result = await categoryTreeRequest(getPool(), rootCategoryId)
|
const result = await categoryTreeRequest(getPool(), rootCategoryId)
|
||||||
.input('cursor', sql.BigInt, cursor)
|
.input('cursor', sql.BigInt, cursor)
|
||||||
.input('limit', sql.Int, limit)
|
.input('limit', sql.Int, limit)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { getDemoCompositeProductCount } from '../demo/store.js';
|
||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
import { getActiveShopId } from '../shop.js';
|
import { getActiveShopId } from '../shop.js';
|
||||||
|
|
||||||
@@ -16,6 +18,10 @@ WHERE a.kStueckliste <> 0
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getCompositeProductCount({ cursor = 0 } = {}) {
|
export async function getCompositeProductCount({ cursor = 0 } = {}) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return getDemoCompositeProductCount({ cursor });
|
||||||
|
}
|
||||||
|
|
||||||
const result = await getPool()
|
const result = await getPool()
|
||||||
.request()
|
.request()
|
||||||
.input('cursor', sql.BigInt, cursor)
|
.input('cursor', sql.BigInt, cursor)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { getDemoCompositeProductList } from '../demo/store.js';
|
||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
import { getActiveShopId } from '../shop.js';
|
import { getActiveShopId } from '../shop.js';
|
||||||
|
|
||||||
@@ -21,6 +23,10 @@ ORDER BY lastChanged ASC;
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getCompositeProductList({ cursor = 0, limit = 100 } = {}) {
|
export async function getCompositeProductList({ cursor = 0, limit = 100 } = {}) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return getDemoCompositeProductList({ cursor, limit });
|
||||||
|
}
|
||||||
|
|
||||||
const result = await getPool()
|
const result = await getPool()
|
||||||
.request()
|
.request()
|
||||||
.input('cursor', sql.BigInt, cursor)
|
.input('cursor', sql.BigInt, cursor)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { createDemoOrder } from '../demo/store.js';
|
||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
import { getActiveShopId, getActiveShopSubshopId } from '../shop.js';
|
import { getActiveShopId, getActiveShopSubshopId } from '../shop.js';
|
||||||
import { deliverOrder } from './delivery/index.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) {
|
export async function createOrder(order) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return createDemoOrder(order);
|
||||||
|
}
|
||||||
|
|
||||||
const kPosAuftrag = Number.parseInt(order.externalId, 10);
|
const kPosAuftrag = Number.parseInt(order.externalId, 10);
|
||||||
const externalOrderNumber = order.externalOrderNumber || '';
|
const externalOrderNumber = order.externalOrderNumber || '';
|
||||||
if (Number.isInteger(kPosAuftrag) && kPosAuftrag > 0) {
|
if (Number.isInteger(kPosAuftrag) && kPosAuftrag > 0) {
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import {
|
||||||
|
getDemoCustomerGroupCount,
|
||||||
|
getDemoCustomerGroupIds,
|
||||||
|
getDemoCustomerGroupList,
|
||||||
|
} from '../demo/store.js';
|
||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
|
|
||||||
const CUSTOMER_GROUP_IDS_SQL = `
|
const CUSTOMER_GROUP_IDS_SQL = `
|
||||||
@@ -26,11 +32,19 @@ WHERE CONVERT(BIGINT, bRowversion) > @cursor;
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getCustomerGroupIds() {
|
export async function getCustomerGroupIds() {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return getDemoCustomerGroupIds();
|
||||||
|
}
|
||||||
|
|
||||||
const result = await getPool().request().query(CUSTOMER_GROUP_IDS_SQL);
|
const result = await getPool().request().query(CUSTOMER_GROUP_IDS_SQL);
|
||||||
return result.recordset.map((row) => row.kKundenGruppe);
|
return result.recordset.map((row) => row.kKundenGruppe);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCustomerGroupList({ cursor = 0 } = {}) {
|
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);
|
const result = await getPool().request().input('cursor', sql.BigInt, cursor).query(CUSTOMER_GROUP_LIST_SQL);
|
||||||
|
|
||||||
return result.recordset.map((row) => ({
|
return result.recordset.map((row) => ({
|
||||||
@@ -43,6 +57,10 @@ export async function getCustomerGroupList({ cursor = 0 } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCustomerGroupCount({ 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);
|
const result = await getPool().request().input('cursor', sql.BigInt, cursor).query(CUSTOMER_GROUP_COUNT_SQL);
|
||||||
return result.recordset[0]?.CustomerGroupCount ?? 0;
|
return result.recordset[0]?.CustomerGroupCount ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { getDemoDeletedEntityCount } from '../demo/store.js';
|
||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
|
|
||||||
const DELETED_ENTITY_COUNT_SQL = `
|
const DELETED_ENTITY_COUNT_SQL = `
|
||||||
@@ -8,6 +10,10 @@ WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > @cursor;
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getDeletedEntityCount({ cursor = 0 } = {}) {
|
export async function getDeletedEntityCount({ cursor = 0 } = {}) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return getDemoDeletedEntityCount({ cursor });
|
||||||
|
}
|
||||||
|
|
||||||
const result = await getPool()
|
const result = await getPool()
|
||||||
.request()
|
.request()
|
||||||
.input('cursor', sql.BigInt, cursor)
|
.input('cursor', sql.BigInt, cursor)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { getDemoDeletedEntityList } from '../demo/store.js';
|
||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
|
|
||||||
const DELETED_ENTITY_LIST_SQL = `
|
const DELETED_ENTITY_LIST_SQL = `
|
||||||
@@ -12,6 +14,10 @@ ORDER BY lastChanged ASC;
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getDeletedEntityList({ cursor = 0, limit = 600 } = {}) {
|
export async function getDeletedEntityList({ cursor = 0, limit = 600 } = {}) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return getDemoDeletedEntityList({ cursor, limit });
|
||||||
|
}
|
||||||
|
|
||||||
const result = await getPool()
|
const result = await getPool()
|
||||||
.request()
|
.request()
|
||||||
.input('cursor', sql.BigInt, cursor)
|
.input('cursor', sql.BigInt, cursor)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { getDemoImageByHash } from '../demo/store.js';
|
||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
|
|
||||||
const IMAGE_BY_HASH_SQL = `
|
const IMAGE_BY_HASH_SQL = `
|
||||||
@@ -24,6 +26,10 @@ function contentTypeFor(cQuelle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getImageByHash(hash, size) {
|
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 result = await getPool().request().input('hash', sql.NVarChar, hash).query(IMAGE_BY_HASH_SQL);
|
||||||
|
|
||||||
const row = result.recordset[0];
|
const row = result.recordset[0];
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { getDemoMaxOrderIdCount } from '../demo/store.js';
|
||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
import { getActiveShopSubshopId } from '../shop.js';
|
import { getActiveShopSubshopId } from '../shop.js';
|
||||||
|
|
||||||
@@ -9,6 +11,10 @@ WHERE kShopSubShop = @kShopSubShop;
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getMaxOrderIdCount() {
|
export async function getMaxOrderIdCount() {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return getDemoMaxOrderIdCount();
|
||||||
|
}
|
||||||
|
|
||||||
const kShopSubShop = getActiveShopSubshopId();
|
const kShopSubShop = getActiveShopSubshopId();
|
||||||
if (!kShopSubShop) {
|
if (!kShopSubShop) {
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { getDemoProductCount } from '../demo/store.js';
|
||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.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() } = {}) {
|
export async function getProductCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return getDemoProductCount({ cursor });
|
||||||
|
}
|
||||||
|
|
||||||
const result = await categoryTreeRequest(getPool(), rootCategoryId)
|
const result = await categoryTreeRequest(getPool(), rootCategoryId)
|
||||||
.input('cursor', sql.BigInt, cursor)
|
.input('cursor', sql.BigInt, cursor)
|
||||||
.query(PRODUCT_COUNT_SQL);
|
.query(PRODUCT_COUNT_SQL);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { getDemoProductList } from '../demo/store.js';
|
||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
import { getCustomerGroupIds } from './customer-groups.js';
|
import { getCustomerGroupIds } from './customer-groups.js';
|
||||||
import { getProductAttributes } from './product-attributes.js';
|
import { getProductAttributes } from './product-attributes.js';
|
||||||
@@ -93,6 +95,10 @@ function grossPrice(netPrice, taxRate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getProductList({ cursor = 0, limit = 20 } = {}) {
|
export async function getProductList({ cursor = 0, limit = 20 } = {}) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return getDemoProductList({ cursor, limit });
|
||||||
|
}
|
||||||
|
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
const [productResult, customerGroupIds] = await Promise.all([
|
const [productResult, customerGroupIds] = await Promise.all([
|
||||||
|
|||||||
Reference in New Issue
Block a user