Compare commits

..

12 Commits

Author SHA1 Message Date
seb
6312eaec48 u 2026-07-30 15:52:55 +02:00
seb
3e8ea1ef76 u 2026-07-29 18:40:19 +02:00
seb
e9f29dc569 u 2026-07-27 02:18:07 +02:00
seb
10c4269da0 u 2026-07-27 02:16:26 +02:00
seb
94e44e8e61 u 2026-07-27 01:31:48 +02:00
seb
b9c574074f u 2026-07-27 01:31:34 +02:00
seb
ba705c7d08 u 2026-07-27 01:31:03 +02:00
seb
fc7afd1be8 u 2026-07-27 01:14:36 +02:00
seb
c27ad52e2a u 2026-07-27 00:13:47 +02:00
seb
538ecf7a80 pairing qr 2026-07-20 18:05:44 +02:00
seb
7bd8df22d1 pfand for cpp 2026-07-20 15:49:55 +02:00
seb
8057933939 jsdoc 2026-07-20 15:35:32 +02:00
38 changed files with 5638 additions and 60 deletions

View File

@@ -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
View File

@@ -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/

499
API.md Normal file
View File

@@ -0,0 +1,499 @@
# JTL POS Sync API
HTTPS API that mirrors the JTL-POS ↔ Wawi sync protocol. Use this to build a custom POS client that syncs catalog data and submits orders.
Two servers implement this protocol:
| Implementation | Entry | Handlers |
|---|---|---|
| **Node.js** | `server.js` | [`src/endpoints/`](src/endpoints/) |
| **C++** | `jtlsrv-cpp/` | [`jtlsrv-cpp/src/endpoints/`](jtlsrv-cpp/src/endpoints/) |
Clients talk to either over the same paths and shapes. See [Node vs C++](#node-vs-c) for the few behavioral differences.
Base URL: `https://<host>:<port>` (default port `4443`)
Paths may be called as `/v1/...` or `/api/v1/...` — both resolve to the same handlers.
All JSON responses use `Content-Type: application/json; charset=utf-8`. Numeric fields are usually returned as **strings**.
The server currently does **not** validate the `authToken` on subsequent requests after pairing. Pairing still issues a token so clients can store it for a real JTL-compatible flow.
---
## Client lifecycle
```
1. Pair GET /v1/client
2. Poll sync GET /v1/init (with lastChanged* cursors)
3. Fetch deltas GET /v1/category|product|productcomposite|customergroup|deletedentity
4. Fetch images GET /v1/pimage|cimage (optional, by hash)
5. Submit sale POST /v1/order
```
Persist every `lastChanged*` cursor locally. On the next poll, send the highest value you have seen for that entity type so you only download deltas.
See [productSync.md](productSync.md) for the cursor / row-version model in detail.
---
## 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, 34 category levels, variants, real photos).
2. Set `DEMO_MODE=true` in `.env` (see `.env.example`).
3. Start the server as usual (`npm start`). Pairing and all sync endpoints work; orders are logged and return synthetic `OK` results.
Demo mode is **opt-in only** — a failed MSSQL connection does not enable it.
---
## `GET /v1/client` — Pairing
Discovers the server and completes pairing with a 6-digit code.
### Query parameters
| Param | Required | Description |
|---|---|---|
| `authCode` | yes | Pairing code. Short codes (14 chars) trigger discovery; a 6-digit code completes pairing. |
| `name` | no | Device name stored on successful pair. Default: `JTL-POS`. |
### Flow
1. **Discovery** — call with a short `authCode` (length 14). Response includes `authToken`, certificate fingerprints, and `mandantId`. `mandantName` / `mandantDatabase` are `null`.
2. **Pair** — call with the 6-digit pairing code. On success the code is revoked and the device is registered. Response includes `mandantName` and `mandantDatabase`.
### Success (200)
```json
{
"authCode": "307018",
"authToken": "df40ad2067954646abb0499548a52241",
"certificateFingerprint": "...",
"certificateSerialNumber": "...",
"mandantId": "1",
"mandantName": "eB-Standard",
"mandantDatabase": "eazybusiness",
"serverFingerprint": null,
"name": null,
"serverTimestamp": "2026-07-20 15:00:00"
}
```
### Errors
| Status | When |
|---|---|
| 400 | Wrong 6-digit code (`Der Authentifizierungscode ist falsch.`) |
| 400 | Missing / invalid code (`Keinen passenden Authentifizierungscode gefunden.`) |
---
## `GET /v1/init` — Sync status
Returns how many entities changed since each cursor. Poll this; only fetch list endpoints when the matching `*_count` is greater than `"0"`.
### Query parameters
| Param | Default | Cursor for |
|---|---|---|
| `lastChangedProduct` | `0` | Products |
| `lastChangedCategory` | `0` | Categories |
| `lastChangedCustomerGroup` | `0` | Customer groups |
| `lastChangedCompositeProduct` | `0` | Composite (Stückliste) products |
| `lastChangedDeletedEntity` | `0` | Deleted entities |
### Response (200)
```json
{
"version": "1.10.12.0",
"product_count": "3",
"category_count": "0",
"customer_count": "0",
"customerGroup_count": "1",
"compositeProduct_count": "0",
"configurationGroup_count": "0",
"configurationItem_count": "0",
"deletedEntity_count": "0",
"max_orderId_count": "100"
}
```
| Field | Meaning |
|---|---|
| `*_count` | Number of rows with `lastChanged` **strictly greater than** the matching cursor |
| `max_orderId_count` | Highest POS order id already mapped for this shop (`Pos.tAuftragMapping`) |
| `customer_count`, `configurationGroup_count`, `configurationItem_count` | Always `"0"` (not implemented) |
---
## Catalog sync endpoints
Shared pattern for list endpoints:
1. Call with the same cursor used on `/v1/init`.
2. Results are ordered by `lastChanged` ascending.
3. After each page, set the cursor to the highest `lastChanged` in the batch.
4. Repeat until `/v1/init` reports a count of `"0"`.
### `GET /v1/category`
| Param | Default |
|---|---|
| `lastChangedCategory` | `0` |
| `limit` | `20` |
```json
[
{
"_id": "12",
"imghash": "abc...",
"imgsrc": "abc...",
"name": "Beverages",
"pid": "0",
"discounts": [],
"sort": "10",
"lastChanged": "24001",
"updated_at": "2026-07-20 15:00:00",
"created_at": "2026-07-20 15:00:00"
}
]
```
- `pid` is `"0"` for top-level categories (under the shop root).
- `imghash` / `imgsrc` are image content hashes for `/v1/cimage`.
### `GET /v1/product`
| Param | Default |
|---|---|
| `lastChangedProduct` | `0` |
| `limit` | `20` |
```json
[
{
"_id": "1234",
"sku": "ART-001",
"barcode": "4006381333931",
"name": "Example product",
"tax_rate": "19",
"price": "11.90",
"created_at": "2025-01-15 10:00:00",
"lastChanged": "24609",
"categories_id": "12",
"categories": [{ "categoryId": "12" }],
"prices": [
{ "customerGroupId": "1", "customerId": "0", "price": "11.90", "quantity": "0" }
],
"imghash": "def...",
"imgsrc": "def...",
"is_parent": "0",
"parent": "0",
"variants": "",
"isCompositeProduct": "0",
"attributes": [
{ "aname": "Color", "aprice": "0.0", "asort": "1", "atype": "1", "agroup": "JTL-POS" }
],
"deposit": "0",
"deposit_name": "",
"d_price": "0.0"
}
]
```
Notable fields:
| Field | Notes |
|---|---|
| `price` / `prices[].price` | Gross prices (net × tax) |
| `prices` | One entry per customer group |
| `isCompositeProduct` | `"1"` if the article is a Stückliste parent |
| `imghash` | Pass to `/v1/pimage?path=...` |
| Deposit fields | Present when JTL-POS Pfand attributes are set |
| `attributes` | Article attributes from `tArtikelAttribut` (incl. Pfand) |
Many other product fields are filled with static defaults (`sort`, `use_stock`, `unit`, etc.) for JTL-POS compatibility.
**Node only:** `prices[]` applies percustomer-group net overrides from `tPreis` / `tPreisDetail`. C++ currently fills every group with the base gross price.
### `GET /v1/productcomposite`
BOM rows for composite (Stückliste) parents.
| Param | Default |
|---|---|
| `lastChangedCompositeProduct` | `0` |
| `limit` | `100` |
```json
[
{
"productId": "100",
"productIdComponent": "200",
"quantity": "2.00",
"lastChanged": "25000"
}
]
```
### `GET /v1/customergroup`
| Param | Default |
|---|---|
| `lastChangedCustomerGroup` | `0` |
No `limit` — returns all groups newer than the cursor.
```json
[
{
"customerGroupId": "1",
"name": "Endkunde",
"standard": "1",
"discountPercent": "0.00",
"lastChanged": "100"
}
]
```
### `GET /v1/deletedentity`
| Param | Default |
|---|---|
| `lastChangedDeletedEntity` | `0` |
| `limit` | `600` |
```json
[
{ "entityId": "123", "entityType": "1", "lastChanged": "208980" }
]
```
| `entityType` | Entity to remove locally |
|---|---|
| `1` | Product |
| `2` | Category |
| `7` | Composite product |
---
## Images
### `GET /v1/pimage` — Product image
### `GET /v1/cimage` — Category image
| Param | Required | Notes |
|---|---|---|
| `path` | yes | Image hash from `imghash` / `imgsrc` |
| `size` | no | **C++ only.** Target max dimension in px (default `200`). Use `0` for the full unresized image. Node always serves a ≤200px resize and ignores `size`. |
**Success (200):** binary image body (`image/jpeg`, `image/png`, …), resized by the server.
**Errors:**
| Status | Body |
|---|---|
| 400 | `{ "Message": "Missing required query parameter 'path'." }` |
| 404 | `{ "Message": "No image was found for path '...'" }` |
---
## `POST /v1/order` — Submit orders
Creates Wawi sales orders (`Verkauf.tAuftrag`), maps POS ids, optionally delivers stock, and records payments.
### Request
```json
{
"orders": [ { /* Order */ } ]
}
```
### Order object
| Field | Type | Notes |
|---|---|---|
| `externalId` | string | POS order id. Used for idempotency via `Pos.tAuftragMapping`. |
| `externalOrderNumber` | string | Receipt / external number stored on the Auftrag |
| `creationDate` | string | `YYYY-MM-DD HH:mm:ss` (local server time). Fallback: now |
| `currencyIso` | string | Default `EUR` |
| `paymentMethodName` | string | Zahlungsart name (e.g. `BAR`); created if missing |
| `shippingName` | string | Versandart name; default lookup `Selbstabholer` |
| `customerNumber` | string | Existing customer number, or empty/`0` for walk-in |
| `billingAddress` | object | See address fields below |
| `shippingAddress` | object | See address fields below |
| `orderItems` | array | Line items |
| `payments` | array | Payments to apply |
| `settings.deliver` | string/bool | Default deliver on create (`1`/`true`). Set `0`/`false` to skip |
| `settings.importSetting` | string | `0` → read-only order, no Wawi invoice |
| `settings.invoiceSetting` | string | Bit flags; bit 0 enables Wawi invoice path |
### Address fields
Used on `billingAddress` / `shippingAddress`:
`firstName`, `lastName`, `company`, `street`, `zipCode`, `city`, `countryIso`, `phone`, `fax`, `email`, `mobile`, `salutation`, `title`, `state`, `extraAddressLine`, `addressAddition`, `discount`, `birthday`, `customerGroupId`, `debtorNumber`
Walk-in: omit `customerNumber` (or `"0"`) and use a minimal billing address (`lastName` often `Laufkunde`).
### Order item fields
| Field | Notes |
|---|---|
| `externalId` | POS line id (mapped in `Pos.tAuftragPositionMapping`) |
| `sku` | Article number; looked up in `tArtikel` |
| `name` | Display name |
| `quantity` | Quantity |
| `priceNet` / `priceGross` | Unit prices; net derived from gross + vat if omitted |
| `vat` | VAT percent (e.g. `"19.00"`) |
| `discountPercent` | Line discount |
| `unit` | Unit label |
| `note` | Position note |
| `type` | `2` = shipping line; otherwise article/free position |
| `isReturn` | `"1"` for returns |
If no shipping line (`type: "2"`) is present and the order has sale items, the server may inject a `Selbstabholer` shipping position automatically.
### Payment fields
| Field | Notes |
|---|---|
| `paymentId` | Only payments with id `≤ 0` / empty are inserted (new payments) |
| `paymentMethodName` | Overrides order-level method if set |
| `amount` | Payment amount |
### Response
Array of per-order results. HTTP **200** if all succeed; **500** if any failed or was skipped as already mapped.
```json
[
{ "status": "OK", "externalOrderId": "99", "message": "" },
{ "status": "ERROR", "externalOrderId": "100", "message": "..." }
]
```
| `status` | Meaning |
|---|---|
| `OK` | Order created |
| `ERROR` | Create failed, **or** order already mapped (idempotent skip) |
Invalid JSON body → `500` with `[]`.
### Minimal example
```json
{
"orders": [
{
"externalId": "99",
"externalOrderNumber": "T00100",
"creationDate": "2026-07-20 15:01:41",
"currencyIso": "EUR",
"paymentMethodName": "BAR",
"customerNumber": "0",
"shippingName": "",
"billingAddress": {
"lastName": "Laufkunde",
"countryIso": "DE"
},
"shippingAddress": {
"lastName": "Laufkunde",
"countryIso": "DE"
},
"orderItems": [
{
"externalId": "146",
"sku": "ART-001",
"name": "Example product",
"quantity": "1",
"priceNet": "10.00",
"priceGross": "11.90",
"vat": "19.00",
"type": "1",
"isReturn": "0",
"discountPercent": "0"
}
],
"payments": [
{ "paymentId": "", "paymentMethodName": "BAR", "amount": "11.90" }
],
"settings": {
"importSetting": "0",
"invoiceSetting": "0",
"deliver": "1"
}
}
]
}
```
---
## Errors (general)
| Status | Body |
|---|---|
| 404 | `{ "Message": "No HTTP resource was found that matches the request URI '...'." }` |
| 500 | `{ "Message": "<error message>" }` (unhandled handler errors) |
---
## Endpoint index
| Method | Path | Role |
|---|---|---|
| `GET` | `/v1/client` | Pairing / discovery |
| `GET` | `/v1/init` | Change counts for all entity cursors |
| `GET` | `/v1/category` | Category deltas |
| `GET` | `/v1/product` | Product deltas |
| `GET` | `/v1/productcomposite` | Composite BOM deltas |
| `GET` | `/v1/customergroup` | Customer group deltas |
| `GET` | `/v1/deletedentity` | Deletion deltas |
| `GET` | `/v1/pimage` | Product image by hash |
| `GET` | `/v1/cimage` | Category image by hash |
| `POST` | `/v1/order` | Create order(s) |
Handlers: Node [`src/endpoints/`](src/endpoints/), C++ [`jtlsrv-cpp/src/endpoints/`](jtlsrv-cpp/src/endpoints/).
---
## Node vs C++
Both expose the same route table, pairing flow, cursor sync protocol, and order request/response contract. A client written against this document works with either binary.
### Same (client-visible)
| Area | Behavior |
|---|---|
| Routes | Identical methods and paths (`/v1/...` and `/api/v1/...`) |
| Pairing | Short `authCode` → discovery; 6-digit → pair / revoke / register |
| Init / list params | Same cursor names and default `limit` values |
| Category / composite / deleted | Same JSON field names and cursor semantics |
| Order | Same `{ "orders": [...] }` body, result array, `OK` / `ERROR`, HTTP 200 vs 500 |
| Auth | Neither validates `authToken` on sync/order after pairing |
### Differences that affect clients
| Area | Node | C++ |
|---|---|---|
| **Group prices** | Percustomer-group overrides from `tPreis` | Every group gets the base gross price |
| **Image `size`** | Ignored; always resize ≤200px | Honored (`size`, default `200`; `0` = full) |
| **`discountPercent`** | Fixed 2 decimals (`"0.00"`) | `std::to_string` of the double (e.g. `"0.000000"`) |
| **Init when DB is down** | Handler throws → HTTP 500 | Returns all counts as `"0"` (HTTP 200) |
| **Product `created_at`** | Formatted `YYYY-MM-DD HH:mm:ss` | Whatever ODBC returns for `dErstelldatum` |
### Safe client strategy
- Treat optional product fields (per-group price overrides) as best-effort when talking to C++.
- Prefer `price` for the default gross; do not assume `prices[]` differ by group unless you know you are on Node.
- Call images with `?path=<hash>` only; omit `size` unless you need C++-specific sizing.
- Parse `discountPercent` as a number, not a fixed-format string.
- On init, treat HTTP 500 and all-zero counts similarly as “nothing to sync / unavailable”.

View File

@@ -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,13 +13,68 @@ const certPath = path.join(certsDir, 'cert.pem');
fs.mkdirSync(certsDir, { recursive: true }); fs.mkdirSync(certsDir, { recursive: true });
const subject = '/CN=localhost/O=JTL POS Sync/C=DE'; function isIp(value) {
const san = 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0'; return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(':');
}
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 rsa:2048 -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' }
); );
const sha1 = execSync(`openssl x509 -in "${certPath}" -noout -fingerprint -sha1`, {
encoding: 'utf8',
})
.trim()
.split('=')[1];
const serial = execSync(`openssl x509 -in "${certPath}" -noout -serial`, {
encoding: 'utf8',
})
.trim()
.split('=')[1];
logger.success(`Wrote ${keyPath}`); logger.success(`Wrote ${keyPath}`);
logger.success(`Wrote ${certPath}`); logger.success(`Wrote ${certPath}`);
logger.info(`SAN: ${sanParts.join(', ')}`);
logger.info(`Fingerprint: ${sha1.replace(/:/g, '')}`);
logger.info(`Serial: ${serial}`);

View File

@@ -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

View 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

File diff suppressed because one or more lines are too long

View File

@@ -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);

View File

@@ -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);

View File

@@ -0,0 +1,239 @@
#pragma once
// Port of src/queries/product-attributes.js — article attributes + Pfand deposit fields.
#include "../db/pool.hpp"
#include "../http.hpp"
#include "../log.hpp"
#include "nlohmann/json.hpp"
#include <algorithm>
#include <cstdio>
#include <map>
#include <sstream>
#include <string>
#include <vector>
struct ProductAttributeBundle {
nlohmann::json attributes = nlohmann::json::array();
// null when no Pfand attributes present; otherwise deposit / deposit_name / d_price
nlohmann::json deposit = nullptr;
};
struct AttrRow {
int64_t article_id = 0;
int64_t k_attribut = 0;
int k_shop = 0;
int n_sortierung = 0;
int k_feld_typ = 0;
std::string c_gruppe_name;
std::string c_attribut_id;
std::string c_name;
std::string c_wert_varchar;
bool has_n_wert_int = false;
int n_wert_int = 0;
bool has_f_wert_decimal = false;
double f_wert_decimal = 0.0;
};
inline std::string join_int64s(const std::vector<int64_t>& ids) {
if (ids.empty()) return "NULL";
std::ostringstream oss;
for (size_t i = 0; i < ids.size(); ++i) {
if (i) oss << ',';
oss << ids[i];
}
return oss.str();
}
inline const std::vector<int64_t>& get_pfand_k_attribute_ids() {
static std::vector<int64_t> cached;
static bool loaded = false;
if (loaded) return cached;
const char* sql =
"SELECT a.kAttribut "
"FROM dbo.tAttribut a "
"INNER JOIN dbo.tAttributSprache s ON s.kAttribut = a.kAttribut AND s.kSprache = 0 "
"WHERE a.cGruppeName = 'JTL-POS' "
"AND a.cAttributId IN ('Pfandartikel', 'Pfandart (Bezeichnung auf Ausdruck)', 'Pfandbetrag')";
ResultSet rs;
if (!get_pool().execute(sql, rs)) {
logc::warn("failed to load Pfand attribute metadata");
return cached;
}
for (auto& row : rs) {
if (!row.empty()) {
cached.push_back(parse_int64(row[0].str, 0));
}
}
loaded = true;
return cached;
}
inline const std::vector<int64_t>& get_active_shop_ids_for_attrs() {
static std::vector<int64_t> cached;
static bool loaded = false;
if (loaded) return cached;
const char* sql =
"WITH ActiveShops AS ("
" SELECT ss.kShop, s.kKategorie AS rootKategorie "
" FROM dbo.tShopSubshop ss "
" INNER JOIN dbo.tShop s ON s.kShop = ss.kShop "
" WHERE ss.nGesperrt = 0"
") SELECT DISTINCT ash.kShop FROM ActiveShops ash";
ResultSet rs;
if (!get_pool().execute(sql, rs)) {
logc::warn("failed to load active shops for attributes");
return cached;
}
for (auto& row : rs) {
if (!row.empty()) {
cached.push_back(parse_int64(row[0].str, 0));
}
}
loaded = true;
return cached;
}
inline nlohmann::json to_pos_attribute(const AttrRow& row) {
return {
{"aname", row.c_name},
{"aprice", "0.0"},
{"asort", std::to_string(row.n_sortierung)},
{"atype", std::to_string(row.k_feld_typ)},
{"agroup", row.c_gruppe_name},
};
}
inline nlohmann::json derive_deposit_fields(const std::vector<AttrRow>& rows) {
const AttrRow* pfand_artikel = nullptr;
const AttrRow* pfand_art = nullptr;
const AttrRow* pfand_betrag = nullptr;
for (const auto& row : rows) {
if (row.c_attribut_id == "Pfandartikel") pfand_artikel = &row;
else if (row.c_attribut_id == "Pfandart (Bezeichnung auf Ausdruck)") pfand_art = &row;
else if (row.c_attribut_id == "Pfandbetrag") pfand_betrag = &row;
}
if (!pfand_artikel && !pfand_art && !pfand_betrag) {
return nullptr;
}
std::string d_price = "0.0";
if (pfand_betrag && pfand_betrag->has_f_wert_decimal) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%.2f", pfand_betrag->f_wert_decimal);
d_price = buf;
}
return {
{"deposit", (pfand_artikel && pfand_artikel->has_n_wert_int && pfand_artikel->n_wert_int == 1) ? "1" : "0"},
{"deposit_name", pfand_art ? pfand_art->c_wert_varchar : ""},
{"d_price", d_price},
};
}
inline std::vector<AttrRow> merge_article_attributes(std::vector<AttrRow> rows) {
// Later rows for the same kAttribut win (shop-specific over global), matching JS Map set order.
std::map<int64_t, AttrRow> by_attr;
for (auto& row : rows) {
by_attr[row.k_attribut] = std::move(row);
}
std::vector<AttrRow> merged;
merged.reserve(by_attr.size());
for (auto& [_, row] : by_attr) {
merged.push_back(std::move(row));
}
std::sort(merged.begin(), merged.end(), [](const AttrRow& a, const AttrRow& b) {
return a.n_sortierung < b.n_sortierung;
});
return merged;
}
inline AttrRow parse_attr_row(const Row& row) {
AttrRow a;
if (row.size() < 11) return a;
a.article_id = parse_int64(row[0].str, 0);
a.k_attribut = parse_int64(row[1].str, 0);
a.k_shop = parse_int(row[2].str, 0);
a.n_sortierung = parse_int(row[3].str, 0);
a.k_feld_typ = parse_int(row[4].str, 0);
a.c_gruppe_name = row[5].type == CellType::Null ? "" : row[5].str;
a.c_attribut_id = row[6].type == CellType::Null ? "" : row[6].str;
a.c_name = row[7].type == CellType::Null ? "" : row[7].str;
a.c_wert_varchar = row[8].type == CellType::Null ? "" : row[8].str;
if (row[9].type != CellType::Null) {
a.has_n_wert_int = true;
a.n_wert_int = parse_int(row[9].str, 0);
}
if (row[10].type != CellType::Null) {
a.has_f_wert_decimal = true;
a.f_wert_decimal = parse_double(row[10].str, 0);
}
return a;
}
inline std::map<int64_t, ProductAttributeBundle> get_product_attributes(
const std::vector<int64_t>& article_ids) {
std::map<int64_t, ProductAttributeBundle> out;
if (article_ids.empty()) return out;
const auto& pfand_ids = get_pfand_k_attribute_ids();
const auto& active_shops = get_active_shop_ids_for_attrs();
std::string sql =
"SELECT "
" aa.kArtikel AS articleId, "
" aa.kAttribut, "
" aa.kShop, "
" at.nSortierung, "
" at.kFeldTyp, "
" at.cGruppeName, "
" at.cAttributId, "
" ats.cName, "
" aas.cWertVarchar, "
" aas.nWertInt, "
" aas.fWertDecimal "
"FROM dbo.tArtikelAttribut aa "
"INNER JOIN dbo.tArtikelAttributSprache aas "
" ON aas.kArtikelAttribut = aa.kArtikelAttribut AND aas.kSprache = 0 "
"INNER JOIN dbo.tAttribut at ON at.kAttribut = aa.kAttribut "
"INNER JOIN dbo.tAttributSprache ats ON ats.kAttribut = at.kAttribut AND ats.kSprache = 0 "
"WHERE aa.kArtikel IN (" + join_int64s(article_ids) + ") "
" AND ("
" aa.kShop = 0 "
" OR ("
" aa.kShop IN (" + join_int64s(active_shops) + ") "
" AND aa.kAttribut IN (" + join_int64s(pfand_ids) + ") "
" )"
" ) "
"ORDER BY aa.kArtikel, at.nSortierung, aa.kShop";
ResultSet rs;
if (!get_pool().execute(sql, rs)) {
logc::warn("product attributes query failed (%zu articles)", article_ids.size());
return out;
}
std::map<int64_t, std::vector<AttrRow>> rows_by_article;
for (const auto& row : rs) {
AttrRow a = parse_attr_row(row);
if (a.article_id == 0) continue;
rows_by_article[a.article_id].push_back(std::move(a));
}
for (auto& [article_id, rows] : rows_by_article) {
auto merged = merge_article_attributes(std::move(rows));
ProductAttributeBundle bundle;
for (const auto& row : merged) {
bundle.attributes.push_back(to_pos_attribute(row));
}
bundle.deposit = derive_deposit_fields(merged);
out[article_id] = std::move(bundle);
}
return out;
}

View File

@@ -4,6 +4,7 @@
#include "nlohmann/json.hpp" #include "nlohmann/json.hpp"
#include "shop.hpp" #include "shop.hpp"
#include "customer_groups.hpp" #include "customer_groups.hpp"
#include "product_attributes.hpp"
#include "../config.hpp" #include "../config.hpp"
#include "../http.hpp" #include "../http.hpp"
#include <cmath> #include <cmath>
@@ -74,6 +75,14 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
} }
auto cg_ids = get_customer_group_ids(); auto cg_ids = get_customer_group_ids();
std::vector<int64_t> article_ids;
article_ids.reserve(rs.size());
for (auto& row : rs) {
article_ids.push_back(parse_int64(row[0].str, 0));
}
auto attrs_by_article = get_product_attributes(article_ids);
nlohmann::json result = nlohmann::json::array(); nlohmann::json result = nlohmann::json::array();
for (auto& row : rs) { for (auto& row : rs) {
@@ -102,6 +111,21 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
}); });
} }
int64_t article_id = parse_int64(row[0].str, 0);
nlohmann::json attributes = nlohmann::json::array();
std::string deposit = "0";
std::string deposit_name = "";
std::string d_price = "0.0";
auto attrs_it = attrs_by_article.find(article_id);
if (attrs_it != attrs_by_article.end()) {
attributes = attrs_it->second.attributes;
if (!attrs_it->second.deposit.is_null()) {
deposit = attrs_it->second.deposit.value("deposit", "0");
deposit_name = attrs_it->second.deposit.value("deposit_name", "");
d_price = attrs_it->second.deposit.value("d_price", "0.0");
}
}
nlohmann::json product = { nlohmann::json product = {
{"_id", row[0].str}, {"_id", row[0].str},
{"imghash", row[7].str.empty() ? nullptr : nlohmann::json(row[7].str)}, {"imghash", row[7].str.empty() ? nullptr : nlohmann::json(row[7].str)},
@@ -119,13 +143,13 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
{"parent", parse_int64(row[10].str, 0) > 0 ? row[10].str : "0"}, {"parent", parse_int64(row[10].str, 0) > 0 ? row[10].str : "0"},
{"variants", row[12].str}, {"variants", row[12].str},
{"isCompositeProduct", row[11].str}, {"isCompositeProduct", row[11].str},
{"attributes", nlohmann::json::array()}, {"attributes", std::move(attributes)},
{"sort", "0"}, {"sort", "0"},
{"p_price", "0.00"}, {"p_price", "0.00"},
{"discountable", "0"}, {"discountable", "0"},
{"deposit", "0"}, {"deposit", deposit},
{"discount", ""}, {"discount", ""},
{"d_price", "0.0"}, {"d_price", d_price},
{"tax_rate2", ""}, {"tax_rate2", ""},
{"use_in_out_tax", "0"}, {"use_in_out_tax", "0"},
{"barcode", row[13].str.empty() ? nullptr : nlohmann::json(row[13].str)}, {"barcode", row[13].str.empty() ? nullptr : nlohmann::json(row[13].str)},
@@ -139,7 +163,7 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
{"tags", ""}, {"tags", ""},
{"variants", row[12].str}, {"variants", row[12].str},
{"print_kitchen_receipt", "0"}, {"print_kitchen_receipt", "0"},
{"deposit_name", ""}, {"deposit_name", deposit_name},
{"updated_at", "0001-01-01 00:00:00"}, {"updated_at", "0001-01-01 00:00:00"},
{"configurationGroups", ""}, {"configurationGroups", ""},
{"options", nullptr}, {"options", nullptr},

View File

@@ -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()) {

306
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"chalk": "^5.6.2", "chalk": "^5.6.2",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"mssql": "^12.7.0", "mssql": "^12.7.0",
"qrcode": "^1.5.4",
"sharp": "^0.35.3" "sharp": "^0.35.3"
}, },
"engines": { "engines": {
@@ -884,6 +885,30 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -984,6 +1009,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "5.6.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -1002,6 +1036,35 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": { "node_modules/commander": {
"version": "11.1.0", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
@@ -1028,6 +1091,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": { "node_modules/decompress-response": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -1101,6 +1173,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.4.2", "version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -1122,6 +1200,12 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/end-of-stream": { "node_modules/end-of-stream": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -1164,12 +1248,34 @@
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/github-from-package": { "node_modules/github-from-package": {
"version": "0.0.0", "version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
@@ -1265,6 +1371,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-inside-container": { "node_modules/is-inside-container": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
@@ -1347,6 +1462,18 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -1492,6 +1619,60 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/prebuild-install": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1538,6 +1719,23 @@
"once": "^1.3.1" "once": "^1.3.1"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/rc": { "node_modules/rc": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -1569,6 +1767,21 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/run-applescript": { "node_modules/run-applescript": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
@@ -1619,6 +1832,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.35.3", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
@@ -1728,6 +1947,32 @@
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
}, },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
@@ -1874,6 +2119,26 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1894,6 +2159,47 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
} }
} }
} }

View File

@@ -8,10 +8,14 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"cert": "node generate-cert.js", "cert": "node generate-cert.js",
"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"
}, },
@@ -23,6 +27,7 @@
"chalk": "^5.6.2", "chalk": "^5.6.2",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"mssql": "^12.7.0", "mssql": "^12.7.0",
"qrcode": "^1.5.4",
"sharp": "^0.35.3" "sharp": "^0.35.3"
} }
} }

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* Generate a pairing QR code for JTL-POS.
*
* Usage:
* node scripts/create-pairing-qr.mjs <host-ip>
* npm run qr -- 192.168.1.50
*
* Reads PORT / PAIRING_CODE from .env and the TLS cert from certs/cert.pem.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import QRCode from 'qrcode';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
dotenv.config({ path: path.join(root, '.env') });
const host = process.argv[2];
if (!host) {
console.error('Usage: node scripts/create-pairing-qr.mjs <host-ip>');
process.exit(1);
}
const port = String(process.env.PORT || '4443');
const pin = String(process.env.PAIRING_CODE || '307018');
const certPath = path.join(root, 'certs', 'cert.pem');
// Compact: base64 DER only (no PEM headers / line wraps) — client can re-wrap if needed
const cert = fs
.readFileSync(certPath, 'utf8')
.replace(/-----BEGIN CERTIFICATE-----/g, '')
.replace(/-----END CERTIFICATE-----/g, '')
.replace(/\s+/g, '');
const payload = {
v: 1,
host,
port,
pin,
cert,
};
const content = JSON.stringify(payload);
const outDir = path.join(root, 'certs');
const pngPath = path.join(outDir, 'pairing-qr.png');
const jsonPath = path.join(outDir, 'pairing-qr.json');
const qrOpts = {
errorCorrectionLevel: 'L', // ~7% recovery — smallest size for the payload
margin: 1,
};
const qr = QRCode.create(content, qrOpts);
// Scale by module count so fewer modules actually looks smaller (not stretched to 512)
const modulePx = 4;
const width = qr.modules.size * modulePx;
await QRCode.toFile(pngPath, content, {
...qrOpts,
type: 'png',
width,
});
fs.writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`);
console.log(await QRCode.toString(content, { type: 'terminal', small: true, ...qrOpts }));
console.log(content);
console.log(`host=${host} port=${port} pin=${pin}`);
console.log(`QR version=${qr.version} modules=${qr.modules.size} png=${width}px`);
console.log(`Wrote ${pngPath}`);
console.log(`Wrote ${jsonPath}`);

View 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 (34 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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); // 24 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);
});

View File

@@ -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

View 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);
});

View File

@@ -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)),
}); });
} }

View File

@@ -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
View 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
View File

@@ -0,0 +1,3 @@
export function isDemoMode() {
return String(process.env.DEMO_MODE || '').toLowerCase() === 'true';
}

139
src/demo/store.js Normal file
View 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,
};
}

View File

@@ -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));
} }

View File

@@ -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\/)/, '');
} }

View File

@@ -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}`;

View File

@@ -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);

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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;
} }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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];

View File

@@ -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;

View File

@@ -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);

View File

@@ -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([