Compare commits

...

22 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
seb
b922229687 barcode 2026-07-16 10:00:17 +02:00
seb
886830cd83 u 2026-07-15 23:40:17 +02:00
seb
72b74f5037 u 2026-07-15 23:11:32 +02:00
seb
1de4144ae2 u 2026-07-15 03:11:30 +02:00
seb
056c9e18cd u 2026-07-15 01:25:38 +02:00
seb
7fa839da4d u 2026-07-14 23:32:19 +02:00
seb
ca7c4af973 u 2026-07-14 23:31:34 +02:00
seb
2c76b401b5 u 2026-07-14 23:30:36 +02:00
seb
262fa63f1b u 2026-07-14 23:30:16 +02:00
seb
245fb047d7 u 2026-07-13 07:41:45 +02:00
91 changed files with 25634 additions and 679 deletions

View File

@@ -1,3 +1,6 @@
# Demo catalog (skips MSSQL; requires `npm run demo:generate` first)
DEMO_MODE=false
# HTTPS POS server
PORT=4443
AUTH_TOKEN=df40ad2067954646abb0499548a52241
@@ -5,11 +8,6 @@ PAIRING_CODE=307018
LOG_FILE=logs/requests.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_ID=1
MANDANT_NAME=eB-Standard

18
.gitignore vendored
View File

@@ -1,6 +1,12 @@
node_modules/
.env
certs/
logs/
capturedDataReference
decompiledReference
/node_modules/
/.env
/certs/
/logs/
/capturedDataReference
/decompiledReference
/scripts/s3-backup/data/
/scripts/s3-backup/tmp/
/scripts/s3-backup/certs/
/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,101 +0,0 @@
---
name: C++ port of jtlsrv
overview: Port the Node.js JTL-POS sync server (~1,400 LOC) to C++17 on Linux, using libuv for the event loop, OpenSSL for HTTPS, unixODBC + msodbcsql18 for MSSQL, and libvips for image resizing — with all blocking work (ODBC, vips) dispatched to uv_queue_work threads.
todos:
- id: skeleton
content: CMake project, .env config loader, logger, libuv TCP skeleton
status: done
- id: tls-http
content: TLS layer (OpenSSL memory-BIO over uv_tcp) + llhttp request parsing
status: done
- id: router
content: Router, pairing store, client/init endpoints (no DB)
status: done
- id: odbc
content: ODBC connection pool + uv_queue_work plumbing + simple queries
status: done
- id: queries
content: Port remaining list queries and JSON shaping (product, category, composite, deleted, attributes)
status: done
- id: images
content: "Image endpoints: blob fetch + libvips thumbnail in worker threads (stub ready, needs libvips + msodbcsql18 driver)"
status: pending
- id: polish
content: Request/order logging, init suppression, graceful shutdown, side-by-side verification vs Node server
status: done
isProject: false
---
# Port jtlsrv to C++ (libuv + ODBC + libvips)
## Answers to your questions
- **libuv?** Yes. Single event-loop thread owns all sockets; libuv's built-in thread pool (`uv_queue_work`) handles blocking work. Set `UV_THREADPOOL_SIZE=8` or so.
- **MSSQL via ODBC?** Yes — unixODBC + Microsoft's `msodbcsql18` driver. It supports named parameters' equivalent (`?` placeholders via `SQLBindParameter`), reading `varbinary(max)` image blobs with `SQLGetData` in chunks, and TLS with `TrustServerCertificate=yes` matching the current `.env` options.
- **ODBC in `uv_queue_work()`?** Yes, mandatory — ODBC calls are fully blocking. Maintain a small connection pool (e.g. 4 `SQLHDBC` handles guarded by a mutex/semaphore); each work item checks out a connection, runs the query, marshals rows into plain structs, and the after-work callback (back on the loop thread) builds JSON and writes the response.
- **Image processing in `uv_queue_work()`?** Yes — same work item as the DB fetch: fetch blob via ODBC, then `vips_thumbnail_buffer()` (200px, fit-inside, no enlargement — exact equivalent of the sharp call in [src/image-resize.js](src/image-resize.js)), return the encoded buffer to the loop thread for sending.
## Caveat worth knowing upfront
libuv has **no TLS support**. The one genuinely new piece of work in this port is an HTTPS layer: OpenSSL memory-BIOs pumped over `uv_tcp_t` (~300 lines, well-trodden pattern), plus **llhttp** (Node's own HTTP parser, plain C) for request parsing. Everything else is a mechanical translation. If you'd rather skip that, `libhv` gives you a libuv-style loop with HTTPS built in — but the plan below assumes plain libuv as requested.
## Dependencies (all in Ubuntu/Debian repos except the MS driver)
- `libuv1-dev`, `libssl-dev`, `libvips-dev`, `unixodbc-dev`, `msodbcsql18` (MS apt repo)
- Vendored single-header/small: `llhttp` (HTTP parser), `nlohmann/json` (or `yyjson` if you want max speed; nlohmann is fine at this scale)
- Build: CMake ≥ 3.16
## Project layout
```
jtlsrv-cpp/
CMakeLists.txt
src/
main.cpp <- server.js: config, signals, startup
config.hpp <- .env loader (tiny hand-rolled parser, ~40 lines)
log.{hpp,cpp} <- logger.js + request-log.js + order-log.js
tls_server.{hpp,cpp}<- uv_tcp + OpenSSL BIO pump (the new part)
http.{hpp,cpp} <- llhttp glue; Request/Response structs; send_json/send_binary
router.{hpp,cpp} <- jtl-server.js: method+path map, init-log suppression
pairing.hpp <- pairing.js (trivial in-memory map)
db/
pool.{hpp,cpp} <- ODBC env + connection pool, work-item helpers
row.hpp <- variant-ish cell type (int64/double/string/blob/null)
endpoints/ <- one .cpp per file in src/endpoints/
queries/ <- one .cpp per file in src/queries/ (SQL strings copied verbatim,
@name params -> ? placeholders)
image.{hpp,cpp} <- image-handler.js + image-resize.js via vips_thumbnail_buffer
```
## Concurrency model
```mermaid
flowchart LR
Client -->|TLS| LoopThread["libuv loop thread: uv_tcp + OpenSSL BIO + llhttp + routing"]
LoopThread -->|uv_queue_work| Workers["uv threadpool workers"]
Workers -->|checkout SQLHDBC| OdbcPool["ODBC connection pool (4 conns)"]
Workers -->|"vips_thumbnail_buffer (image endpoints)"| Vips[libvips]
Workers -->|after_work: rows/buffer| LoopThread
LoopThread -->|JSON / binary response| Client
```
Rules: no libuv handle touched off-loop; work items own their input/output structs (heap-allocated, freed in after-work); JSON serialization happens on the loop thread (cheap at these payload sizes); errors in workers carried back as a status + message field, mapped to the same 500/404 JSON bodies as [src/jtl-server.js](src/jtl-server.js).
## Translation notes per area
- **HTTPS/TLS**: load `certs/key.pem`/`cert.pem` as now; `SSL_CTX` with TLS ≥ 1.2. Keep-alive supported via llhttp message-complete callbacks.
- **Router**: same normalization as [src/http.js](src/http.js) (strip leading `/api` before `/v1/`), `"METHOD path"` key into an `unordered_map`.
- **Queries**: the 12 files in [src/queries](src/queries) port 1:1. `STRING_AGG`, CTEs etc. stay server-side, untouched. The dynamic `IN (...)` in `product-list.js` price overrides stays string-built from integer IDs (safe, they come from the DB). Post-processing (price/gross calc, attribute maps) becomes plain C++ over row structs.
- **Images**: `getImageByHash` reads `bBild`/`bVorschauBild` blobs (chunked `SQLGetData` into `std::vector<uint8_t>`), picks preview vs full exactly as [src/queries/image.js](src/queries/image.js), then vips thumbnail keeping the source format's encoder (jpg/png/gif/webp — libvips handles all).
- **Logging**: `requests.log` / `orders.log` appended via `uv_fs_write` or plain buffered `FILE*` on the loop thread (writes are tiny); replicate the init-suppression window logic from [server.js](server.js) verbatim.
- **Shutdown**: SIGINT/SIGTERM via `uv_signal_t` → stop accepting, drain pool, `uv_stop`.
## Milestones (each independently testable)
1. Skeleton: CMake, config/env, logger, plain-TCP libuv echo — builds and runs.
2. TLS + llhttp layer serving a hardcoded 404 JSON — verify with `curl -k`.
3. Router + pairing + the no-DB endpoints (`client`, `init`) — the existing [test-client.js](test-client.js) should pass against it.
4. ODBC pool + `uv_queue_work` plumbing + `shop`/`customer-groups`/counts queries.
5. Remaining list queries (product, category, composite, deleted-entity, attributes).
6. Image endpoints with libvips; byte-compare output against the Node server using [check-image-rv.mjs](check-image-rv.mjs)-style spot checks.
7. Request/order logs, init-log suppression, graceful shutdown; side-by-side diff of responses vs the Node server on a real POS sync.

View File

@@ -1,5 +1,6 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -12,13 +13,68 @@ const certPath = path.join(certsDir, 'cert.pem');
fs.mkdirSync(certsDir, { recursive: true });
const subject = '/CN=localhost/O=JTL POS Sync/C=DE';
const san = 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0';
function isIp(value) {
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(
`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' }
);
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 ${certPath}`);
logger.info(`SAN: ${sanParts.join(', ')}`);
logger.info(`Fingerprint: ${sha1.replace(/:/g, '')}`);
logger.info(`Serial: ${serial}`);

View File

@@ -0,0 +1,29 @@
---
description: Server testing must use isolated bind address and non-default ports
alwaysApply: true
---
# Server testing — isolated bind only
## Never use the user's ports
Do NOT start servers on:
- Ports from the project's `.env` (e.g. `PORT=4447`)
- Well-known or project defaults (`4443`, `8080`, `3000`, etc.)
- `0.0.0.0` when the user's server may already be running there
Do NOT leave test servers running in the background (`&`, `nohup`, etc.). Stop them before ending the turn.
## OK for agent runtime tests
When a live test is truly needed:
- Bind to **`127.0.0.2`** (or another non-default loopback alias), not `127.0.0.1` / `0.0.0.0`
- Use a **high ephemeral port** (e.g. `19443`, `38447`) — never the project's configured port
- Override via env, e.g. `BIND_ADDRESS=127.0.0.2 PORT=19443 ./build/jtlsrv`
- Kill the test process when done
Prefer compile-only verification (`make`) when that is enough.
If the user is already running the real server, ask them to test and share logs rather than starting another listener.

View File

@@ -1,4 +0,0 @@
build/
odbc-driver/
logs/
*.log

View File

@@ -5,9 +5,6 @@ build/
jtlsrv
jtlsrv-debug
# Compiled commands
compile_commands.json
# ODBC driver (downloaded, not source)
odbc-driver/

View File

@@ -1,75 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project(jtlsrv-cpp LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# --- Dependencies -----------------------------------------------------------
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBUV REQUIRED libuv)
pkg_check_modules(OPENSSL REQUIRED openssl)
pkg_check_modules(VIPS REQUIRED vips)
find_library(ODBC_LIBRARY odbc)
find_path(ODBC_INCLUDE_DIR sql.h)
# llhttp (vendored)
add_library(llhttp STATIC
vendor/llhttp.c
vendor/api.c
vendor/http.c
)
target_include_directories(llhttp PUBLIC vendor)
# nlohmann/json (vendored, header-only)
add_library(json INTERFACE)
target_include_directories(json INTERFACE vendor)
# --- jtlsrv -----------------------------------------------------------------
add_executable(jtlsrv
src/main.cpp
src/log.cpp
src/http.cpp
src/tls_server.cpp
src/pairing.cpp
src/router.cpp
src/endpoints/client.cpp
src/endpoints/init.cpp
src/endpoints/category.cpp
src/endpoints/product.cpp
src/endpoints/productcomposite.cpp
src/endpoints/deleted_entity.cpp
src/endpoints/customergroup.cpp
src/endpoints/order.cpp
src/endpoints/pimage.cpp
src/endpoints/cimage.cpp
src/db/pool.cpp
src/request_log.cpp
src/order_log.cpp
)
target_include_directories(jtlsrv PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${LIBUV_INCLUDE_DIRS}
${OPENSSL_INCLUDE_DIRS}
${ODBC_INCLUDE_DIR}
${VIPS_INCLUDE_DIRS}
)
target_link_libraries(jtlsrv PRIVATE
llhttp
json
${LIBUV_LIBRARIES}
${OPENSSL_LIBRARIES}
${ODBC_LIBRARY}
${VIPS_LIBRARIES}
pthread
)
target_link_directories(jtlsrv PRIVATE
${LIBUV_LIBRARY_DIRS}
${OPENSSL_LIBRARY_DIRS}
${VIPS_LIBRARY_DIRS}
)

View File

@@ -1,39 +0,0 @@
FROM ubuntu:24.04
# Install build deps
RUN apt-get update && apt-get install -y \
build-essential cmake pkg-config \
libuv1-dev libssl-dev \
libodbc2 unixodbc-dev \
libvips-dev \
curl gnupg2 gdbserver \
&& rm -rf /var/lib/apt/lists/*
# Install Microsoft ODBC Driver 18
RUN curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/24.04/prod noble main" > /etc/apt/sources.list.d/mssql-release.list \
&& apt-get update \
&& ACCEPT_EULA=Y apt-get install -y msodbcsql18 mssql-tools18 \
&& rm -rf /var/lib/apt/lists/*
# Copy source
WORKDIR /build
COPY CMakeLists.txt .
COPY vendor/ vendor/
COPY src/ src/
# Build (default Debug for gdb, override with --build-arg CMAKE_BUILD_TYPE=Release)
ARG CMAKE_BUILD_TYPE=Debug
RUN mkdir -p build && cd build \
&& cmake .. -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} \
&& cmake --build . -j$(nproc) \
&& cp jtlsrv /usr/local/bin/
# Copy certs and env
WORKDIR /app
COPY certs/ certs/
COPY .env.docker .env
EXPOSE 4443
CMD ["jtlsrv"]

89
jtlsrv-cpp/Makefile Normal file
View File

@@ -0,0 +1,89 @@
CXX := g++
CC := gcc
PKGFLAGS := $(shell pkg-config --cflags libuv openssl vips)
PKGLIBS := $(shell pkg-config --libs libuv openssl vips)
BUILD_DIR := build
BINARY := jtlsrv
CXXFLAGS := -std=c++17 -Wall -Wextra -Isrc -Ivendor $(PKGFLAGS)
CFLAGS := -Wall -Ivendor
LDFLAGS := $(PKGLIBS) -lodbc -pthread
ifeq ($(DEBUG),1)
CXXFLAGS := -std=c++17 -Wall -Wextra -g -O0 -Isrc -Ivendor $(PKGFLAGS)
CFLAGS := -Wall -g -O0 -Ivendor
else
CXXFLAGS += -O2
CFLAGS += -O2
endif
CPP_SRCS := \
src/main.cpp \
src/log.cpp \
src/http.cpp \
src/tls_server.cpp \
src/pairing.cpp \
src/router.cpp \
src/endpoints/client.cpp \
src/endpoints/init.cpp \
src/endpoints/category.cpp \
src/endpoints/product.cpp \
src/endpoints/productcomposite.cpp \
src/endpoints/deleted_entity.cpp \
src/endpoints/customergroup.cpp \
src/endpoints/order.cpp \
src/endpoints/pimage.cpp \
src/endpoints/cimage.cpp \
src/db/pool.cpp \
src/queries/shop.cpp \
src/request_log.cpp \
src/order_log.cpp
C_SRCS := \
vendor/llhttp.c \
vendor/api.c \
vendor/http.c
CPP_OBJS := $(patsubst src/%.cpp,$(BUILD_DIR)/src/%.o,$(CPP_SRCS))
C_OBJS := $(patsubst vendor/%.c,$(BUILD_DIR)/vendor/%.o,$(C_SRCS))
OBJS := $(CPP_OBJS) $(C_OBJS)
.PHONY: all debug clean rebuild run help
all: $(BINARY)
debug:
$(MAKE) DEBUG=1 all
$(BINARY): $(OBJS)
@mkdir -p $(BUILD_DIR)
$(CXX) $(OBJS) -o $@ $(LDFLAGS)
$(BUILD_DIR)/src/%.o: src/%.cpp
@mkdir -p $(dir $@)
$(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/vendor/%.o: vendor/%.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -rf $(BUILD_DIR) $(BINARY)
rebuild: clean all
run: all
@mkdir -p logs
./$(BINARY)
help:
@echo "Usage: make [target]"
@echo ""
@echo " all Build $(BINARY) (default)"
@echo " debug Build with -g -O0"
@echo " clean Remove $(BUILD_DIR)/"
@echo " rebuild clean + all"
@echo " run Build and start the server"
@echo ""
@echo "Variables: DEBUG=1"

132
jtlsrv-cpp/README.md Normal file
View File

@@ -0,0 +1,132 @@
# jtlsrv
Built on **libuv** (event loop), **OpenSSL** (TLS), **llhttp** (HTTP parsing), **unixODBC** + [**ODBC Driver 18 for SQL Server**](https://learn.microsoft.com/en-us/sql/connect/odbc/microsoft-odbc-driver-for-sql-server) (database), and **libvips** (image thumbnails). Blocking work (ODBC queries, image processing) runs on libuv worker threads.
## Requirements
| Dependency | Purpose |
|---|---|
| C++17 compiler | gcc or clang |
| GNU Make | Build |
| pkg-config | Dependency flags |
| libuv | Async I/O and thread pool |
| OpenSSL | TLS |
| libvips | Product/category image resizing |
| unixODBC + dev headers | ODBC runtime |
| [ODBC Driver 18 for SQL Server](https://learn.microsoft.com/en-us/sql/connect/odbc/microsoft-odbc-driver-for-sql-server) | Connect to JTL-Wawi (MSSQL) |
Vendored in `vendor/`: **llhttp**, **nlohmann/json** (no install needed).
### Install on Ubuntu / Debian
```bash
sudo apt install \
build-essential make pkg-config \
libuv1-dev libssl-dev libvips-dev unixodbc-dev
```
Install [Microsoft's ODBC driver](https://learn.microsoft.com/en-us/sql/connect/odbc/microsoft-odbc-driver-for-sql-server) (required for MSSQL):
```bash
curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | \
sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] \
https://packages.microsoft.com/ubuntu/$(lsb_release -rs)/prod $(lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/mssql-release.list
sudo apt update
sudo ACCEPT_EULA=Y apt install msodbcsql18
```
## Build
```bash
make
```
The binary is written to `./jtlsrv`. After code changes always run `make` before starting the server.
Debug build:
```bash
make debug
```
## Configuration
Copy the example env file from the repo root and edit as needed:
```bash
cp ../.env.example .env
```
Key settings:
| Variable | Default | Description |
|---|---|---|
| `PORT` | `4443` | HTTPS listen port |
| `PAIRING_CODE` | `307018` | Code shown to the POS during pairing |
| `AUTH_TOKEN` | (built-in default) | Device auth token after pairing |
| `MSSQL_*` | — | JTL-Wawi database connection |
| `MANDANT_*` | — | Mandant metadata returned to the client |
| `ROOT_CATEGORY_ID` | `1` | Root category for sync |
| `TAX_ZONE_NAME` | `Zone-EU` | Tax zone for product prices |
| `LOG_FILE` | `logs/requests.log` | Request log path |
| `ORDER_LOG_FILE` | `logs/orders.log` | Order log path |
The server starts without MSSQL if `MSSQL_USER` is unset or the connection fails — pairing and handshake still work, but catalog sync from the database is unavailable.
## TLS certificates
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
mkdir -p certs
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout certs/key.pem -out certs/cert.pem -days 3650 \
-subj '/CN=localhost/O=JTL POS Sync/C=DE' \
-addext 'subjectAltName=DNS:localhost,DNS:sync.quixpos.com,IP:127.0.0.1,IP:0.0.0.0,IP:192.168.188.22'
```
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
| Method | Path | Description |
|---|---|---|
| GET | `/v1/client` | Client discovery / pairing |
| GET | `/v1/init` | Init handshake |
| GET | `/v1/category` | Category sync |
| GET | `/v1/product` | Product sync |
| GET | `/v1/productcomposite` | Composite product sync |
| GET | `/v1/deletedentity` | Deleted entity sync |
| GET | `/v1/customergroup` | Customer group sync |
| POST | `/v1/order` | Submit orders |
| GET | `/v1/pimage` | Product image (resized) |
| GET | `/v1/cimage` | Category image (resized) |
## Project layout
```
Makefile
src/
main.cpp Entry point, route registration, startup
config.hpp .env loader
tls_server.{hpp,cpp} HTTPS over libuv + OpenSSL
http.{hpp,cpp} llhttp request/response handling
router.{hpp,cpp} Route dispatch
pairing.{hpp,cpp} In-memory pairing store
endpoints/ HTTP handlers
queries/ SQL query builders (header-only)
db/pool.{hpp,cpp} ODBC connection pool
vendor/ llhttp, nlohmann/json
```

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

@@ -3,7 +3,10 @@
#include "../log.hpp"
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <thread>
#include <chrono>
static OdbcPool g_pool;
@@ -11,6 +14,35 @@ OdbcPool& get_pool() { return g_pool; }
OdbcPool::~OdbcPool() { disconnect(); }
static void odbc_log_diag(SQLSMALLINT handle_type, SQLHANDLE handle, const char* ctx,
std::string* capture = nullptr) {
if (!handle) {
logc::warn("ODBC %s failed (no handle)", ctx);
if (capture && capture->empty()) *capture = std::string(ctx) + ": no handle";
return;
}
SQLSMALLINT rec = 0;
bool any = false;
while (true) {
SQLCHAR state[6], msg[SQL_MAX_MESSAGE_LENGTH];
SQLINTEGER native;
SQLSMALLINT msg_len;
SQLRETURN diag_rc = SQLGetDiagRec(handle_type, handle, ++rec,
state, &native, msg, sizeof(msg), &msg_len);
if (diag_rc != SQL_SUCCESS && diag_rc != SQL_SUCCESS_WITH_INFO) break;
any = true;
logc::warn("ODBC %s: %s - %s (%d)", ctx, state, msg, (int)native);
if (capture && capture->empty()) {
*capture = std::string(reinterpret_cast<char*>(state)) + " - "
+ std::string(reinterpret_cast<char*>(msg));
}
}
if (!any) {
logc::warn("ODBC %s failed (no diag)", ctx);
if (capture && capture->empty()) *capture = std::string(ctx) + ": no diagnostic";
}
}
int OdbcPool::connect() {
SQLRETURN rc;
@@ -31,7 +63,6 @@ int OdbcPool::connect() {
return -1;
}
// Connection string
std::string conn_str =
"DRIVER={ODBC Driver 18 for SQL Server};"
"SERVER=" + server + "," + std::to_string(port) + ";"
@@ -43,8 +74,7 @@ int OdbcPool::connect() {
logc::info("ODBC connecting to %s:%d/%s as %s", server.c_str(), port, database.c_str(), user.c_str());
// Create pool of 4 connections
const int POOL_SIZE = 4;
const int POOL_SIZE = config::get_int("MSSQL_POOL_SIZE", 1);
conns_.resize(POOL_SIZE);
int connected = 0;
@@ -58,14 +88,14 @@ int OdbcPool::connect() {
if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
SQLAllocHandle(SQL_HANDLE_STMT, conns_[i].hdbc, &conns_[i].hstmt);
SQLSetConnectAttr(conns_[i].hdbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0);
connected++;
} else {
SQLCHAR state[6], msg[SQL_MAX_MESSAGE_LENGTH];
SQLINTEGER native;
SQLSMALLINT msg_len;
SQLGetDiagRec(SQL_HANDLE_DBC, conns_[i].hdbc, 1,
state, &native, msg, sizeof(msg), &msg_len);
logc::warn("ODBC connect[%d] failed: %s - %s", i, state, msg);
char ctx[32];
std::snprintf(ctx, sizeof(ctx), "connect[%d]", i);
odbc_log_diag(SQL_HANDLE_DBC, conns_[i].hdbc, ctx);
SQLFreeHandle(SQL_HANDLE_DBC, conns_[i].hdbc);
conns_[i].hdbc = SQL_NULL_HDBC;
}
}
@@ -87,44 +117,52 @@ void OdbcPool::disconnect() {
if (henv_ != SQL_NULL_HENV) { SQLFreeHandle(SQL_HANDLE_ENV, henv_); henv_ = SQL_NULL_HENV; }
}
OdbcPool::Connection* OdbcPool::checkout() {
std::lock_guard<std::mutex> lock(mutex_);
for (auto& c : conns_) {
if (!c.in_use) { c.in_use = true; return &c; }
OdbcPool::Connection* OdbcPool::checkout_raw() {
for (int attempt = 0; attempt < 300; attempt++) {
{
std::lock_guard<std::mutex> lock(mutex_);
for (auto& c : conns_) {
if (!c.in_use && c.hdbc != SQL_NULL_HDBC) {
c.in_use = true;
return &c;
}
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return nullptr;
}
OdbcPool::ConnGuard OdbcPool::checkout() {
return ConnGuard(checkout_raw(), this);
}
void OdbcPool::release(Connection* c) {
if (!c) return;
if (c->in_transaction) {
SQLEndTran(SQL_HANDLE_DBC, c->hdbc, SQL_ROLLBACK);
SQLSetConnectAttr(c->hdbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0);
c->in_transaction = false;
logc::warn("ODBC: rolled back uncommitted transaction on connection release");
}
std::lock_guard<std::mutex> lock(mutex_);
c->in_use = false;
}
bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params, ResultSet& out) {
Connection* c = checkout();
if (!c) return false;
static bool bind_params(SQLHSTMT hstmt, const std::vector<Param>& params, std::vector<SQLLEN>& indicators) {
indicators.assign(params.size(), 0);
SQLRETURN rc;
// Stable placeholder for null numeric parameters.
static const int64_t null_placeholder = 0;
// Prepare statement
rc = SQLPrepare(c->hstmt, (SQLCHAR*)sql.c_str(), SQL_NTS);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
release(c);
return false;
}
// Wide buffer storage for NVarChar parameters (must outlive SQLExecute)
std::vector<std::vector<SQLWCHAR>> wbufs;
// Bind parameters
for (size_t i = 0; i < params.size(); i++) {
const auto& p = params[i];
SQLUSMALLINT param_num = static_cast<SQLUSMALLINT>(i + 1);
SQLPOINTER val_ptr = nullptr;
SQLPOINTER val_ptr = (SQLPOINTER)&null_placeholder;
SQLLEN buf_len = 0;
SQLLEN indicator = 0;
SQLSMALLINT c_type = SQL_C_CHAR;
SQLSMALLINT sql_type = SQL_VARCHAR;
SQLULEN column_size = 1;
switch (p.type) {
case ParamType::Int:
@@ -132,12 +170,16 @@ bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params,
sql_type = SQL_INTEGER;
val_ptr = (SQLPOINTER)&p.int_val;
buf_len = sizeof(SQLINTEGER);
column_size = sizeof(SQLINTEGER);
indicators[i] = buf_len;
break;
case ParamType::BigInt:
c_type = SQL_C_SBIGINT;
sql_type = SQL_BIGINT;
val_ptr = (SQLPOINTER)&p.int_val;
buf_len = sizeof(SQLBIGINT);
column_size = sizeof(SQLBIGINT);
indicators[i] = buf_len;
break;
case ParamType::Float:
case ParamType::Double:
@@ -145,128 +187,173 @@ bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params,
sql_type = SQL_DOUBLE;
val_ptr = (SQLPOINTER)&p.dbl_val;
buf_len = sizeof(SQLDOUBLE);
column_size = sizeof(SQLDOUBLE);
indicators[i] = buf_len;
break;
case ParamType::NVarChar: {
// Convert narrow string to SQLWCHAR (unsigned short = UTF-16)
// SQLWCHAR is unsigned short (2 bytes) on this platform
std::vector<SQLWCHAR> wbuf(p.str_val.size());
for (size_t k = 0; k < p.str_val.size(); k++)
wbuf[k] = static_cast<SQLWCHAR>((unsigned char)p.str_val[k]);
wbufs.push_back(std::move(wbuf));
auto& wb = wbufs.back();
c_type = SQL_C_WCHAR;
case ParamType::NVarChar:
case ParamType::DateTime:
// UTF-8 SQL_C_CHAR + SQL_WVARCHAR works with msodbcsql18 for nvarchar columns.
c_type = SQL_C_CHAR;
sql_type = SQL_WVARCHAR;
val_ptr = (SQLPOINTER)wb.data();
buf_len = (SQLLEN)(wb.size() * sizeof(SQLWCHAR));
indicator = wb.empty() ? SQL_NULL_DATA : (SQLLEN)(wb.size() * sizeof(SQLWCHAR));
column_size = p.str_val.empty() ? 1 : p.str_val.size();
if (!p.is_null) {
val_ptr = (SQLPOINTER)p.str_val.c_str();
buf_len = static_cast<SQLLEN>(p.str_val.size());
indicators[i] = SQL_NTS;
}
break;
}
case ParamType::Bit:
c_type = SQL_C_BIT;
sql_type = SQL_BIT;
val_ptr = (SQLPOINTER)&p.int_val;
buf_len = 1;
column_size = 1;
indicators[i] = 1;
break;
}
rc = SQLBindParameter(c->hstmt, param_num, SQL_PARAM_INPUT,
c_type, sql_type, p.str_val.size() + 1, 0, val_ptr, buf_len, &indicator);
if (p.is_null) {
indicators[i] = SQL_NULL_DATA;
}
SQLRETURN rc = SQLBindParameter(hstmt, param_num, SQL_PARAM_INPUT,
c_type, sql_type, column_size, 0, val_ptr, buf_len, &indicators[i]);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
release(c);
return false;
}
}
return true;
}
// Execute
rc = SQLExecute(c->hstmt);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO && rc != SQL_NO_DATA) {
release(c);
static bool fetch_results(SQLHSTMT hstmt, ResultSet& out) {
out.clear();
while (true) {
SQLSMALLINT col_count = 0;
SQLNumResultCols(hstmt, &col_count);
if (col_count > 0) {
std::vector<SQLSMALLINT> col_types(col_count);
for (SQLSMALLINT col = 0; col < col_count; col++) {
SQLSMALLINT data_type;
SQLDescribeCol(hstmt, col + 1, nullptr, 0, nullptr, &data_type, nullptr, nullptr, nullptr);
col_types[col] = data_type;
}
while (true) {
SQLRETURN rc = SQLFetch(hstmt);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) break;
Row row;
for (SQLSMALLINT col = 0; col < col_count; col++) {
Cell cell;
SQLLEN ind;
SQLSMALLINT sql_type = col_types[col];
if (sql_type == SQL_BINARY || sql_type == SQL_VARBINARY ||
sql_type == SQL_LONGVARBINARY) {
std::vector<uint8_t> blob_data;
unsigned char chunk[8192];
while (true) {
rc = SQLGetData(hstmt, col + 1, SQL_C_BINARY, chunk, sizeof(chunk), &ind);
if (ind == SQL_NULL_DATA) { break; }
if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
SQLLEN copy_len = std::min(ind, (SQLLEN)sizeof(chunk));
blob_data.insert(blob_data.end(), chunk, chunk + copy_len);
}
if (rc == SQL_SUCCESS) break;
if (rc != SQL_SUCCESS_WITH_INFO) break;
}
if (!blob_data.empty()) {
cell.type = CellType::Blob;
cell.blob = std::move(blob_data);
}
} else {
char buf[4096];
rc = SQLGetData(hstmt, col + 1, SQL_C_CHAR, buf, sizeof(buf) - 1, &ind);
if (ind == SQL_NULL_DATA) {
cell.type = CellType::Null;
} else if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
cell.type = CellType::String;
cell.str = std::string(buf, std::min((SQLLEN)(sizeof(buf)-1), ind));
}
}
row.push_back(std::move(cell));
}
out.push_back(std::move(row));
}
if (!out.empty()) return true;
}
SQLRETURN rc = SQLMoreResults(hstmt);
if (rc == SQL_NO_DATA) break;
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) break;
}
return true;
}
bool OdbcPool::execute(Connection* c, const std::string& sql, const std::vector<Param>& params, ResultSet& out) {
if (!c) {
last_error_ = "no connection";
logc::warn("ODBC execute: no connection");
return false;
}
// Fetch results if it's a SELECT
out.clear();
SQLSMALLINT col_count = 0;
SQLNumResultCols(c->hstmt, &col_count);
if (col_count > 0) {
// Discover column types
std::vector<SQLSMALLINT> col_types(col_count);
bool has_blobs = false;
for (SQLSMALLINT col = 0; col < col_count; col++) {
SQLSMALLINT data_type;
SQLDescribeCol(c->hstmt, col + 1, nullptr, 0, nullptr, &data_type, nullptr, nullptr, nullptr);
col_types[col] = data_type;
if (data_type == SQL_BINARY || data_type == SQL_VARBINARY || data_type == SQL_LONGVARBINARY)
has_blobs = true;
}
while (true) {
rc = SQLFetch(c->hstmt);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) break;
Row row;
for (SQLSMALLINT col = 0; col < col_count; col++) {
Cell cell;
SQLLEN ind;
SQLSMALLINT sql_type = col_types[col];
// Binary columns: read as binary
if (sql_type == SQL_BINARY || sql_type == SQL_VARBINARY ||
sql_type == SQL_LONGVARBINARY) {
std::vector<uint8_t> blob_data;
unsigned char chunk[8192];
int chunk_count = 0;
while (true) {
rc = SQLGetData(c->hstmt, col + 1, SQL_C_BINARY, chunk, sizeof(chunk), &ind);
if (ind == SQL_NULL_DATA) { break; }
if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
SQLLEN copy_len = std::min(ind, (SQLLEN)sizeof(chunk));
if (has_blobs && chunk_count == 0)
logc::info(" blob col=%d rc=%d ind=%d copy=%d", col, rc, (int)ind, (int)copy_len);
blob_data.insert(blob_data.end(), chunk, chunk + copy_len);
chunk_count++;
}
if (rc == SQL_SUCCESS) break;
if (rc != SQL_SUCCESS_WITH_INFO) break;
}
if (chunk_count > 0) {
cell.type = CellType::Blob;
cell.blob = std::move(blob_data);
if (has_blobs)
logc::info(" blob col=%d total=%zu chunks=%d", col, cell.blob.size(), chunk_count);
}
} else {
// String/numeric columns: read as char
char buf[4096];
rc = SQLGetData(c->hstmt, col + 1, SQL_C_CHAR, buf, sizeof(buf) - 1, &ind);
if (ind == SQL_NULL_DATA) {
cell.type = CellType::Null;
} else if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
cell.type = CellType::String;
cell.str = std::string(buf, std::min((SQLLEN)(sizeof(buf)-1), ind));
}
}
row.push_back(std::move(cell));
}
out.push_back(std::move(row));
}
SQLRETURN rc = SQLPrepare(c->hstmt, (SQLCHAR*)sql.c_str(), SQL_NTS);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
last_error_.clear();
odbc_log_diag(SQL_HANDLE_STMT, c->hstmt, "prepare", &last_error_);
return false;
}
// Reset statement for reuse
std::vector<SQLLEN> indicators;
if (!params.empty() && !bind_params(c->hstmt, params, indicators)) {
last_error_.clear();
odbc_log_diag(SQL_HANDLE_STMT, c->hstmt, "bind", &last_error_);
return false;
}
rc = SQLExecute(c->hstmt);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO && rc != SQL_NO_DATA) {
last_error_.clear();
odbc_log_diag(SQL_HANDLE_STMT, c->hstmt, "execute", &last_error_);
return false;
}
last_error_.clear();
fetch_results(c->hstmt, out);
SQLFreeStmt(c->hstmt, SQL_UNBIND);
SQLFreeStmt(c->hstmt, SQL_CLOSE);
release(c);
return true;
}
bool OdbcPool::execute(Connection* c, const std::string& sql, ResultSet& out) {
return execute(c, sql, {}, out);
}
bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params, ResultSet& out) {
ConnGuard g = checkout();
if (!g) {
logc::warn("ODBC execute: pool exhausted");
return false;
}
bool ok = execute(g.get(), sql, params, out);
return ok;
}
bool OdbcPool::execute(const std::string& sql, ResultSet& out) {
return execute(sql, {}, out);
}
int64_t OdbcPool::execute_scalar(const std::string& sql, const std::vector<Param>& params, int64_t fallback) {
ResultSet rs;
if (!execute(sql, params, rs) || rs.empty() || rs[0].empty()) return fallback;
if (!execute(sql, params, rs)) {
logc::warn("ODBC scalar query failed");
return fallback;
}
if (rs.empty() || rs[0].empty()) return fallback;
const auto& cell = rs[0][0];
if (cell.type == CellType::Int64) return cell.i64;
if (cell.type == CellType::String) {
@@ -274,3 +361,42 @@ int64_t OdbcPool::execute_scalar(const std::string& sql, const std::vector<Param
}
return fallback;
}
bool OdbcPool::begin(Connection* c) {
if (!c) return false;
if (c->in_transaction) {
logc::warn("ODBC begin: connection already in transaction");
return false;
}
SQLRETURN rc = SQLSetConnectAttr(c->hdbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
odbc_log_diag(SQL_HANDLE_DBC, c->hdbc, "begin");
return false;
}
c->in_transaction = true;
return true;
}
bool OdbcPool::commit(Connection* c) {
if (!c || !c->in_transaction) return false;
SQLRETURN rc = SQLEndTran(SQL_HANDLE_DBC, c->hdbc, SQL_COMMIT);
SQLSetConnectAttr(c->hdbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0);
c->in_transaction = false;
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
odbc_log_diag(SQL_HANDLE_DBC, c->hdbc, "commit");
return false;
}
return true;
}
bool OdbcPool::rollback(Connection* c) {
if (!c || !c->in_transaction) return true;
SQLRETURN rc = SQLEndTran(SQL_HANDLE_DBC, c->hdbc, SQL_ROLLBACK);
SQLSetConnectAttr(c->hdbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0);
c->in_transaction = false;
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
odbc_log_diag(SQL_HANDLE_DBC, c->hdbc, "rollback");
return false;
}
return true;
}

View File

@@ -21,35 +21,78 @@ struct Cell {
using Row = std::vector<Cell>;
using ResultSet = std::vector<Row>;
enum class ParamType { Int, BigInt, Float, Double, NVarChar, Bit };
enum class ParamType { Int, BigInt, Float, Double, NVarChar, Bit, DateTime };
struct Param {
ParamType type;
std::string str_val;
int64_t int_val = 0;
double dbl_val = 0.0;
bool is_null = false;
static Param null_int() { Param p; p.type = ParamType::Int; p.is_null = true; return p; }
static Param null_bigint() { Param p; p.type = ParamType::BigInt; p.is_null = true; return p; }
static Param null_nvarchar() { Param p; p.type = ParamType::NVarChar; p.is_null = true; return p; }
};
class OdbcPool {
public:
~OdbcPool();
int connect();
void disconnect();
bool execute(const std::string& sql, const std::vector<Param>& params, ResultSet& out);
bool execute(const std::string& sql, ResultSet& out);
int64_t execute_scalar(const std::string& sql, const std::vector<Param>& params = {}, int64_t fallback = 0);
private:
struct Connection {
SQLHDBC hdbc = SQL_NULL_HDBC;
SQLHSTMT hstmt = SQL_NULL_HSTMT;
bool in_use = false;
bool in_transaction = false;
};
// RAII checkout guard. Returned by checkout().
struct ConnGuard {
Connection* conn = nullptr;
OdbcPool* pool = nullptr;
ConnGuard() = default;
ConnGuard(Connection* c, OdbcPool* p) : conn(c), pool(p) {}
~ConnGuard() { if (conn && pool) pool->release(conn); }
ConnGuard(const ConnGuard&) = delete;
ConnGuard(ConnGuard&& other) noexcept : conn(other.conn), pool(other.pool) { other.conn = nullptr; }
ConnGuard& operator=(ConnGuard&& other) noexcept {
if (this != &other) {
if (conn && pool) pool->release(conn);
conn = other.conn;
pool = other.pool;
other.conn = nullptr;
}
return *this;
}
Connection* operator->() const { return conn; }
Connection* get() const { return conn; }
explicit operator bool() const { return conn != nullptr; }
};
~OdbcPool();
int connect();
void disconnect();
bool execute(const std::string& sql, const std::vector<Param>& params, ResultSet& out);
bool execute(const std::string& sql, ResultSet& out);
int64_t execute_scalar(const std::string& sql, const std::vector<Param>& params = {}, int64_t fallback = 0);
// Transactional execution on an explicitly checked-out connection.
ConnGuard checkout();
bool execute(Connection* conn, const std::string& sql, const std::vector<Param>& params, ResultSet& out);
bool execute(Connection* conn, const std::string& sql, ResultSet& out);
bool begin(Connection* conn);
bool commit(Connection* conn);
bool rollback(Connection* conn);
void release(Connection* c);
const std::string& last_error() const { return last_error_; }
private:
SQLHENV henv_ = SQL_NULL_HENV;
std::vector<Connection> conns_;
std::mutex mutex_;
Connection* checkout();
void release(Connection* c);
std::string last_error_;
Connection* checkout_raw();
};
OdbcPool& get_pool();

View File

@@ -3,9 +3,9 @@
#include "../router.hpp"
#include "../queries/category_list.hpp"
void handle_category(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedCategory", "0"));
int limit = std::stoi(req.get_query_param("limit", "20"));
void handle_category(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t cursor = req.get_query_int64("lastChangedCategory");
int limit = req.get_query_int("limit", 20);
auto categories = get_category_list(cursor, limit);
resp.send_json(200, categories);
}

View File

@@ -4,7 +4,7 @@
#include "../log.hpp"
#include "../queries/image.hpp"
void handle_cimage(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
void handle_cimage(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
std::string path = req.get_query_param("path");
if (path.empty()) {
resp.send_json(400, {{"Message", "Missing required query parameter 'path'."}});

View File

@@ -6,16 +6,27 @@
#include "../tls_server.hpp"
#include "../router.hpp"
static std::string config_string(const json& config, const char* key,
const std::string& def = "") {
if (!config.is_object()) return def;
json v = config.value(key, json());
if (v.is_null()) return def;
if (v.is_string()) return v.get<std::string>();
if (v.is_number_integer()) return std::to_string(v.get<int64_t>());
if (v.is_number_unsigned()) return std::to_string(v.get<uint64_t>());
return def;
}
static json build_client_step1(const json& config) {
return {
{"authCode", nullptr},
{"authToken", config["authToken"]},
{"certificateFingerprint", config["certificateFingerprint"]},
{"certificateSerialNumber", config["certificateSerialNumber"]},
{"mandantId", config["mandantId"]},
{"authToken", config_string(config, "authToken")},
{"certificateFingerprint", config_string(config, "certificateFingerprint")},
{"certificateSerialNumber", config_string(config, "certificateSerialNumber")},
{"mandantId", config_string(config, "mandantId")},
{"mandantName", nullptr},
{"mandantDatabase", nullptr},
{"serverFingerprint", config["serverFingerprint"]},
{"serverFingerprint", config_string(config, "serverFingerprint")},
{"name", nullptr},
{"serverTimestamp", server_timestamp()},
};
@@ -24,12 +35,12 @@ static json build_client_step1(const json& config) {
static json build_client_step2(const std::string& auth_code, const json& config) {
return {
{"authCode", auth_code},
{"authToken", config["authToken"]},
{"certificateFingerprint", config["certificateFingerprint"]},
{"certificateSerialNumber", config["certificateSerialNumber"]},
{"mandantId", config["mandantId"]},
{"mandantName", config["mandantName"]},
{"mandantDatabase", config["mandantDatabase"]},
{"authToken", config_string(config, "authToken")},
{"certificateFingerprint", config_string(config, "certificateFingerprint")},
{"certificateSerialNumber", config_string(config, "certificateSerialNumber")},
{"mandantId", config_string(config, "mandantId")},
{"mandantName", config_string(config, "mandantName")},
{"mandantDatabase", config_string(config, "mandantDatabase")},
{"serverFingerprint", nullptr},
{"name", nullptr},
{"serverTimestamp", server_timestamp()},
@@ -47,7 +58,7 @@ void handle_client(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
if (auth_code.size() == 6) {
if (ctx.pairing_store->has_pairing_code(auth_code)) {
ctx.pairing_store->revoke_pairing_code(auth_code);
ctx.pairing_store->register_device(ctx.config["authToken"].get<std::string>(), name);
ctx.pairing_store->register_device(config_string(ctx.config, "authToken"), name);
return resp.send_json(200, build_client_step2(auth_code, ctx.config));
}
return resp.send_json(400, {

View File

@@ -3,8 +3,8 @@
#include "../router.hpp"
#include "../queries/customer_groups.hpp"
void handle_customergroup(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedCustomerGroup", "0"));
void handle_customergroup(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t cursor = req.get_query_int64("lastChangedCustomerGroup");
auto groups = get_customer_group_list(cursor);
resp.send_json(200, groups);
}

View File

@@ -3,9 +3,9 @@
#include "../router.hpp"
#include "../queries/deleted_entity_list.hpp"
void handle_deleted_entity(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedDeletedEntity", "0"));
int limit = std::stoi(req.get_query_param("limit", "600"));
void handle_deleted_entity(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t cursor = req.get_query_int64("lastChangedDeletedEntity");
int limit = req.get_query_int("limit", 600);
auto deleted = get_deleted_entity_list(cursor, limit);
resp.send_json(200, deleted);
}

View File

@@ -1,22 +1,24 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../log.hpp"
#include "../queries/counts.hpp"
#include "../queries/customer_groups.hpp"
#include "../queries/shop.hpp"
#include "../config.hpp"
void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
int64_t product_cursor = std::stoll(req.get_query_param("lastChangedProduct", "0"));
int64_t category_cursor = std::stoll(req.get_query_param("lastChangedCategory", "0"));
int64_t cg_cursor = std::stoll(req.get_query_param("lastChangedCustomerGroup", "0"));
int64_t composite_cursor = std::stoll(req.get_query_param("lastChangedCompositeProduct", "0"));
int64_t deleted_cursor = std::stoll(req.get_query_param("lastChangedDeletedEntity", "0"));
void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t product_cursor = req.get_query_int64("lastChangedProduct");
int64_t category_cursor = req.get_query_int64("lastChangedCategory");
int64_t cg_cursor = req.get_query_int64("lastChangedCustomerGroup");
int64_t composite_cursor = req.get_query_int64("lastChangedCompositeProduct");
int64_t deleted_cursor = req.get_query_int64("lastChangedDeletedEntity");
int root = config::get_int("ROOT_CATEGORY_ID", 1);
int shop = get_active_shop_id();
int64_t product_count = 0, category_count = 0, cg_count = 0, composite_count = 0, deleted_count = 0;
int64_t max_order_id_count = 0;
if (get_pool().execute_scalar("SELECT 1") != 0) {
product_count = get_product_count(root, shop, product_cursor);
@@ -24,6 +26,13 @@ void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
cg_count = get_customer_group_count(cg_cursor);
composite_count = get_composite_count(shop, composite_cursor);
deleted_count = get_deleted_count(deleted_cursor);
max_order_id_count = get_max_order_id_count(get_active_shop_subshop_id());
if (product_cursor == 0 && category_cursor == 0 &&
product_count == 0 && category_count == 0 && deleted_count == 0) {
logc::warn("init: all counts zero with cursors at 0 — check DB connectivity and shop/category config");
}
} else {
logc::warn("init: DB connection check failed, returning zero counts");
}
resp.send_json(200, {
@@ -36,6 +45,6 @@ void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
{"configurationGroup_count", "0"},
{"configurationItem_count", "0"},
{"deletedEntity_count", std::to_string(deleted_count)},
{"max_orderId_count", "0"}
{"max_orderId_count", std::to_string(max_order_id_count)}
});
}

View File

@@ -1,11 +1,22 @@
// POST /v1/order — creates one or more POS orders.
// Port of src/endpoints/order.js and src/queries/create-order.js.
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../log.hpp"
#include "../order_log.hpp"
#include "../queries/create_order.hpp"
void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
// TODO: full order creation (Milestone 5/6)
// For now, parse the JSON body and acknowledge
#include <string>
static nlohmann::json get_orders(const nlohmann::json& body) {
if (!body.is_object()) return nlohmann::json::array();
auto it = body.find("orders");
if (it == body.end() || !it->is_array()) return nlohmann::json::array();
return *it;
}
void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
nlohmann::json body;
try {
body = nlohmann::json::parse(req.body);
@@ -14,15 +25,59 @@ void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
}
nlohmann::json results = nlohmann::json::array();
if (body.contains("orders") && body["orders"].is_array()) {
for (auto& order : body["orders"]) {
std::string ext_id = order.value("externalId", "");
nlohmann::json orders = get_orders(body);
int successful = 0;
int failed = 0;
for (const auto& order : orders) {
std::string externalOrderId = order.value("externalId", "");
g_order_log.log_order(order.dump(), externalOrderId);
try {
nlohmann::json created = order::create_order(order);
if (created.value("alreadyExists", false)) {
std::string msg = "order already mapped, skipped save (kAuftrag="
+ created.value("orderId", "") + ", "
+ created.value("orderNumber", "") + ")";
logc::error("order externalId=%s skipped: %s",
externalOrderId.c_str(), msg.c_str());
results.push_back({
{"status", "ERROR"},
{"externalOrderId", externalOrderId},
{"message", msg}
});
++failed;
} else {
logc::success("order %s (kAuftrag=%s) created for externalId=%s",
created.value("orderNumber", "").c_str(),
created.value("orderId", "").c_str(),
externalOrderId.c_str());
results.push_back({
{"status", "OK"},
{"externalOrderId", externalOrderId},
{"message", ""}
});
++successful;
}
} catch (const std::exception& ex) {
logc::error("order externalId=%s failed: %s", externalOrderId.c_str(), ex.what());
results.push_back({
{"status", "OK"},
{"externalOrderId", ext_id},
{"message", ""}
{"status", "ERROR"},
{"externalOrderId", externalOrderId},
{"message", ex.what()}
});
++failed;
}
}
resp.send_json(200, results);
int http_status = (failed > 0) ? 500 : 200;
resp.send_json(http_status, results);
if (orders.empty()) {
logc::info("POST /v1/order: no orders in body");
} else {
logc::info("POST /v1/order: %d order(s), %d OK, %d ERROR",
static_cast<int>(orders.size()), successful, failed);
}
}

View File

@@ -4,7 +4,7 @@
#include "../log.hpp"
#include "../queries/image.hpp"
void handle_pimage(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
void handle_pimage(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
std::string path = req.get_query_param("path");
if (path.empty()) {
resp.send_json(400, {{"Message", "Missing required query parameter 'path'."}});

View File

@@ -3,9 +3,9 @@
#include "../router.hpp"
#include "../queries/product_list.hpp"
void handle_product(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedProduct", "0"));
int limit = std::stoi(req.get_query_param("limit", "20"));
void handle_product(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t cursor = req.get_query_int64("lastChangedProduct");
int limit = req.get_query_int("limit", 20);
auto products = get_product_list(cursor, limit);
resp.send_json(200, products);
}

View File

@@ -3,9 +3,9 @@
#include "../router.hpp"
#include "../queries/composite_product_list.hpp"
void handle_productcomposite(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedCompositeProduct", "0"));
int limit = std::stoi(req.get_query_param("limit", "100"));
void handle_productcomposite(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t cursor = req.get_query_int64("lastChangedCompositeProduct");
int limit = req.get_query_int("limit", 100);
auto composites = get_composite_product_list(cursor, limit);
resp.send_json(200, composites);
}

View File

@@ -7,6 +7,25 @@
#include <cstring>
#include <ctime>
// ---------------------------------------------------------------------------
// Safe parsing
// ---------------------------------------------------------------------------
int parse_int(const std::string& s, int fallback) {
if (s.empty()) return fallback;
try { return std::stoi(s); } catch (...) { return fallback; }
}
int64_t parse_int64(const std::string& s, int64_t fallback) {
if (s.empty()) return fallback;
try { return std::stoll(s); } catch (...) { return fallback; }
}
double parse_double(const std::string& s, double fallback) {
if (s.empty()) return fallback;
try { return std::stod(s); } catch (...) { return fallback; }
}
// ---------------------------------------------------------------------------
// HttpRequest
// ---------------------------------------------------------------------------
@@ -39,10 +58,24 @@ std::string HttpRequest::get_query_param(const std::string& key, const std::stri
return decoded;
}
int HttpRequest::get_query_int(const std::string& key, int def) const {
return parse_int(get_query_param(key), def);
}
int64_t HttpRequest::get_query_int64(const std::string& key, int64_t def) const {
return parse_int64(get_query_param(key), def);
}
// ---------------------------------------------------------------------------
// 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) {
if (headers_sent) return;
status_code = code;
@@ -53,6 +86,7 @@ void HttpResponse::send_json(int code, const json& body) {
"Content-Type: application/json; charset=utf-8\r\n"
"Content-Length: " + std::to_string(body_str.size()) + "\r\n"
"Connection: keep-alive\r\n"
+ std::string(CORS_HEADERS) +
"\r\n"
+ body_str;
@@ -68,6 +102,7 @@ void HttpResponse::send_binary(int code, const std::vector<uint8_t>& data, const
"Content-Type: " + content_type + "\r\n"
"Content-Length: " + std::to_string(data.size()) + "\r\n"
"Connection: keep-alive\r\n"
+ std::string(CORS_HEADERS) +
"\r\n";
session_write_binary(session, header, data);
@@ -81,6 +116,7 @@ void HttpResponse::send_empty(int code) {
std::string resp = "HTTP/1.1 " + std::to_string(code) + " " + reason_phrase(code) + "\r\n"
"Content-Length: 0\r\n"
"Connection: keep-alive\r\n"
+ std::string(CORS_HEADERS) +
"\r\n";
session_write(session, resp);

View File

@@ -17,6 +17,11 @@ struct tls_session;
using json = nlohmann::json;
// Safe string-to-number parsing (never throws).
int parse_int(const std::string& s, int fallback = 0);
int64_t parse_int64(const std::string& s, int64_t fallback = 0);
double parse_double(const std::string& s, double fallback = 0.0);
// ---------------------------------------------------------------------------
// Parsed HTTP request
// ---------------------------------------------------------------------------
@@ -32,6 +37,8 @@ struct HttpRequest {
std::string query_string; // "authCode=xxx&name=yyy"
std::string get_query_param(const std::string& key, const std::string& def = "") const;
int get_query_int(const std::string& key, int def = 0) const;
int64_t get_query_int64(const std::string& key, int64_t def = 0) const;
};
// ---------------------------------------------------------------------------

View File

@@ -3,8 +3,13 @@
#include <cstdio>
#include <string>
#include <chrono>
#include <cctype>
#include <uv.h>
#include <openssl/pem.h>
#include <openssl/x509.h>
#include <openssl/evp.h>
#include <openssl/bn.h>
#include "config.hpp"
#include "log.hpp"
@@ -16,6 +21,7 @@
#include <vips/vips.h>
#include "queries/shop.hpp"
#include "queries/customer_groups.hpp"
#include "request_log.hpp"
#include "order_log.hpp"
@@ -27,14 +33,61 @@ static uv_loop_t* loop = nullptr;
static Router router;
static PairingStore pairing_store;
static RequestLog request_log;
static OrderLog order_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 {
{"authToken", config::get("AUTH_TOKEN", "df40ad2067954646abb0499548a52241")},
{"certificateFingerprint", config::get("CERTIFICATE_FINGERPRINT", "BC2114CF407A42724BEEF417960F76DCBF9DE879")},
{"certificateSerialNumber", config::get("CERTIFICATE_SERIAL_NUMBER", "00BFC8BEACDB981B165210EF111CB9D3")},
{"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")},
{"certificateFingerprint", fingerprint},
{"certificateSerialNumber", serial},
{"serverFingerprint", server_fingerprint},
{"mandantId", config::get("MANDANT_ID", "1")},
{"mandantName", config::get("MANDANT_NAME", "eB-Standard")},
{"mandantDatabase", config::get("MANDANT_DATABASE", "eazybusiness")},
@@ -82,26 +135,36 @@ static void handle_request(tls_session* sess) {
}
size_t resp_size = resp_body.size();
const bool is_init = (req.path == "/v1/init");
if (is_init) {
if (!router.should_log_init(url)) {
return;
}
} else {
router.flush_suppressed_init_logs();
}
// Console log with response size and truncated body
const char* ip = sess->peer_ip.c_str();
if (resp_size > 0) {
std::string preview = resp_body.substr(0, std::min(resp_size, (size_t)200));
logc::info("127.0.0.1 %s %s %d %dms [%zu bytes] %s",
req.method.c_str(), url.c_str(), resp.status_code,
std::string preview = resp_body.substr(0, std::min(resp_size, (size_t)220));
logc::info("%s %s %s %d %dms [%zu bytes] %s",
ip, req.method.c_str(), url.c_str(), resp.status_code,
(int)elapsed, resp_size, preview.c_str());
} else {
logc::info("127.0.0.1 %s %s %d %dms",
req.method.c_str(), url.c_str(), resp.status_code, (int)elapsed);
logc::info("%s %s %s %d %dms",
ip, req.method.c_str(), url.c_str(), resp.status_code, (int)elapsed);
}
// Request log
request_log.log("127.0.0.1", req.method, url, resp.status_code, (int)elapsed, resp_body);
request_log.log(ip, req.method, url, resp.status_code, (int)elapsed, resp_body);
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
int main(int argc, char* argv[]) {
int main(int /*argc*/, char* argv[]) {
config::load(".env");
if (VIPS_INIT(argv[0])) {
@@ -109,12 +172,13 @@ int main(int argc, char* argv[]) {
}
int port = config::get_int("PORT", 4443);
std::string bind_address = config::get("BIND_ADDRESS", "0.0.0.0");
std::string cert_path = "certs/cert.pem";
std::string key_path = "certs/key.pem";
loop = uv_default_loop();
server_config = build_config();
server_config = build_config(cert_path.c_str());
// Register routes
router.add_route("GET", "/v1/client", handle_client);
@@ -130,7 +194,7 @@ int main(int argc, char* argv[]) {
// Initialize pairing store
pairing_store.set_pairing_code(config::get("PAIRING_CODE", "307018"), "JTL-POS");
pairing_store.register_device(server_config["authToken"].get<std::string>(), "JTL-POS");
pairing_store.register_device(server_config.value("authToken", std::string("df40ad2067954646abb0499548a52241")), "JTL-POS");
// Connect to MSSQL
if (get_pool().connect() == 0) {
@@ -139,7 +203,10 @@ int main(int argc, char* argv[]) {
config::get("MSSQL_DATABASE").c_str());
if (fetch_active_shop()) {
logc::info("Active shop ID: %d", get_active_shop_id());
} else {
logc::warn("Active shop not loaded — sync filters and order mapping may be wrong");
}
(void)get_customer_group_ids();
} else {
logc::warn("MSSQL connection skipped");
logc::warn("POS handshake will still work; sync from database is not available yet.");
@@ -149,9 +216,9 @@ int main(int argc, char* argv[]) {
// Open log files
request_log.open(config::get("LOG_FILE", "logs/requests.log"));
order_log.open(config::get("ORDER_LOG_FILE", "logs/orders.log"));
g_order_log.open(config::get("ORDER_LOG_FILE", "logs/orders.log"));
int r = tls_server_init(loop, "0.0.0.0", port,
int r = tls_server_init(loop, bind_address.c_str(), port,
cert_path.c_str(), key_path.c_str());
if (r != 0) {
logc::error("failed to start TLS server");
@@ -165,7 +232,7 @@ int main(int argc, char* argv[]) {
uv_run(loop, UV_RUN_DEFAULT);
request_log.close();
order_log.close();
g_order_log.close();
get_pool().disconnect();
vips_shutdown();
logc::info("shutdown complete.");

View File

@@ -3,6 +3,8 @@
#include <ctime>
#include <sys/stat.h>
OrderLog g_order_log;
static void ensure_parent_dir(const std::string& path) {
size_t pos = path.rfind('/');
if (pos != std::string::npos) {
@@ -19,10 +21,9 @@ void OrderLog::close() {
if (fp_) { std::fclose(fp_); fp_ = nullptr; }
}
int OrderLog::log_order(const std::string& order_json) {
void OrderLog::log_order(const std::string& order_json, const std::string& external_id) {
std::lock_guard<std::mutex> lock(mutex_);
sequence_++;
max_external_id_++;
if (fp_) {
auto now = std::chrono::system_clock::now();
@@ -32,13 +33,8 @@ int OrderLog::log_order(const std::string& order_json) {
char ts[32];
std::strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%S", &tm_buf);
std::fprintf(fp_, "%s #%d externalId=%d %s\n",
ts, sequence_, max_external_id_, order_json.c_str());
std::fprintf(fp_, "%s #%d externalId=%s %s\n",
ts, sequence_, external_id.c_str(), order_json.c_str());
std::fflush(fp_);
}
return max_external_id_;
}
std::string OrderLog::get_max_external_id() const {
return std::to_string(max_external_id_);
}

View File

@@ -7,11 +7,11 @@ class OrderLog {
public:
void open(const std::string& path);
void close();
int log_order(const std::string& order_json);
std::string get_max_external_id() const;
void log_order(const std::string& order_json, const std::string& external_id = "");
private:
FILE* fp_ = nullptr;
std::mutex mutex_;
int sequence_ = 0;
int max_external_id_ = 0;
};
extern OrderLog g_order_log;

View File

@@ -41,7 +41,8 @@ inline nlohmann::json get_category_list(int64_t cursor, int limit) {
auto ts = server_timestamp();
nlohmann::json result = nlohmann::json::array();
for (auto& row : rs) {
std::string pid = (std::stoll(row[1].str) == root) ? "0" : row[1].str;
int64_t parent_id = parse_int64(row[1].str, 0);
std::string pid = (parent_id == root) ? "0" : row[1].str;
result.push_back({
{"_id", row[0].str},
{"imghash", row[4].str.empty() ? nullptr : nlohmann::json(row[4].str)},

View File

@@ -0,0 +1,35 @@
#pragma once
#include <string>
#include "xml.hpp"
#include "../db/pool.hpp"
namespace delivery {
inline void commit_picklists(OdbcPool::Connection* c, int kBenutzer, int kSessionId, int kAuftrag) {
std::string bestellungen = xml::element("Bestellung", xml::tag("kBestellung", int64_t(kAuftrag)));
const char* sql =
"DECLARE @xBestellungen XML = CONVERT(XML, ?);"
"DECLARE @xResult XML;"
"EXEC Auslieferung.spPicklistenUebernehmen"
" @Bestellungen = @xBestellungen,"
" @kBenutzer = ?,"
" @nTeillieferung = 0,"
" @kSessionId = ?,"
" @xResult = @xResult OUTPUT";
std::vector<Param> ps = {
{ParamType::NVarChar, bestellungen, 0},
{ParamType::Int, "", kBenutzer},
{ParamType::Int, "", kSessionId}
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs)) {
const std::string& detail = get_pool().last_error();
throw std::runtime_error(detail.empty()
? "commit_picklists failed"
: "commit_picklists failed: " + detail);
}
}
} // namespace delivery

View File

@@ -43,23 +43,32 @@ static const char* DELETED_ENTITY_COUNT_SQL =
"SELECT COUNT(*) AS cnt FROM Pos.vDeletedEntity "
"WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > ?";
static const char* MAX_ORDER_ID_SQL =
"SELECT ISNULL(MAX(kPosAuftrag), 0) AS cnt FROM Pos.tAuftragMapping "
"WHERE kShopSubShop = ?";
inline int64_t get_category_count(int64_t root_cat, int k_shop, int64_t cursor) {
return get_pool().execute_scalar(CATEGORY_COUNT_SQL,
{{ParamType::BigInt,"",root_cat},{ParamType::BigInt,"",k_shop},
{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",cursor}});
{{ParamType::BigInt,"",root_cat},{ParamType::Int,"",k_shop},
{ParamType::Int,"",k_shop},{ParamType::BigInt,"",cursor}});
}
inline int64_t get_product_count(int64_t root_cat, int k_shop, int64_t cursor) {
return get_pool().execute_scalar(PRODUCT_COUNT_SQL,
{{ParamType::BigInt,"",root_cat},{ParamType::BigInt,"",k_shop},
{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",cursor},
{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",cursor}});
{{ParamType::BigInt,"",root_cat},{ParamType::Int,"",k_shop},
{ParamType::Int,"",k_shop},{ParamType::BigInt,"",cursor},
{ParamType::Int,"",k_shop},{ParamType::BigInt,"",cursor}});
}
inline int64_t get_composite_count(int k_shop, int64_t cursor) {
return get_pool().execute_scalar(COMPOSITE_PRODUCT_COUNT_SQL,
{{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",k_shop},
{{ParamType::Int,"",k_shop},{ParamType::Int,"",k_shop},
{ParamType::BigInt,"",cursor}});
}
inline int64_t get_deleted_count(int64_t cursor) {
return get_pool().execute_scalar(DELETED_ENTITY_COUNT_SQL,
{{ParamType::BigInt,"",cursor}});
}
inline int64_t get_max_order_id_count(int k_shop_subshop) {
if (k_shop_subshop <= 0) return 0;
return get_pool().execute_scalar(MAX_ORDER_ID_SQL,
{{ParamType::Int,"",k_shop_subshop}});
}

View File

@@ -0,0 +1,966 @@
#pragma once
#include <algorithm>
#include <cctype>
#include <cmath>
#include <ctime>
#include <chrono>
#include <iomanip>
#include <map>
#include <optional>
#include <memory>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
#include "delivery.hpp"
#include "shop.hpp"
#include "../db/pool.hpp"
#include "../config.hpp"
#include "nlohmann/json.hpp"
namespace order {
struct Defaults;
namespace {
struct Config {
int kBenutzer = 1;
int kFirmaHistory = 0;
int kSprache = 1;
int kPlattform = 7;
int kVersandArt = 0;
int kKundengruppe = 0;
int orderNumberSequence = 3;
int customerNumberSequence = 6;
};
constexpr int VERSANDPOSITION_TYPE = 2;
constexpr int ZAHLUNG_TYPE_ZAHLUNG = 10;
constexpr int NIST_READONLY_NICHT_AENDERBAR = 2;
constexpr int NIST_EXTERNE_RECHNUNG_KEINE = 2;
Config g_config;
std::unique_ptr<Defaults> g_resolved_defaults;
std::map<std::string, nlohmann::json> g_zahlungsart_cache;
} // namespace
struct Defaults {
int kFirmaHistory = 1;
int kVersandArt = 1;
int kKundengruppe = 1;
int kPlattform = 1;
};
inline void load_config() {
g_config.kBenutzer = config::get_int("JTL_KBENUTZER", 1);
g_config.kFirmaHistory = config::get_int("JTL_KFIRMAHISTORY", 0);
g_config.kSprache = config::get_int("JTL_KSPRACHE", 1);
g_config.kPlattform = config::get_int("JTL_KPLATTFORM", 7);
g_config.kVersandArt = config::get_int("JTL_KVERSANDART", 0);
g_config.kKundengruppe = config::get_int("JTL_KKUNDENGRUPPE", 0);
g_config.orderNumberSequence = config::get_int("JTL_ORDER_NUMBER_SEQUENCE", 3);
g_config.customerNumberSequence = config::get_int("JTL_CUSTOMER_NUMBER_SEQUENCE", 6);
}
inline const Defaults& get_defaults(OdbcPool::Connection* c) {
if (g_resolved_defaults) return *g_resolved_defaults;
Defaults d;
d.kFirmaHistory = g_config.kFirmaHistory;
d.kVersandArt = g_config.kVersandArt;
d.kKundengruppe = g_config.kKundengruppe;
d.kPlattform = g_config.kPlattform;
const char* sql =
"SELECT"
" (SELECT MAX(kFirmaHistory) FROM dbo.tFirmaHistory) AS kFirmaHistory,"
" (SELECT MIN(kVersandArt) FROM dbo.tVersandArt) AS kVersandArt,"
" (SELECT TOP 1 kKundenGruppe FROM dbo.tKundenGruppe ORDER BY nStandard DESC, kKundenGruppe) AS kKundengruppe,"
" (SELECT CASE WHEN EXISTS (SELECT 1 FROM dbo.tPlattform WHERE nPlattform = ?) THEN ? ELSE 1 END) AS kPlattform";
std::vector<Param> ps = {
{ParamType::Int, "", g_config.kPlattform},
{ParamType::Int, "", g_config.kPlattform}
};
ResultSet rs;
if (get_pool().execute(c, sql, ps, rs) && !rs.empty() && !rs[0].empty()) {
if (!rs[0][0].str.empty()) d.kFirmaHistory = std::stoi(rs[0][0].str);
if (!rs[0][1].str.empty()) d.kVersandArt = std::stoi(rs[0][1].str);
if (!rs[0][2].str.empty()) d.kKundengruppe = std::stoi(rs[0][2].str);
if (!rs[0][3].str.empty()) d.kPlattform = std::stoi(rs[0][3].str);
}
// Apply environment overrides
if (g_config.kFirmaHistory > 0) d.kFirmaHistory = g_config.kFirmaHistory;
if (g_config.kVersandArt > 0) d.kVersandArt = g_config.kVersandArt;
if (g_config.kKundengruppe > 0) d.kKundengruppe = g_config.kKundengruppe;
g_resolved_defaults = std::make_unique<Defaults>(d);
return *g_resolved_defaults;
}
inline double to_number(const nlohmann::json& value, double fallback = 0) {
if (value.is_null()) return fallback;
if (value.is_number()) return value.get<double>();
if (value.is_string()) {
std::string s = value.get<std::string>();
if (s.empty()) return fallback;
std::replace(s.begin(), s.end(), ',', '.');
try { return std::stod(s); } catch (...) {}
}
return fallback;
}
inline int to_int(const nlohmann::json& value, int fallback = 0) {
if (value.is_null()) return fallback;
if (value.is_number_integer()) return value.get<int>();
if (value.is_number_unsigned()) return static_cast<int>(value.get<unsigned>());
if (value.is_number_float()) return static_cast<int>(value.get<double>());
if (value.is_boolean()) return value.get<bool>() ? 1 : 0;
if (value.is_string()) {
std::string s = value.get<std::string>();
if (s.empty()) return fallback;
try { return std::stoi(s); } catch (...) {}
}
return fallback;
}
inline int json_int(const nlohmann::json& obj, const char* key, int fallback = 0) {
auto it = obj.find(key);
if (it == obj.end()) return fallback;
return to_int(*it, fallback);
}
inline double json_double(const nlohmann::json& obj, const char* key, double fallback = 0) {
auto it = obj.find(key);
if (it == obj.end()) return fallback;
return to_number(*it, fallback);
}
inline int steuerklasse_for_vat(double vat) {
if (vat >= 15) return 1;
if (vat > 0) return 2;
return 1;
}
inline int iso_week(const std::tm& tm) {
// JTL placeholders: J = Jahr, M = Monat, T = Tag, K = Kalenderwoche.
// ISO week date
std::tm t0 = tm;
int yday = t0.tm_yday;
int wday = t0.tm_wday;
if (wday == 0) wday = 7;
int week = (yday - wday + 10) / 7;
if (week < 1) week = 1;
if (week > 53) week = 53;
return week;
}
inline std::string format_number_placeholders(const std::string& tpl, const std::tm& tm) {
if (tpl.empty()) return "";
std::string r = tpl;
auto replace = [&r](const std::string& from, const std::string& to) {
size_t start = 0;
while ((start = r.find(from, start)) != std::string::npos) {
r.replace(start, from.size(), to);
start += to.size();
}
};
std::ostringstream mm, dd, kk;
mm << std::setw(2) << std::setfill('0') << (tm.tm_mon + 1);
dd << std::setw(2) << std::setfill('0') << tm.tm_mday;
kk << std::setw(2) << std::setfill('0') << iso_week(tm);
replace("<J>", std::to_string(tm.tm_year + 1900));
replace("<M>", mm.str());
replace("<T>", dd.str());
replace("<K>", kk.str());
return r;
}
inline std::tm parse_order_date(const std::string& s) {
std::tm tm{};
std::string iso = s;
std::replace(iso.begin(), iso.end(), ' ', 'T');
std::istringstream ss(iso);
ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
if (ss.fail()) {
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
localtime_r(&t, &tm);
}
return tm;
}
inline std::string order_date_sql(const std::tm& tm) {
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
return buf;
}
inline std::string country_name(const std::string& iso) {
static const std::map<std::string, std::string> names = {
{"DE", "Deutschland"},
{"AT", "Oesterreich"},
{"CH", "Schweiz"}
};
auto it = names.find(iso);
return it != names.end() ? it->second : iso;
}
inline std::string next_number_from_sequence(OdbcPool::Connection* c, int kLaufendeNummer,
const std::tm& date) {
const char* sql =
"DECLARE @n INT, @cPrefix NVARCHAR(50), @cSuffix NVARCHAR(50);"
"UPDATE dbo.tLaufendeNummern"
" SET @n = nNummer = nNummer + 1, @cPrefix = cPrefix, @cSuffix = cSuffix"
" WHERE kLaufendeNummer = ?;"
"SELECT @n AS nNummer, @cPrefix AS cPrefix, @cSuffix AS cSuffix";
std::vector<Param> ps = {{ParamType::Int, "", kLaufendeNummer}};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].size() < 3) {
throw std::runtime_error("dbo.tLaufendeNummern has no row " + std::to_string(kLaufendeNummer));
}
int n = std::stoi(rs[0][0].str);
std::string prefix = format_number_placeholders(rs[0][1].str, date);
std::string suffix = format_number_placeholders(rs[0][2].str, date);
return prefix + std::to_string(n) + suffix;
}
inline int allocate_pk(OdbcPool::Connection* c, const std::string& tableName) {
const char* sql =
"DECLARE @pk INT;"
"UPDATE dbo.tpk SET @pk = nummer, nummer = nummer + 1, dChanged = GETDATE() WHERE cName = ?;"
"SELECT @pk AS pk";
std::vector<Param> ps = {{ParamType::NVarChar, tableName, 0}};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) {
throw std::runtime_error("dbo.tpk has no row for table '" + tableName + "'");
}
return std::stoi(rs[0][0].str);
}
inline int parse_import_setting(const nlohmann::json& order) {
if (!order.contains("settings") || order["settings"].is_null()) return 0;
return json_int(order["settings"], "importSetting", 0);
}
inline int parse_invoice_setting(const nlohmann::json& order) {
if (!order.contains("settings") || order["settings"].is_null()) return 0;
return json_int(order["settings"], "invoiceSetting", 0);
}
inline int resolve_n_ist_readonly(const nlohmann::json& order) {
return parse_import_setting(order) == 0 ? NIST_READONLY_NICHT_AENDERBAR : 0;
}
inline int resolve_n_ist_externe_rechnung(const nlohmann::json& order) {
const int import_setting = parse_import_setting(order);
const int invoice_setting = parse_invoice_setting(order);
if (import_setting >= 2 && import_setting <= 5) return 0;
if (invoice_setting & 1) return 0;
if (import_setting == 0) return NIST_EXTERNE_RECHNUNG_KEINE;
return 0;
}
struct VersandArtRow {
int kVersandArt = 0;
std::string cName;
double fPrice = 0;
double fMwSt = 19;
};
inline bool is_versandposition(const nlohmann::json& item) {
return json_int(item, "type", 0) == VERSANDPOSITION_TYPE;
}
inline bool has_versandposition(const nlohmann::json& items) {
if (!items.is_array()) return false;
for (const auto& item : items) {
if (is_versandposition(item)) return true;
}
return false;
}
inline bool has_non_return_sale_items(const nlohmann::json& items) {
if (!items.is_array()) return false;
for (const auto& item : items) {
if (json_double(item, "isReturn", 0) == 0) return true;
}
return false;
}
inline bool should_inject_selbstabholer_shipping(const nlohmann::json& items) {
if (has_versandposition(items)) return false;
return has_non_return_sale_items(items);
}
inline std::optional<VersandArtRow> lookup_versand_art(OdbcPool::Connection* c,
const std::string& shipping_name) {
auto try_lookup = [&](const std::string& cName) -> std::optional<VersandArtRow> {
std::vector<Param> ps = {{ParamType::NVarChar, cName, 0}};
ResultSet rs;
if (!get_pool().execute(c,
"SELECT TOP 1 kVersandArt, cName, fPrice, fMwSt "
"FROM dbo.tVersandArt WHERE cName = ?", ps, rs)
|| rs.empty() || rs[0].empty()) {
return std::nullopt;
}
VersandArtRow row;
row.kVersandArt = std::stoi(rs[0][0].str);
row.cName = rs[0][1].str;
row.fPrice = rs[0].size() > 2 ? to_number(nlohmann::json(rs[0][2].str), 0) : 0;
row.fMwSt = rs[0].size() > 3 ? to_number(nlohmann::json(rs[0][3].str), 19) : 19;
return row;
};
std::string name = shipping_name;
if (name.empty()) name = "Selbstabholer";
if (auto row = try_lookup(name)) return row;
if (name != "Selbstabholer") return try_lookup("Selbstabholer");
return std::nullopt;
}
inline nlohmann::json synthetic_shipping_item(const VersandArtRow& versand_art) {
const double vat = versand_art.fMwSt;
const double gross = versand_art.fPrice;
const double net = gross / (1 + vat / 100);
return {
{"type", std::to_string(VERSANDPOSITION_TYPE)},
{"quantity", "1"},
{"name", versand_art.cName},
{"priceGross", std::to_string(gross)},
{"priceNet", std::to_string(net)},
{"vat", std::to_string(vat)},
{"isReturn", "0"}
};
}
inline nlohmann::json resolve_zahlungsart(OdbcPool::Connection* c, const std::string& name) {
const std::string lookup_name = name.empty() ? "Bar" : name;
const std::string cache_key = [&]() {
std::string key = lookup_name;
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
return key;
}();
auto it = g_zahlungsart_cache.find(cache_key);
if (it != g_zahlungsart_cache.end()) return it->second;
auto fetch_row = [&](const char* sql) -> std::optional<std::pair<int, std::string>> {
std::vector<Param> ps = {{ParamType::NVarChar, lookup_name, 0}};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) {
return std::nullopt;
}
return std::make_pair(std::stoi(rs[0][0].str), rs[0][1].str);
};
std::optional<std::pair<int, std::string>> row =
fetch_row("SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE cName = ?");
if (!row) {
row = fetch_row(
"SELECT TOP 1 z.kZahlungsart, z.cName "
"FROM dbo.tZahlungsArtSprache zs "
"INNER JOIN dbo.tZahlungsart z ON z.kZahlungsart = zs.kZahlungsart "
"WHERE zs.cName = ?");
}
if (!row) {
row = fetch_row(
"SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE UPPER(cName) = UPPER(?)");
}
nlohmann::json zahlungsart;
if (row) {
zahlungsart = {{"kZahlungsart", row->first}, {"cName", row->second}};
} else {
int kZahlungsart = allocate_pk(c, "tZahlungsart");
const char* insert =
"INSERT INTO dbo.tZahlungsart"
" (kZahlungsart, cName, cPrtString, nLastschrift, cPrtStringVor, cPaymentOption, cKonto,"
" nAusliefernVorZahlung, nPrioritaet, nMahnwesenAktiv, fSkontoWert, nSkontoZeitraum,"
" nMatchingOptionen, nIstStandard, nAktiv)"
" VALUES (?, ?, '', 0, '', '', '', 0, 0, 0, 0, 0, 0, 0, 1)";
std::vector<Param> ps2 = {
{ParamType::Int, "", kZahlungsart},
{ParamType::NVarChar, lookup_name, 0}
};
ResultSet rs;
get_pool().execute(c, insert, ps2, rs);
zahlungsart = {{"kZahlungsart", kZahlungsart}, {"cName", lookup_name}};
}
g_zahlungsart_cache[cache_key] = zahlungsart;
return zahlungsart;
}
inline std::string next_customer_number(OdbcPool::Connection* c) {
std::tm date;
{
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
localtime_r(&t, &date);
}
return next_number_from_sequence(c, g_config.customerNumberSequence, date);
}
inline std::pair<int,int> lookup_kassenkunde(OdbcPool::Connection* c, const Defaults& defaults) {
ResultSet rs;
get_pool().execute(c,
"SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKassenKunde = 'Y' ORDER BY kKunde", rs);
if (!rs.empty() && !rs[0].empty()) {
int kKunde = std::stoi(rs[0][0].str);
int grp = defaults.kKundengruppe;
if (rs[0].size() > 1 && !rs[0][1].str.empty()) grp = std::stoi(rs[0][1].str);
return {kKunde, grp};
}
return {0, defaults.kKundengruppe};
}
inline bool is_walk_in_order(const nlohmann::json& order) {
std::string customer_number = order.value("customerNumber", "");
if (customer_number.empty() || customer_number == "0") return true;
const auto billing = order.value("billingAddress", nlohmann::json::object());
return billing.value("lastName", "") == "Laufkunde"
&& billing.value("firstName", "").empty();
}
inline std::string resolve_auftrag_c_kunden_nr(const nlohmann::json& order) {
if (is_walk_in_order(order)) return "0";
return order.value("customerNumber", "");
}
inline std::pair<int,int> create_customer(OdbcPool::Connection* c, const std::string& customer_number,
const nlohmann::json& address, const Defaults& defaults) {
const nlohmann::json a = address.is_null() ? nlohmann::json::object() : address;
std::string iso = a.value("countryIso", "DE");
std::transform(iso.begin(), iso.end(), iso.begin(), [](unsigned char ch) { return std::toupper(ch); });
int kKundengruppe = json_int(a, "customerGroupId", 0);
if (kKundengruppe <= 0) kKundengruppe = defaults.kKundengruppe;
const char* sql =
"DECLARE @returnValue INT;"
"DECLARE @kunde_daten dbo.TYPE_spkundeInsert;"
"INSERT INTO @kunde_daten"
" (kInetKunde, kKundenKategorie, cKundenNr, cFirma, cAnrede, cTitel, cVorname, cName,"
" cStrasse, cPLZ, cOrt, cLand, cTel, cFax, cEMail, dErstellt, cMobil, fRabatt, cUSTID, cNewsletter,"
" cZusatz, cEbayName, kBuyer, cAdressZusatz, cGeburtstag, cWWW, cSperre, cPostID, kKundenGruppe,"
" nZahlungsziel, kSprache, cISO, cBundesland, cHerkunft, cKassenKunde, cHRNr, kZahlungsart,"
" nDebitorennr, cSteuerNr, nKreditlimit, kKundenDrucktext, nMahnstopp, nMahnrhythmus, kFirma,"
" fProvision, nVertreter, fSkonto, nSkontoInTagen)"
" VALUES"
" (0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, GETDATE(), ?, ?, NULL, 'N',"
" N'', N'', 0, ?, ?, N'', 'N', NULL, ?,"
" 0, ?, ?, ?, ?, ?, N'', 0,"
" ?, N'', 0, 0, 0, 0, 0,"
" NULL, 0, 0, 0);"
"EXEC @returnValue = Kunde.spKundeInsert @daten = @kunde_daten;"
"SELECT @returnValue AS kKunde";
std::vector<Param> ps = {
{ParamType::NVarChar, customer_number, 0},
{ParamType::NVarChar, a.value("company", ""), 0},
{ParamType::NVarChar, a.value("salutation", ""), 0},
{ParamType::NVarChar, a.value("title", ""), 0},
{ParamType::NVarChar, a.value("firstName", ""), 0},
{ParamType::NVarChar, a.value("lastName", "Laufkunde"), 0},
{ParamType::NVarChar, a.value("street", "-"), 0},
{ParamType::NVarChar, a.value("zipCode", ""), 0},
{ParamType::NVarChar, a.value("city", "-"), 0},
{ParamType::NVarChar, country_name(iso), 0},
{ParamType::NVarChar, a.value("phone", ""), 0},
{ParamType::NVarChar, a.value("fax", ""), 0},
{ParamType::NVarChar, a.value("email", ""), 0},
{ParamType::NVarChar, a.value("mobile", ""), 0},
{ParamType::Double, "", 0, json_double(a, "discount", 0)},
{ParamType::NVarChar, a.value("addressAddition", ""), 0},
{ParamType::NVarChar, a.value("birthday", ""), 0},
{ParamType::Int, "", kKundengruppe},
{ParamType::Int, "", g_config.kSprache},
{ParamType::NVarChar, iso, 0},
{ParamType::NVarChar, a.value("state", ""), 0},
{ParamType::NVarChar, std::string("Kasse"), 0},
{ParamType::NVarChar, std::string("Y"), 0},
{ParamType::Int, "", json_int(a, "debtorNumber", 0)}
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) {
throw std::runtime_error("Kunde.spKundeInsert failed for '" + customer_number + "'");
}
int kKunde = std::stoi(rs[0][0].str);
if (kKunde <= 0) throw std::runtime_error("Kunde.spKundeInsert returned invalid kKunde");
return {kKunde, kKundengruppe};
}
inline std::pair<int,int> resolve_customer(OdbcPool::Connection* c, const nlohmann::json& order,
const Defaults& defaults) {
if (is_walk_in_order(order)) {
auto [kKunde, grp] = lookup_kassenkunde(c, defaults);
if (kKunde > 0) return {kKunde, grp};
return create_customer(c, next_customer_number(c),
order.value("billingAddress", nlohmann::json::object()), defaults);
}
std::string customer_number = order.value("customerNumber", "");
std::vector<Param> ps = {{ParamType::NVarChar, customer_number, 0}};
ResultSet rs;
get_pool().execute(c, "SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKundenNr = ?", ps, rs);
if (!rs.empty() && !rs[0].empty()) {
int kKunde = std::stoi(rs[0][0].str);
int grp = defaults.kKundengruppe;
if (rs[0].size() > 1 && !rs[0][1].str.empty()) grp = std::stoi(rs[0][1].str);
return {kKunde, grp};
}
return create_customer(c, customer_number, order.value("billingAddress", nlohmann::json::object()), defaults);
}
inline std::string next_order_number(OdbcPool::Connection* c, const std::tm& date) {
return next_number_from_sequence(c, g_config.orderNumberSequence, date);
}
inline void insert_order_address(OdbcPool::Connection* c, int kAuftrag, int kKunde,
const nlohmann::json& address, int nTyp) {
const nlohmann::json a = address.is_null() ? nlohmann::json::object() : address;
std::string iso = a.value("countryIso", "DE");
std::transform(iso.begin(), iso.end(), iso.begin(), [](unsigned char ch) { return std::toupper(ch); });
const char* sql =
"INSERT INTO Verkauf.tAuftragAdresse"
" (kAuftrag, kKunde, cFirma, cAnrede, cTitel, cVorname, cName, cStrasse, cPLZ, cOrt, cLand,"
" cTel, cZusatz, cAdressZusatz, cMobil, cMail, cFax, cBundesland, cISO, nTyp, nZolldokumenteErforderlich)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)";
std::vector<Param> ps = {
{ParamType::Int, "", kAuftrag},
{ParamType::Int, "", kKunde},
{ParamType::NVarChar, a.value("company", ""), 0},
{ParamType::NVarChar, a.value("salutation", ""), 0},
{ParamType::NVarChar, a.value("title", ""), 0},
{ParamType::NVarChar, a.value("firstName", ""), 0},
{ParamType::NVarChar, a.value("lastName", "-"), 0},
{ParamType::NVarChar, a.value("street", "-"), 0},
{ParamType::NVarChar, a.value("zipCode", ""), 0},
{ParamType::NVarChar, a.value("city", "-"), 0},
{ParamType::NVarChar, country_name(iso), 0},
{ParamType::NVarChar, a.value("phone", ""), 0},
{ParamType::NVarChar, a.value("extraAddressLine", ""), 0},
{ParamType::NVarChar, a.value("addressAddition", ""), 0},
{ParamType::NVarChar, a.value("mobile", ""), 0},
{ParamType::NVarChar, a.value("email", ""), 0},
{ParamType::NVarChar, a.value("fax", ""), 0},
{ParamType::NVarChar, a.value("state", ""), 0},
{ParamType::NVarChar, iso, 0},
{ParamType::Int, "", nTyp}
};
ResultSet rs;
get_pool().execute(c, sql, ps, rs);
}
inline int insert_order_item(OdbcPool::Connection* c, int kAuftrag, const nlohmann::json& item) {
double vat = json_double(item, "vat", 19);
double quantity = json_double(item, "quantity", 1);
double price_gross = json_double(item, "priceGross", 0);
double price_net = json_double(item, "priceNet", price_gross / (1 + vat / 100));
double discount = json_double(item, "discountPercent", 0);
int kSteuerklasse = steuerklasse_for_vat(vat);
std::string sku = item.value("sku", "");
const int position_type = is_versandposition(item) ? VERSANDPOSITION_TYPE : json_int(item, "type", 0);
int kArtikel = 0;
bool has_artikel = false;
if (!sku.empty() && position_type != VERSANDPOSITION_TYPE) {
std::vector<Param> ps = {{ParamType::NVarChar, sku, 0}};
ResultSet rs;
get_pool().execute(c, "SELECT TOP 1 kArtikel FROM dbo.tArtikel WHERE cArtNr = ?", ps, rs);
if (!rs.empty() && !rs[0].empty()) {
kArtikel = std::stoi(rs[0][0].str);
has_artikel = true;
}
}
const int nType = position_type == VERSANDPOSITION_TYPE ? VERSANDPOSITION_TYPE : (has_artikel ? 1 : 0);
const int nReserviert = nType == VERSANDPOSITION_TYPE ? 0 : 1;
const char* sql =
"DECLARE @t TABLE ([kAuftragPosition] INT);"
"INSERT INTO Verkauf.tAuftragPosition"
" (kArtikel, kAuftrag, cArtNr, nReserviert, cName, cHinweis, fAnzahl, fVkNetto, fMwSt,"
" cNameStandard, kSteuerklasse, nType, cEinheit, fFaktor, kSteuerschluessel, fRabatt)"
" OUTPUT inserted.kAuftragPosition INTO @t"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,"
" ?, ?, ?, ?, 1.0, 3, ?);"
"SELECT kAuftragPosition FROM @t";
Param cartnr{ParamType::NVarChar, has_artikel ? sku : std::string(), 0, 0.0, !has_artikel};
Param pk_art{ParamType::Int, "", kArtikel, 0.0, !has_artikel};
std::string name = item.value("name", sku.empty() ? std::string("Position") : sku);
std::vector<Param> ps = {
pk_art,
{ParamType::Int, "", kAuftrag},
cartnr,
{ParamType::Int, "", nReserviert},
{ParamType::NVarChar, name, 0},
{ParamType::NVarChar, item.value("note", ""), 0},
{ParamType::Double, "", 0, quantity},
{ParamType::Double, "", 0, price_net},
{ParamType::Double, "", 0, vat},
{ParamType::NVarChar, name, 0},
{ParamType::Int, "", kSteuerklasse},
{ParamType::Int, "", nType},
{ParamType::NVarChar, item.value("unit", ""), 0},
{ParamType::Double, "", 0, discount}
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) return 0;
return std::stoi(rs[0][0].str);
}
inline int parse_pos_auftrag_id(const std::string& external_id) {
if (external_id.empty()) return 0;
try { return std::stoi(external_id); } catch (...) { return 0; }
}
inline bool external_order_numbers_match(const std::string& mapped, const std::string& incoming) {
if (incoming.empty()) return true;
if (mapped.size() != incoming.size()) return false;
for (size_t i = 0; i < mapped.size(); ++i) {
if (std::tolower(static_cast<unsigned char>(mapped[i]))
!= std::tolower(static_cast<unsigned char>(incoming[i]))) {
return false;
}
}
return true;
}
// PosOrderCreationService.CheckIfOrderExists — already-imported POS order.
inline std::optional<std::pair<int, std::string>> find_existing_pos_order(
OdbcPool::Connection* c, int kPosAuftrag, const std::string& external_order_number) {
int kShopSubShop = get_active_shop_subshop_id();
if (kPosAuftrag <= 0 || kShopSubShop <= 0) return std::nullopt;
const char* sql =
"SELECT TOP 1 m.kAuftrag, a.cAuftragsNr, ISNULL(a.cExterneAuftragsnummer, '') AS cExterneAuftragsnummer "
"FROM Pos.tAuftragMapping m "
"LEFT JOIN Verkauf.tAuftrag a ON a.kAuftrag = m.kAuftrag "
"WHERE m.kPosAuftrag = ? AND m.kShopSubShop = ? AND m.kAuftrag IS NOT NULL "
"ORDER BY m.kAuftrag DESC";
ResultSet rs;
std::vector<Param> ps = {
{ParamType::Int, "", kPosAuftrag},
{ParamType::Int, "", kShopSubShop},
};
if (!get_pool().execute(c, sql, ps, rs)
|| rs.empty() || rs[0].empty() || rs[0][0].type == CellType::Null) {
return std::nullopt;
}
int kAuftrag = std::stoi(rs[0][0].str);
if (kAuftrag <= 0) return std::nullopt;
std::string order_number = rs[0].size() > 1 ? rs[0][1].str : "";
std::string mapped_external = rs[0].size() > 2 ? rs[0][2].str : "";
if (!external_order_numbers_match(mapped_external, external_order_number)) {
return std::nullopt;
}
return std::make_pair(kAuftrag, order_number);
}
inline void upsert_pos_order_mapping(OdbcPool::Connection* c, int kAuftrag, int kPosAuftrag) {
int kShopSubShop = get_active_shop_subshop_id();
if (kPosAuftrag <= 0 || kShopSubShop <= 0) return;
ResultSet rs;
std::vector<Param> del_ps = {
{ParamType::Int, "", kPosAuftrag},
{ParamType::Int, "", kShopSubShop},
};
get_pool().execute(c,
"DELETE FROM Pos.tAuftragMapping WHERE kPosAuftrag = ? AND kShopSubShop = ?",
del_ps, rs);
std::vector<Param> ins_ps = {
{ParamType::Int, "", kAuftrag},
{ParamType::Int, "", kPosAuftrag},
{ParamType::Int, "", kShopSubShop},
};
get_pool().execute(c,
"INSERT INTO Pos.tAuftragMapping (kAuftrag, kPosAuftrag, kShopSubShop) VALUES (?, ?, ?)",
ins_ps, rs);
}
inline void insert_pos_order_position_mapping(OdbcPool::Connection* c, int kAuftragPosition,
const std::string& external_id) {
int kPosAuftragPosition = 0;
try { kPosAuftragPosition = std::stoi(external_id); } catch (...) { return; }
int kShopSubShop = get_active_shop_subshop_id();
if (kAuftragPosition <= 0 || kPosAuftragPosition <= 0 || kShopSubShop <= 0) return;
const char* sql = "INSERT INTO Pos.tAuftragPositionMapping (kAuftragPosition, kPosAuftragPosition, kShopSubShop) VALUES (?, ?, ?)";
std::vector<Param> ps = {
{ParamType::Int, "", kAuftragPosition},
{ParamType::Int, "", kPosAuftragPosition},
{ParamType::Int, "", kShopSubShop}
};
ResultSet rs;
get_pool().execute(c, sql, ps, rs);
}
inline bool is_order_delivered(const nlohmann::json& order) {
auto it = order.find("settings");
if (it != order.end() && !it->is_null()) {
auto del = it->find("deliver");
if (del != it->end()) {
if (del->is_boolean()) return del->get<bool>();
if (del->is_number()) return del->get<int>() != 0;
if (del->is_string()) {
std::string s = *del;
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char ch) { return std::tolower(ch); });
return s == "true" || s == "1";
}
return false;
}
}
return true;
}
// ODBC maps ? placeholders to @p1, @p2, … — never DECLARE @p1 in the same batch.
inline void recalculate_auftrag_eckdaten(OdbcPool::Connection* c, int kAuftrag) {
const char* sql =
"DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;"
"INSERT INTO @eckdaten_calc VALUES (?);"
"EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc";
std::vector<Param> ps = {{ParamType::Int, "", kAuftrag}};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs)) {
throw std::runtime_error("spAuftragEckdatenBerechnen failed for kAuftrag="
+ std::to_string(kAuftrag));
}
}
inline std::optional<double> get_offener_auftragswert(OdbcPool::Connection* c, int kAuftrag) {
const char* sql =
"SELECT ROUND(tAuftragEckdaten.fOffenerWertOhneStorno, 2) AS fOffenerAuftragswert "
"FROM Verkauf.tAuftrag "
"LEFT JOIN Verkauf.tAuftragEckdaten ON tAuftragEckdaten.kAuftrag = tAuftrag.kAuftrag "
"WHERE tAuftrag.kAuftrag = ?";
std::vector<Param> ps = {{ParamType::Int, "", kAuftrag}};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()
|| rs[0][0].type == CellType::Null) {
return std::nullopt;
}
return to_number(nlohmann::json(rs[0][0].str), 0);
}
inline bool is_new_payment(const nlohmann::json& payment) {
return json_int(payment, "paymentId", 0) <= 0;
}
inline void insert_payment(OdbcPool::Connection* c, int kAuftrag, const nlohmann::json& payment,
const nlohmann::json& order, const std::tm& order_date_tm) {
std::string payment_name = payment.value("paymentMethodName", "");
if (payment_name.empty()) payment_name = order.value("paymentMethodName", "Bar");
nlohmann::json zahlungsart = resolve_zahlungsart(c, payment_name);
int kZahlung = allocate_pk(c, "tZahlung");
recalculate_auftrag_eckdaten(c, kAuftrag);
auto fOffenerWert = get_offener_auftragswert(c, kAuftrag);
if (!fOffenerWert) {
throw std::runtime_error("no open order amount for kAuftrag=" + std::to_string(kAuftrag));
}
const char* sql =
"IF EXISTS ("
" SELECT 1 FROM Verkauf.tAuftragEckdaten"
" WHERE kAuftrag = ?"
" AND ROUND(fOffenerWertOhneStorno, 2) = ROUND(?, 2)"
")"
"BEGIN"
" INSERT INTO dbo.tZahlung"
" (kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart,"
" nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung)"
" VALUES (?, ?, ?, ?, ?, ?, 0, '', ?,"
" 0, ?, 0, ?, '', 0);"
"END;"
"SELECT @@ROWCOUNT AS inserted";
std::vector<Param> ps = {
{ParamType::Int, "", kAuftrag},
{ParamType::Double, "", 0, *fOffenerWert},
{ParamType::Int, "", kZahlung},
{ParamType::NVarChar, zahlungsart.value("cName", ""), 0},
{ParamType::NVarChar, order_date_sql(order_date_tm), 0},
{ParamType::Double, "", 0, json_double(payment, "amount", 0)},
{ParamType::Int, "", kAuftrag},
{ParamType::Int, "", g_config.kBenutzer},
{ParamType::Int, "", json_int(zahlungsart, "kZahlungsart", 0)},
{ParamType::NVarChar, order.value("externalOrderNumber", ""), 0},
{ParamType::Int, "", ZAHLUNG_TYPE_ZAHLUNG},
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()
|| std::stoi(rs[0][0].str) == 0) {
throw std::runtime_error("payment insert skipped: open amount changed for kAuftrag="
+ std::to_string(kAuftrag));
}
}
inline nlohmann::json create_order(const nlohmann::json& order) {
load_config();
auto guard = get_pool().checkout();
if (!guard) throw std::runtime_error("no ODBC connection available");
auto* c = guard.get();
int kPosAuftrag = parse_pos_auftrag_id(order.value("externalId", ""));
std::string external_order_number = order.value("externalOrderNumber", "");
if (kPosAuftrag > 0) {
if (auto existing = find_existing_pos_order(c, kPosAuftrag, external_order_number)) {
return {
{"orderId", std::to_string(existing->first)},
{"orderNumber", existing->second},
{"alreadyExists", true}
};
}
}
const Defaults& defaults = get_defaults(c);
if (!get_pool().begin(c)) throw std::runtime_error("failed to begin transaction");
try {
g_zahlungsart_cache.clear();
std::string creation_date = order.value("creationDate", "");
std::tm order_date_tm = creation_date.empty()
? []() {
std::tm tm{};
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
localtime_r(&t, &tm);
return tm;
}()
: parse_order_date(creation_date);
auto [kKunde, kKundengruppe] = resolve_customer(c, order, defaults);
nlohmann::json zahlungsart = resolve_zahlungsart(c, order.value("paymentMethodName", "Bar"));
std::string cAuftragsNr = next_order_number(c, order_date_tm);
nlohmann::json order_items = order.contains("orderItems") ? order["orderItems"] : nlohmann::json::array();
if (!order_items.is_array()) order_items = nlohmann::json::array();
auto versand_art = lookup_versand_art(c, order.value("shippingName", ""));
int kVersandArt = versand_art ? versand_art->kVersandArt : defaults.kVersandArt;
if (should_inject_selbstabholer_shipping(order_items) && versand_art) {
order_items.push_back(synthetic_shipping_item(*versand_art));
}
int active_shop = get_active_shop_id();
const int nIstReadOnly = resolve_n_ist_readonly(order);
const int nIstExterneRechnung = resolve_n_ist_externe_rechnung(order);
const char* insert_auftrag =
"DECLARE @t TABLE ([kAuftrag] INT);"
"INSERT INTO Verkauf.tAuftrag"
" (cAuftragsNr, dErstellt, nKomplettAusgeliefert, kBenutzer, kKunde, kBenutzerErstellt, nType, fFaktor,"
" kFirmaHistory, kSprache, cVersandlandWaehrung, fVersandlandWaehrungFaktor, fFinanzierungskosten,"
" cWaehrung, kPlattform, kShop, cKundenNr, cVersandlandISO, kVersandArt, kZahlungsart, kKundengruppe,"
" cExterneAuftragsnummer, nIstExterneRechnung, cInet, nIstReadOnly, kShopauftrag, nLieferPrioritaet)"
" OUTPUT inserted.kAuftrag INTO @t"
" VALUES (?, ?, 0, ?, ?, ?, 1, 1.0,"
" ?, ?, ?, 1.0, 0.0,"
" ?, ?, ?, ?, ?, ?, ?, ?,"
" ?, ?, ?, ?, 0, 10);"
"SELECT kAuftrag FROM @t";
std::string shipping_iso = order.value("shippingAddress", nlohmann::json::object()).value("countryIso", "DE");
std::transform(shipping_iso.begin(), shipping_iso.end(), shipping_iso.begin(),
[](unsigned char ch) { return std::toupper(ch); });
std::string currency_iso = order.value("currencyIso", "EUR");
std::string cKundenNr = resolve_auftrag_c_kunden_nr(order);
std::vector<Param> ps = {
{ParamType::NVarChar, cAuftragsNr, 0},
{ParamType::NVarChar, order_date_sql(order_date_tm), 0},
{ParamType::Int, "", g_config.kBenutzer},
{ParamType::Int, "", kKunde},
{ParamType::Int, "", g_config.kBenutzer},
{ParamType::Int, "", defaults.kFirmaHistory},
{ParamType::Int, "", g_config.kSprache},
{ParamType::NVarChar, currency_iso, 0},
{ParamType::NVarChar, currency_iso, 0},
{ParamType::Int, "", defaults.kPlattform},
{ParamType::Int, "", active_shop == 0 ? -1 : active_shop},
{ParamType::NVarChar, cKundenNr, 0},
{ParamType::NVarChar, shipping_iso, 0},
{ParamType::Int, "", kVersandArt},
{ParamType::Int, "", json_int(zahlungsart, "kZahlungsart", 0)},
{ParamType::Int, "", kKundengruppe},
{ParamType::NVarChar, order.value("externalOrderNumber", ""), 0},
{ParamType::Int, "", nIstExterneRechnung},
{ParamType::NVarChar, "Y", 0},
{ParamType::Int, "", nIstReadOnly},
};
if (active_shop == 0) {
ps[11] = Param::null_int();
}
ResultSet rs;
if (!get_pool().execute(c, insert_auftrag, ps, rs) || rs.empty() || rs[0].empty()) {
throw std::runtime_error("failed to insert Verkauf.tAuftrag");
}
int kAuftrag = std::stoi(rs[0][0].str);
upsert_pos_order_mapping(c, kAuftrag, kPosAuftrag);
insert_order_address(c, kAuftrag, kKunde, order.value("shippingAddress", nlohmann::json::object()), 0);
insert_order_address(c, kAuftrag, kKunde, order.value("billingAddress", nlohmann::json::object()), 1);
std::vector<delivery::DeliveredItem> delivered_items;
for (const auto& item : order_items) {
int kAuftragPosition = insert_order_item(c, kAuftrag, item);
const std::string external_id = item.value("externalId", "");
if (!external_id.empty()) {
insert_pos_order_position_mapping(c, kAuftragPosition, external_id);
}
if (kAuftragPosition > 0 && !is_versandposition(item)) {
delivered_items.push_back({kAuftragPosition, json_double(item, "quantity", 1)});
}
}
const nlohmann::json& payments = order.contains("payments") ? order["payments"] : nlohmann::json::array();
for (const auto& payment : payments) {
if (!is_new_payment(payment)) continue;
insert_payment(c, kAuftrag, payment, order, order_date_tm);
}
if (is_order_delivered(order)) {
delivery::deliver_order(c, g_config.kBenutzer, kAuftrag, kVersandArt, delivered_items);
}
const char* calc =
"DECLARE @auftrag_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;"
"INSERT INTO @auftrag_calc VALUES (?);"
"EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @auftrag_calc";
std::vector<Param> ps_calc = {{ParamType::Int, "", kAuftrag}};
get_pool().execute(c, calc, ps_calc, rs);
if (!get_pool().commit(c)) throw std::runtime_error("failed to commit transaction");
return {
{"orderId", std::to_string(kAuftrag)},
{"orderNumber", cAuftragsNr},
{"alreadyExists", false}
};
} catch (const std::exception&) {
try { get_pool().rollback(c); } catch (...) {}
throw;
}
}
} // namespace order

View File

@@ -1,5 +1,7 @@
#pragma once
#include "../db/pool.hpp"
#include "../log.hpp"
#include "../http.hpp"
#include "nlohmann/json.hpp"
static const char* CUSTOMER_GROUP_IDS_SQL =
@@ -16,13 +18,20 @@ static const char* CUSTOMER_GROUP_COUNT_SQL =
"WHERE CONVERT(BIGINT, bRowversion) > ?";
inline std::vector<int64_t> get_customer_group_ids() {
static std::vector<int64_t> cached;
static bool loaded = false;
if (loaded) return cached;
ResultSet rs;
get_pool().execute(CUSTOMER_GROUP_IDS_SQL, rs);
std::vector<int64_t> ids;
for (auto& row : rs) {
ids.push_back(std::stoll(row[0].str));
if (!get_pool().execute(CUSTOMER_GROUP_IDS_SQL, rs)) {
logc::warn("failed to load customer group ids");
return {};
}
return ids;
for (auto& row : rs) {
cached.push_back(parse_int64(row[0].str, 0));
}
loaded = true;
return cached;
}
inline nlohmann::json get_customer_group_list(int64_t cursor = 0) {
@@ -35,7 +44,7 @@ inline nlohmann::json get_customer_group_list(int64_t cursor = 0) {
{"customerGroupId", row[0].str},
{"name", row[1].str},
{"standard", row[2].str},
{"discountPercent", std::to_string(std::stod(row[3].str))},
{"discountPercent", std::to_string(parse_double(row[3].str, 0))},
{"lastChanged", row[4].str}
});
}

View File

@@ -0,0 +1,42 @@
#pragma once
#include <string>
#include "xml.hpp"
#include "../db/pool.hpp"
namespace delivery {
inline void deliver_picklists(OdbcPool::Connection* c, int kBenutzer, int kSessionId,
int kAuftrag, int kVersandArt) {
std::string pakete = xml::element("Paket",
xml::tag("kBestellung", int64_t(kAuftrag)) +
xml::tag("kVersandart", int64_t(kVersandArt)) +
xml::tag("fGewicht", 0.0));
const int AUSLIEFERN_OPTIONS = 0x002;
const char* sql =
"DECLARE @xHinweise XML = NULL;"
"DECLARE @xPakete XML = CONVERT(XML, ?);"
"DECLARE @xResult XML;"
"EXEC Auslieferung.spPicklistenAusliefern"
" @xHinweise = @xHinweise,"
" @Pakete = @xPakete,"
" @nOptions = ?,"
" @kBenutzer = ?,"
" @kSessionId = ?,"
" @xResult = @xResult OUTPUT;"
"SELECT @xResult AS xResult";
std::vector<Param> ps = {
{ParamType::NVarChar, pakete, 0},
{ParamType::Int, "", AUSLIEFERN_OPTIONS},
{ParamType::Int, "", kBenutzer},
{ParamType::Int, "", kSessionId}
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs)) {
throw std::runtime_error("deliver_picklists failed");
}
}
} // namespace delivery

View File

@@ -0,0 +1,37 @@
#pragma once
#include "xml.hpp"
#include "session.hpp"
#include "warehouse.hpp"
#include "reserve.hpp"
#include "stock_shortage.hpp"
#include "commit.hpp"
#include "deliver.hpp"
#include "../db/pool.hpp"
namespace delivery {
inline void deliver_order(OdbcPool::Connection* c, int kBenutzer, int kAuftrag,
int kVersandArt, const std::vector<DeliveredItem>& items) {
if (items.empty()) return;
int kWarenLager = resolve_outgoing_warehouse(c);
int kWarenLagerPlatz = resolve_warehouse_place(c, kWarenLager);
int kSessionId = open_session(c, kBenutzer);
try {
reserve_positions(c, kBenutzer, kSessionId, kWarenLager, items);
book_stock_shortfalls_and_rereserve(c, kBenutzer, kSessionId, kWarenLager,
kWarenLagerPlatz, items);
commit_picklists(c, kBenutzer, kSessionId, kAuftrag);
deliver_picklists(c, kBenutzer, kSessionId, kAuftrag, kVersandArt);
} catch (...) {
try { discard_session(c, kBenutzer, kSessionId); } catch (...) {}
try { close_session(c, kSessionId); } catch (...) {}
throw;
}
try { discard_session(c, kBenutzer, kSessionId); } catch (...) {}
try { close_session(c, kSessionId); } catch (...) {}
}
} // namespace delivery

View File

@@ -1,6 +1,7 @@
#pragma once
#include "../db/pool.hpp"
#include "../log.hpp"
#include "../http.hpp"
#include <string>
#include <vector>
#include <algorithm>
@@ -83,10 +84,10 @@ inline ImageResult get_image_by_hash(const std::string& hash, const std::string&
auto& row = rs[0];
int target = size.empty() ? 200 : std::stoi(size);
int target = parse_int(size, 200);
std::string ct = content_type_for(row[6].str);
int preview_w = row[4].str.empty() ? 0 : std::stoi(row[4].str);
int preview_h = row[5].str.empty() ? 0 : std::stoi(row[5].str);
int preview_w = row[4].str.empty() ? 0 : parse_int(row[4].str, 0);
int preview_h = row[5].str.empty() ? 0 : parse_int(row[5].str, 0);
int preview_max = std::max(preview_w, preview_h);
bool has_full = !row[0].blob.empty();

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

@@ -1,8 +1,10 @@
#pragma once
#include "../log.hpp"
#include "../db/pool.hpp"
#include "nlohmann/json.hpp"
#include "shop.hpp"
#include "customer_groups.hpp"
#include "product_attributes.hpp"
#include "../config.hpp"
#include "../http.hpp"
#include <cmath>
@@ -28,7 +30,8 @@ static const char* PRODUCT_LIST_SQL =
"a.nIstVater AS isParent, a.kVaterArtikel AS parentArticleId, "
"CASE WHEN a.kStueckliste <> 0 THEN '1' ELSE '0' END AS isCompositeProduct, "
"(SELECT TOP 1 pv.cVariantName FROM Pos.vProductVariant pv "
"WHERE pv.kProduct = a.kArtikel) AS variantName "
"WHERE pv.kProduct = a.kArtikel) AS variantName, "
"a.cBarcode AS barcode "
"FROM dbo.tArtikel a "
"INNER JOIN dbo.tArtikelBeschreibung ab ON ab.kArtikel = a.kArtikel AND ab.kSprache = ? "
"LEFT JOIN TaxRates tr ON tr.kSteuerklasse = a.kSteuerklasse "
@@ -41,16 +44,9 @@ static const char* PRODUCT_LIST_SQL =
"OR (ir.maxImageRV IS NOT NULL AND ir.maxImageRV > ?)) "
"ORDER BY lastChanged ASC";
static const char* PRICE_OVERRIDES_SQL =
"SELECT p.kArtikel AS articleId, p.kKundenGruppe AS customerGroupId, "
"MIN(pd.fNettoPreis) AS netPrice FROM dbo.tPreis p "
"INNER JOIN dbo.tPreisDetail pd ON pd.kPreis = p.kPreis "
"WHERE p.kArtikel IN (%s) AND p.kShop = 0 AND pd.nAnzahlAb = 0 "
"GROUP BY p.kArtikel, p.kKundenGruppe";
static std::string gross_price(const std::string& net, const std::string& tax) {
double n = std::stod(net.empty() ? "0" : net);
double t = std::stod(tax.empty() ? "0" : tax);
double n = parse_double(net, 0);
double t = parse_double(tax, 0);
char buf[32];
std::snprintf(buf, sizeof(buf), "%.2f", n * (1.0 + t / 100.0));
return buf;
@@ -72,9 +68,21 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
{ParamType::BigInt,"",cursor}
};
ResultSet rs;
get_pool().execute(PRODUCT_LIST_SQL, ps, rs);
if (!get_pool().execute(PRODUCT_LIST_SQL, ps, rs)) {
logc::warn("product list query failed (cursor=%lld limit=%d shop=%d)",
(long long)cursor, limit, shop);
return nlohmann::json::array();
}
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();
for (auto& row : rs) {
@@ -103,13 +111,28 @@ 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 = {
{"_id", row[0].str},
{"imghash", row[7].str.empty() ? nullptr : nlohmann::json(row[7].str)},
{"imgsrc", row[7].str.empty() ? nullptr : nlohmann::json(row[7].str)},
{"sku", row[1].str},
{"name", row[2].str},
{"tax_rate", std::to_string((int)std::round(std::stod(row[4].str.empty()?"0":row[4].str)))},
{"tax_rate", std::to_string((int)std::round(parse_double(row[4].str, 0)))},
{"price", base_price},
{"created_at", row[5].str},
{"lastChanged", row[6].str},
@@ -117,19 +140,19 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
{"categories", cats},
{"prices", prices},
{"is_parent", row[9].str == "1" ? "1" : "0"},
{"parent", std::stoll(row[10].str) > 0 ? row[10].str : "0"},
{"parent", parse_int64(row[10].str, 0) > 0 ? row[10].str : "0"},
{"variants", row[12].str},
{"isCompositeProduct", row[11].str},
{"attributes", nlohmann::json::array()},
{"attributes", std::move(attributes)},
{"sort", "0"},
{"p_price", "0.00"},
{"discountable", "0"},
{"deposit", "0"},
{"deposit", deposit},
{"discount", ""},
{"d_price", "0.0"},
{"d_price", d_price},
{"tax_rate2", ""},
{"use_in_out_tax", "0"},
{"barcode", nullptr},
{"barcode", row[13].str.empty() ? nullptr : nlohmann::json(row[13].str)},
{"use_stock", "0"},
{"q_div", "0"},
{"quantity", "0"},
@@ -140,7 +163,7 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
{"tags", ""},
{"variants", row[12].str},
{"print_kitchen_receipt", "0"},
{"deposit_name", ""},
{"deposit_name", deposit_name},
{"updated_at", "0001-01-01 00:00:00"},
{"configurationGroups", ""},
{"options", nullptr},

View File

@@ -0,0 +1,60 @@
#pragma once
#include <string>
#include <vector>
#include "xml.hpp"
#include "../db/pool.hpp"
namespace delivery {
struct DeliveredItem {
int kAuftragPosition;
double quantity;
};
inline void reserve_positions(OdbcPool::Connection* c, int kBenutzer, int kSessionId,
int kWarenLager, const std::vector<DeliveredItem>& items) {
if (items.empty()) return;
std::string bestellpositionen;
for (const auto& it : items) {
bestellpositionen += xml::element("Bestellposition",
xml::tag("kBestellPos", int64_t(it.kAuftragPosition)) +
xml::tag("fAnzahl", it.quantity));
}
std::string laeger = xml::element("Lager",
xml::tag("kWarenlager", int64_t(kWarenLager)) +
xml::tag("nPrio", int64_t(0)) +
xml::tag("kLieferant", int64_t(0)) +
xml::tag("kAnsprechpartner", int64_t(0)));
const int RESERVIERE_OPTIONS = 0x102;
const char* sql =
"DECLARE @xBestellpositionen XML = CONVERT(XML, ?);"
"DECLARE @xLaeger XML = CONVERT(XML, ?);"
"EXEC Auslieferung.spReserviereBestellpositionen"
" @Bestellpositionen = @xBestellpositionen,"
" @Laeger = @xLaeger,"
" @Warenlagereingaenge = NULL,"
" @nOptions = ?,"
" @kBenutzer = ?,"
" @kSessionId = ?";
std::vector<Param> ps = {
{ParamType::NVarChar, bestellpositionen, 0},
{ParamType::NVarChar, laeger, 0},
{ParamType::Int, "", RESERVIERE_OPTIONS},
{ParamType::Int, "", kBenutzer},
{ParamType::Int, "", kSessionId}
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs)) {
const std::string& detail = get_pool().last_error();
throw std::runtime_error(detail.empty()
? "reserve_positions failed"
: "reserve_positions failed: " + detail);
}
}
} // namespace delivery

View File

@@ -0,0 +1,42 @@
#pragma once
#include <string>
#include "../db/pool.hpp"
namespace delivery {
inline int open_session(OdbcPool::Connection* c, int kBenutzer,
const std::string& hostname = "jtlsrv") {
const char* sql =
"DECLARE @t TABLE ([kSessionId] INT);"
"INSERT INTO dbo.tSessionId (cRechnername, kBenutzer, dLastAction)"
" OUTPUT inserted.kSessionId INTO @t"
" VALUES (?, ?, DATEADD(day, 10, GETDATE()));"
"SELECT kSessionId FROM @t;";
std::vector<Param> ps = {
{ParamType::NVarChar, hostname, 0},
{ParamType::Int, "", kBenutzer}
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) {
throw std::runtime_error("failed to open delivery session");
}
return std::stoi(rs[0][0].str);
}
inline void discard_session(OdbcPool::Connection* c, int kBenutzer, int kSessionId) {
std::vector<Param> ps = {
{ParamType::Int, "", kBenutzer},
{ParamType::Int, "", kSessionId}
};
ResultSet rs;
get_pool().execute(c, "{CALL Auslieferung.spPicklistenVerwerfen(?, ?)}", ps, rs);
}
inline void close_session(OdbcPool::Connection* c, int kSessionId) {
std::vector<Param> ps = {{ParamType::Int, "", kSessionId}};
ResultSet rs;
get_pool().execute(c, "DELETE FROM dbo.tSessionId WHERE kSessionId = ?", ps, rs);
}
} // namespace delivery

View File

@@ -0,0 +1,28 @@
#include "shop.hpp"
#include "../log.hpp"
#include <stdexcept>
int g_active_shop_id = 0;
int g_active_shop_subshop_id = 0;
static int cell_to_int(const Cell& cell) {
if (cell.type == CellType::Int64) return static_cast<int>(cell.i64);
if (cell.type == CellType::String && !cell.str.empty()) return std::stoi(cell.str);
return 0;
}
bool fetch_active_shop() {
ResultSet rs;
if (!get_pool().execute(
"SELECT TOP 1 kShop, kShopSubshop FROM dbo.tShopSubshop "
"WHERE nGesperrt = 0 ORDER BY kShop",
rs) || rs.empty()) {
logc::warn("failed to load active shop from dbo.tShopSubshop");
return false;
}
g_active_shop_id = cell_to_int(rs[0][0]);
g_active_shop_subshop_id = rs[0].size() > 1 ? cell_to_int(rs[0][1]) : 0;
logc::info("Active shop: kShop=%d kShopSubshop=%d", g_active_shop_id, g_active_shop_subshop_id);
return g_active_shop_id > 0;
}

View File

@@ -1,21 +1,10 @@
#pragma once
#include "../db/pool.hpp"
#include "../config.hpp"
static int g_active_shop_id = 0;
static int g_active_shop_subshop_id = 0;
extern int g_active_shop_id;
extern int g_active_shop_subshop_id;
inline int get_active_shop_id() { return g_active_shop_id; }
inline int get_active_shop_subshop_id() { return g_active_shop_subshop_id; }
inline bool fetch_active_shop() {
ResultSet rs;
if (!get_pool().execute(
"SELECT TOP 1 kShop, kShopSubshop FROM dbo.tShopSubshop "
"WHERE nGesperrt = 0 ORDER BY kShop", rs) || rs.empty()) {
return false;
}
g_active_shop_id = std::stoi(rs[0][0].str);
g_active_shop_subshop_id = rs[0].size() > 1 ? std::stoi(rs[0][1].str) : 0;
return true;
}
bool fetch_active_shop();

View File

@@ -0,0 +1,137 @@
#pragma once
#include <cmath>
#include <stdexcept>
#include <string>
#include <vector>
#include "reserve.hpp"
#include "warehouse.hpp"
#include "../db/pool.hpp"
#include "../log.hpp"
namespace delivery {
namespace {
constexpr const char* POS_SHORTAGE_COMMENT = "Korrekturbuchung erstellt durch POS-Abgleich";
constexpr int BUCHUNGSART_WARENEINGANG = 10;
inline double get_reserved_quantity(OdbcPool::Connection* c, int kSessionId, int kAuftragPosition) {
const char* sql =
"SELECT ISNULL(SUM(pp.fAnzahl), 0) FROM dbo.tPicklistePos pp "
"INNER JOIN dbo.tPickliste p ON p.kPickliste = pp.kPickliste "
"WHERE p.kSessionId = ? AND pp.kBestellPos = ?";
std::vector<Param> ps = {
{ParamType::Int, "", kSessionId},
{ParamType::Int, "", kAuftragPosition},
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) {
return 0.0;
}
try {
return std::stod(rs[0][0].str);
} catch (...) {
return 0.0;
}
}
inline int get_position_artikel(OdbcPool::Connection* c, int kAuftragPosition) {
std::vector<Param> ps = {{ParamType::Int, "", kAuftragPosition}};
ResultSet rs;
if (!get_pool().execute(c,
"SELECT kArtikel FROM Verkauf.tAuftragPosition WHERE kAuftragPosition = ?",
ps, rs) || rs.empty() || rs[0].empty() || rs[0][0].type == CellType::Null) {
return 0;
}
try {
return std::stoi(rs[0][0].str);
} catch (...) {
return 0;
}
}
inline void book_wareneingang(OdbcPool::Connection* c, int kBenutzer,
int kWarenLagerPlatz, int kArtikel, double fehlmenge) {
const char* sql =
"DECLARE @kWarenlagerEingang INT;"
"EXEC dbo.spWarenlagerEingangSchreiben"
" @kArtikel = ?,"
" @kWarenLagerPlatz = ?,"
" @kLieferantenBestellungPos = 0,"
" @kBenutzer = ?,"
" @fAnzahl = ?,"
" @fEkEinzel = 0,"
" @cLieferscheinNr = '',"
" @cChargenNr = NULL,"
" @dMHD = NULL,"
" @dGeliefertAm = NULL,"
" @cKommentar = ?,"
" @kGutschriftPos = 0,"
" @kLHM = 0,"
" @kSessionId = 0,"
" @kBuchungsart = ?,"
" @kBestellPosUmlagerung = 0,"
" @kRMRetourePos = 0,"
" @nHistorieNichtSchreiben = 0,"
" @kWarenlagerEingang = @kWarenlagerEingang OUTPUT;"
"SELECT @kWarenlagerEingang AS kWarenlagerEingang;";
std::vector<Param> ps = {
{ParamType::Int, "", kArtikel},
{ParamType::Int, "", kWarenLagerPlatz},
{ParamType::Int, "", kBenutzer},
{ParamType::Double, "", 0, fehlmenge},
{ParamType::NVarChar, POS_SHORTAGE_COMMENT, 0},
{ParamType::Int, "", BUCHUNGSART_WARENEINGANG},
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs)) {
const std::string& detail = get_pool().last_error();
throw std::runtime_error(detail.empty()
? "spWarenlagerEingangSchreiben failed"
: "spWarenlagerEingangSchreiben failed: " + detail);
}
}
} // namespace
// PosStockPositionService.FehlbestandEinbuchen — book missing stock, then re-reserve.
inline void book_stock_shortfalls_and_rereserve(OdbcPool::Connection* c, int kBenutzer,
int kSessionId, int kWarenLager,
int kWarenLagerPlatz,
const std::vector<DeliveredItem>& items) {
std::vector<DeliveredItem> rereserve;
for (const auto& item : items) {
if (item.kAuftragPosition <= 0 || item.quantity <= 0.0) continue;
double reserved = get_reserved_quantity(c, kSessionId, item.kAuftragPosition);
double shortage = item.quantity - reserved;
if (shortage <= 0.0001) continue;
int kArtikel = get_position_artikel(c, item.kAuftragPosition);
if (kArtikel <= 0) continue;
logc::info("POS shortage booking: kBestellPos=%d kArtikel=%d reserved=%.4f need=%.4f book=%.4f",
item.kAuftragPosition, kArtikel, reserved, item.quantity, shortage);
book_wareneingang(c, kBenutzer, kWarenLagerPlatz, kArtikel, shortage);
rereserve.push_back({item.kAuftragPosition, shortage});
}
if (!rereserve.empty()) {
reserve_positions(c, kBenutzer, kSessionId, kWarenLager, rereserve);
}
for (const auto& item : items) {
if (item.kAuftragPosition <= 0 || item.quantity <= 0.0) continue;
double reserved = get_reserved_quantity(c, kSessionId, item.kAuftragPosition);
if (reserved + 0.0001 < item.quantity) {
throw std::runtime_error(
"insufficient stock after POS shortage booking for kBestellPos="
+ std::to_string(item.kAuftragPosition));
}
}
}
} // namespace delivery

View File

@@ -0,0 +1,37 @@
#pragma once
#include "../db/pool.hpp"
#include "../config.hpp"
namespace delivery {
inline int resolve_outgoing_warehouse(OdbcPool::Connection* c) {
int configured = config::get_int("JTL_KWARENLAGER", 0);
if (configured > 0) return configured;
ResultSet rs;
if (!get_pool().execute(c,
"SELECT TOP 1 kWarenLager FROM dbo.tWarenLager "
"WHERE nFulfillment = 0 AND ISNULL(nAktiv, 1) = 1 "
"ORDER BY nAuslieferungsPrio, kWarenLager", rs) || rs.empty() || rs[0].empty()) {
throw std::runtime_error("No local warehouse (dbo.tWarenLager.nFulfillment = 0) found; set JTL_KWARENLAGER explicitly.");
}
return std::stoi(rs[0][0].str);
}
inline int resolve_warehouse_place(OdbcPool::Connection* c, int kWarenLager) {
int configured = config::get_int("JTL_KWARENLAGERPLATZ", 0);
if (configured > 0) return configured;
std::vector<Param> ps = {{ParamType::Int, "", kWarenLager}};
ResultSet rs;
if (!get_pool().execute(c,
"SELECT TOP 1 kWarenLagerPlatz FROM dbo.tWarenLagerPlatz "
"WHERE kWarenLager = ? AND ISNULL(nGesperrt, 0) = 0 "
"ORDER BY nPrio, kWarenLagerPlatz", ps, rs) || rs.empty() || rs[0].empty()) {
throw std::runtime_error("No warehouse place found for kWarenLager="
+ std::to_string(kWarenLager) + "; set JTL_KWARENLAGERPLATZ explicitly.");
}
return std::stoi(rs[0][0].str);
}
} // namespace delivery

View File

@@ -0,0 +1,40 @@
#pragma once
#include <string>
namespace xml {
inline std::string escape(const std::string& s) {
std::string r;
r.reserve(s.size());
for (char c : s) {
switch (c) {
case '<': r += "&lt;"; break;
case '>': r += "&gt;"; break;
case '&': r += "&amp;"; break;
case '\'': r += "&apos;"; break;
case '"': r += "&quot;"; break;
default: r += c; break;
}
}
return r;
}
inline std::string tag(const std::string& name, const std::string& value) {
return "<" + name + ">" + escape(value) + "</" + name + ">";
}
inline std::string tag(const std::string& name, int64_t value) {
return "<" + name + ">" + std::to_string(value) + "</" + name + ">";
}
inline std::string tag(const std::string& name, double value) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%.6f", value);
return "<" + name + ">" + buf + "</" + name + ">";
}
inline std::string element(const std::string& name, const std::string& children) {
return "<" + name + ">" + children + "</" + name + ">";
}
} // namespace xml

View File

@@ -1,8 +1,7 @@
#include "router.hpp"
#include "log.hpp"
#include <chrono>
#include <algorithm>
#include <exception>
void Router::add_route(const std::string& method, const std::string& path, Handler handler) {
std::string key = method + " " + path;
@@ -19,6 +18,11 @@ void Router::dispatch(tls_session* sess, PairingStore& pairing, const json& conf
full_url += "?" + req.query_string;
}
if (req.method == "OPTIONS") {
resp.send_empty(204);
return;
}
std::string route_key = req.method + " " + req.path;
auto it = routes_.find(route_key);
if (it != routes_.end()) {
@@ -27,7 +31,21 @@ void Router::dispatch(tls_session* sess, PairingStore& pairing, const json& conf
ctx.full_url = full_url;
ctx.pairing_store = &pairing;
ctx.config = config;
it->second(req, resp, ctx);
try {
it->second(req, resp, ctx);
} catch (const std::exception& ex) {
logc::error("request handler error: %s %s — %s",
req.method.c_str(), full_url.c_str(), ex.what());
if (!resp.headers_sent) {
resp.send_json(500, {{"Message", "Internal server error"}});
}
} catch (...) {
logc::error("request handler error: %s %s — unknown exception",
req.method.c_str(), full_url.c_str());
if (!resp.headers_sent) {
resp.send_json(500, {{"Message", "Internal server error"}});
}
}
return;
}
@@ -37,32 +55,20 @@ void Router::dispatch(tls_session* sess, PairingStore& pairing, const json& conf
});
}
// Init suppression: log only if non-200, slow, or first/different request
// within 30s window. Port of server.js shouldLogInit().
bool Router::should_log_init(const std::string& url, int status, int duration_ms,
const std::string& response) {
// Report suppressed count hourly
auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
if (suppressed_count_ > 0 && (now - suppressed_report_ts_) > 3'600'000) {
logc::info("Suppressed %d init log(s) in the last hour", suppressed_count_);
suppressed_count_ = 0;
suppressed_report_ts_ = now;
}
if (status != 200) return true;
if (duration_ms > 400) return true;
int64_t time_since_last = now - last_init_timestamp_;
if (time_since_last < 30'000 && url == last_init_url_ && response == last_init_result_) {
last_init_timestamp_ = now;
suppressed_count_++;
// Suppress consecutive identical /v1/init URLs; report count on non-init logs.
bool Router::should_log_init(const std::string& url) {
if (last_logged_init_url_ == url) {
suppressed_init_count_++;
return false;
}
last_init_url_ = url;
last_init_result_ = response;
last_init_timestamp_ = now;
last_logged_init_url_ = url;
return true;
}
void Router::flush_suppressed_init_logs() {
if (suppressed_init_count_ > 0) {
logc::info("Suppressed %d duplicate init log(s)", suppressed_init_count_);
suppressed_init_count_ = 0;
}
last_logged_init_url_.clear();
}

View File

@@ -19,17 +19,16 @@ public:
// Dispatch a request. Called from the TLS on_request callback.
void dispatch(tls_session* sess, PairingStore& pairing, const json& config);
// Suppress init logs for repeated identical requests within 30s
bool should_log_init(const std::string& url, int status, int duration_ms,
const std::string& response);
// Suppress consecutive identical init URLs; return false if suppressed.
bool should_log_init(const std::string& url);
// Print suppressed init count (if any) before a non-init log line.
void flush_suppressed_init_logs();
private:
std::unordered_map<std::string, Handler> routes_;
// Init suppression state
std::string last_init_url_;
std::string last_init_result_;
int64_t last_init_timestamp_ = 0;
int suppressed_count_ = 0;
int64_t suppressed_report_ts_ = 0;
std::string last_logged_init_url_;
int suppressed_init_count_ = 0;
};

View File

@@ -197,7 +197,6 @@ static void on_read(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) {
int ret = SSL_do_handshake(sess->ssl);
if (ret == 1) {
sess->handshake_done = true;
logc::info("TLS handshake complete");
flush_encrypted(sess);
} else {
int err = SSL_get_error(sess->ssl, ret);
@@ -276,7 +275,7 @@ static void on_connection(uv_stream_t* server, int status) {
uv_ip4_name(reinterpret_cast<struct sockaddr_in*>(&saddr), addr_buf, sizeof(addr_buf));
else
uv_ip6_name(reinterpret_cast<struct sockaddr_in6*>(&saddr), addr_buf, sizeof(addr_buf));
logc::info("TLS connection from %s", addr_buf);
sess->peer_ip = addr_buf;
// Read encrypted data
uv_read_start(reinterpret_cast<uv_stream_t*>(&sess->tcp_handle), on_alloc, on_read);
@@ -341,6 +340,7 @@ static void tls_server_shutdown() {
uv_close(reinterpret_cast<uv_handle_t*>(&g_sigint), nullptr);
uv_close(reinterpret_cast<uv_handle_t*>(&g_sigterm), nullptr);
uv_close(reinterpret_cast<uv_handle_t*>(&g_server), nullptr);
uv_stop(g_loop);
}
void tls_server_install_signals(uv_loop_t* loop) {

View File

@@ -35,6 +35,7 @@ struct tls_session {
bool handshake_done = false;
uint32_t body_length = 0;
std::string peer_ip;
};
// ---------------------------------------------------------------------------

306
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"chalk": "^5.6.2",
"dotenv": "^17.4.2",
"mssql": "^12.7.0",
"qrcode": "^1.5.4",
"sharp": "^0.35.3"
},
"engines": {
@@ -884,6 +885,30 @@
"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": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -984,6 +1009,15 @@
"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": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -1002,6 +1036,35 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"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": {
"version": "11.1.0",
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -1101,6 +1173,12 @@
"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": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -1122,6 +1200,12 @@
"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": {
"version": "1.4.5",
"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==",
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"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": {
"version": "0.0.0",
"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"
}
},
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
@@ -1347,6 +1462,18 @@
"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": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -1492,6 +1619,60 @@
"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": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1538,6 +1719,23 @@
"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": {
"version": "1.2.8",
"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_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": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
@@ -1619,6 +1832,12 @@
"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": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
@@ -1728,6 +1947,32 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
@@ -1874,6 +2119,26 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1894,6 +2159,47 @@
"funding": {
"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,6 +8,14 @@
"main": "server.js",
"scripts": {
"cert": "node generate-cert.js",
"qr": "node scripts/create-pairing-qr.mjs",
"backup:s3": "node scripts/s3-backup/backup.mjs",
"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:extract": "node scripts/create-minimal-db.mjs extract",
"demo:generate": "node scripts/generate-demo-catalog.mjs",
"start": "node --watch server.js",
"test:client": "node test-client.js"
},
@@ -19,6 +27,7 @@
"chalk": "^5.6.2",
"dotenv": "^17.4.2",
"mssql": "^12.7.0",
"qrcode": "^1.5.4",
"sharp": "^0.35.3"
}
}

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,184 @@
{
"version": 1,
"extractedAt": "2026-07-15T21:37:45.653Z",
"source": "eazybusiness",
"tableCount": 43,
"indexCount": 158,
"triggerCount": 77,
"typeCount": 11,
"rowCount": 9575,
"tables": [
{
"schema": "dbo",
"table": "tArtikel"
},
{
"schema": "dbo",
"table": "tArtikelAttribut"
},
{
"schema": "dbo",
"table": "tArtikelAttributSprache"
},
{
"schema": "dbo",
"table": "tArtikelBeschreibung"
},
{
"schema": "dbo",
"table": "tArtikelbildPlattform"
},
{
"schema": "dbo",
"table": "tAttribut"
},
{
"schema": "dbo",
"table": "tAttributSprache"
},
{
"schema": "dbo",
"table": "tBild"
},
{
"schema": "dbo",
"table": "tFirmaHistory"
},
{
"schema": "dbo",
"table": "tKategorie"
},
{
"schema": "dbo",
"table": "tKategorieArtikel"
},
{
"schema": "dbo",
"table": "tKategorieShop"
},
{
"schema": "dbo",
"table": "tKategorieSprache"
},
{
"schema": "dbo",
"table": "tKategoriebildPlattform"
},
{
"schema": "dbo",
"table": "tKunde"
},
{
"schema": "dbo",
"table": "tKundenGruppe"
},
{
"schema": "dbo",
"table": "tLaufendeNummern"
},
{
"schema": "dbo",
"table": "tLieferschein"
},
{
"schema": "dbo",
"table": "tPickliste"
},
{
"schema": "dbo",
"table": "tPicklistePos"
},
{
"schema": "dbo",
"table": "tPlattform"
},
{
"schema": "dbo",
"table": "tPreis"
},
{
"schema": "dbo",
"table": "tPreisDetail"
},
{
"schema": "dbo",
"table": "tSessionId"
},
{
"schema": "dbo",
"table": "tShop"
},
{
"schema": "dbo",
"table": "tShopSubshop"
},
{
"schema": "dbo",
"table": "tSteuersatz"
},
{
"schema": "dbo",
"table": "tSteuerzone"
},
{
"schema": "dbo",
"table": "tStueckliste"
},
{
"schema": "dbo",
"table": "tVersand"
},
{
"schema": "dbo",
"table": "tVersandArt"
},
{
"schema": "dbo",
"table": "tWarenLager"
},
{
"schema": "dbo",
"table": "tWarenLagerPlatz"
},
{
"schema": "dbo",
"table": "tZahlung"
},
{
"schema": "dbo",
"table": "tZahlungsArtSprache"
},
{
"schema": "dbo",
"table": "tZahlungsart"
},
{
"schema": "dbo",
"table": "tpk"
},
{
"schema": "Pos",
"table": "tAuftragMapping"
},
{
"schema": "Pos",
"table": "tAuftragPositionMapping"
},
{
"schema": "Verkauf",
"table": "tAuftrag"
},
{
"schema": "Verkauf",
"table": "tAuftragAdresse"
},
{
"schema": "Verkauf",
"table": "tAuftragEckdaten"
},
{
"schema": "Verkauf",
"table": "tAuftragPosition"
}
]
}

View File

@@ -0,0 +1,55 @@
# MSSQL backup via local S3 endpoint
Custom S3-compatible HTTPS server (no MinIO). SQL Server 2022+ backs up with `BACKUP TO URL`; files land on disk under `data/sqlbackups/`.
## Quick start
```bash
npm run backup:s3
```
First run installs the CA into the `mssql` Docker container (`/var/opt/mssql/security/ca-certificates/`) and restarts SQL Server — required on Linux.
## Commands
| Command | Description |
|---------|-------------|
| `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 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 --server-only` | Run endpoint only |
| `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
```
scripts/s3-backup/
server.mjs S3-compatible HTTPS server (SigV4, multipart upload)
backup.mjs Orchestrator: trust CA → start server → sqlcmd BACKUP
restore.mjs Orchestrator: trust CA → start server → sqlcmd RESTORE
config.mjs Host, port, credentials
ensure-certs.mjs TLS certs + Docker MSSQL PAL trust
sigv4.mjs AWS Signature V4 verification
data/sqlbackups/ Backup files written here
```
## Configuration
Reads `MSSQL_*` from repo `.env`. Optional overrides:
| Variable | Default | Description |
|----------|---------|-------------|
| `S3_BACKUP_HOST` | Docker bridge IP (`172.17.0.1`) | Host SQL Server uses in `s3://` URL |
| `S3_BACKUP_PORT` | `19443` | HTTPS port |
| `S3_BACKUP_ACCESS_KEY` | `jtlsrv-s3` | S3 access key |
| `S3_BACKUP_SECRET_KEY` | `jtlsrv-s3-secret` | S3 secret key |
| `MSSQL_DOCKER_CONTAINER` | `mssql` | Container name for CA install |
## Notes
- MSSQL runs in Docker: the endpoint binds `0.0.0.0` and uses the docker bridge IP so the container can reach it.
- SQL Server on Linux uses **SQLPAL** for outbound TLS — the CA must be in `/var/opt/mssql/security/ca-certificates/`, not only the OS trust store.
- Regenerating certs requires re-running without `--skip-trust` so PAL stays in sync.

View File

@@ -0,0 +1,267 @@
#!/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 args = new Set(process.argv.slice(2));
const databases = args.has('--all')
? ['eazybusiness', 'Mandant_3']
: [process.env.MSSQL_DATABASE || 'eazybusiness'];
const serverOnly = args.has('--server-only');
const skipTrust = args.has('--skip-trust');
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 timestamp() {
return new Date().toISOString().replace(/[:.]/g, '-');
}
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 backupDatabase(db) {
const file = `${db}-${timestamp()}.bak`;
const url = `${s3BaseUrl()}/${file}`;
console.log(`Backing up ${db} -> ${url}`);
await sqlcmd(`
BACKUP DATABASE [${db}]
TO URL = '${url}'
WITH FORMAT, COMPRESSION, MAXTRANSFERSIZE = 20971520, STATS = 10;
`);
const onDisk = path.join(DATA_DIR, file);
if (!fs.existsSync(onDisk)) {
throw new Error(`Backup finished but file missing on disk: ${onDisk}`);
}
const mb = (fs.statSync(onDisk).size / (1024 * 1024)).toFixed(1);
console.log(`Saved ${onDisk} (${mb} MB)`);
return onDisk;
}
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;
}
await ensureCredential();
const saved = [];
for (const db of databases) {
saved.push(await backupDatabase(db));
}
console.log('\nBackups on disk:');
for (const file of saved) {
console.log(` ${file}`);
}
} finally {
if (!serverOnly) {
serverChild.kill();
await stopPortListener(PORT);
}
}
}
if (args.has('--help')) {
console.log(`Usage: node scripts/s3-backup/backup.mjs [options]
Starts a local S3-compatible HTTPS endpoint and backs up MSSQL to disk.
Options:
--all Backup eazybusiness and Mandant_3
--server-only Start endpoint only, no backup
--skip-trust Skip installing CA cert into system trust store
Reads MSSQL_* from .env in repo root.
Backups land in scripts/s3-backup/data/${BUCKET}/
`);
process.exit(0);
}
main().catch((err) => {
console.error(err.message || err);
process.exit(1);
});

View File

@@ -0,0 +1,40 @@
import { execSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.dirname(fileURLToPath(import.meta.url));
function dockerGateway() {
try {
const out = execSync("ip -4 route show default dev docker0 2>/dev/null | awk '{print $3}'", {
encoding: 'utf8',
}).trim();
if (out) return out;
} catch {
// fall through
}
return '172.17.0.1';
}
export const HOST = process.env.S3_BACKUP_HOST || dockerGateway();
export const BIND = process.env.S3_BACKUP_BIND || '0.0.0.0';
export const PORT = Number(process.env.S3_BACKUP_PORT || 19443);
export const BUCKET = process.env.S3_BACKUP_BUCKET || 'sqlbackups';
export const ACCESS_KEY = process.env.S3_BACKUP_ACCESS_KEY || 'jtlsrv-s3';
export const SECRET_KEY = process.env.S3_BACKUP_SECRET_KEY || 'jtlsrv-s3-secret';
export const REGION = process.env.S3_BACKUP_REGION || 'us-east-1';
export const DATA_DIR = path.join(root, 'data', BUCKET);
export const TMP_DIR = path.join(root, 'tmp');
export const CERTS_DIR = path.join(root, 'certs');
export const CA_CERT = path.join(CERTS_DIR, 'ca.pem');
export const SERVER_KEY = path.join(CERTS_DIR, 'server-key.pem');
export const SERVER_CERT = path.join(CERTS_DIR, 'server-cert.pem');
export function s3BaseUrl() {
return `s3://${HOST}:${PORT}/${BUCKET}`;
}
export function httpsBaseUrl() {
return `https://${HOST}:${PORT}`;
}

View File

@@ -0,0 +1,197 @@
import { execSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { CA_CERT, CERTS_DIR, HOST, SERVER_CERT, SERVER_KEY } from './config.mjs';
const SQL_CA_DIR = '/var/opt/mssql/security/ca-certificates';
export function certHosts(host) {
const hosts = new Set([host, '127.0.0.1', 'localhost', 's3backup.local']);
for (const iface of Object.values(os.networkInterfaces())) {
for (const addr of iface || []) {
if (addr.family === 'IPv4' && !addr.internal && !addr.address.startsWith('169.254.')) {
hosts.add(addr.address);
}
}
}
return [...hosts].sort();
}
export function ensureCerts(host = HOST) {
fs.mkdirSync(CERTS_DIR, { recursive: true });
const hosts = certHosts(host);
const marker = path.join(CERTS_DIR, 'hosts.txt');
const hostList = hosts.join('\n');
if (
fs.existsSync(SERVER_CERT) &&
fs.existsSync(SERVER_KEY) &&
fs.existsSync(CA_CERT) &&
fs.existsSync(marker) &&
fs.readFileSync(marker, 'utf8') === hostList
) {
return;
}
const cnf = `${CERTS_DIR}/openssl.cnf`;
const altNames = hosts
.map((h, i) => (/^\d+\./.test(h) ? `IP.${i + 1} = ${h}` : `DNS.${i + 1} = ${h}`))
.join('\n');
fs.writeFileSync(
cnf,
`[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = ${host}
[v3_req]
subjectAltName = @alt_names
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
[alt_names]
${altNames}
`
);
execSync(
`openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout "${CERTS_DIR}/ca-key.pem" -out "${CA_CERT}" \
-subj "/CN=JTL S3 Backup CA/O=JTL/C=DE"`,
{ stdio: 'pipe' }
);
execSync(
`openssl req -newkey rsa:2048 -nodes \
-keyout "${SERVER_KEY}" -out "${CERTS_DIR}/server.csr" \
-config "${cnf}"`,
{ stdio: 'pipe' }
);
execSync(
`openssl x509 -req -in "${CERTS_DIR}/server.csr" \
-CA "${CA_CERT}" -CAkey "${CERTS_DIR}/ca-key.pem" -CAcreateserial \
-out "${SERVER_CERT}" -days 3650 -extensions v3_req -extfile "${cnf}"`,
{ stdio: 'pipe' }
);
fs.writeFileSync(marker, hostList);
}
export function caTrustStatus(container = process.env.MSSQL_DOCKER_CONTAINER || 'mssql') {
if (!fs.existsSync(CA_CERT)) {
return { ok: false, inSync: false, restarted: false };
}
const localFp = execSync(`openssl x509 -in "${CA_CERT}" -noout -fingerprint -sha256`, {
encoding: 'utf8',
}).trim();
const remoteFp = spawnSync(
'docker',
[
'exec',
container,
'bash',
'-lc',
`test -f ${SQL_CA_DIR}/jtlsrv-s3-ca.pem && openssl x509 -in ${SQL_CA_DIR}/jtlsrv-s3-ca.pem -noout -fingerprint -sha256`,
],
{ encoding: 'utf8' }
);
const inSync = remoteFp.status === 0 && remoteFp.stdout.trim() === localFp;
return { ok: remoteFp.status === 0, inSync, restarted: false };
}
export function installCaTrust(container = process.env.MSSQL_DOCKER_CONTAINER || 'mssql') {
if (!fs.existsSync(CA_CERT)) {
ensureCerts();
}
const localFp = execSync(`openssl x509 -in "${CA_CERT}" -noout -fingerprint -sha256`, {
encoding: 'utf8',
}).trim();
const remoteFp = spawnSync(
'docker',
[
'exec',
container,
'bash',
'-lc',
`test -f ${SQL_CA_DIR}/jtlsrv-s3-ca.pem && openssl x509 -in ${SQL_CA_DIR}/jtlsrv-s3-ca.pem -noout -fingerprint -sha256`,
],
{ encoding: 'utf8' }
);
if (remoteFp.status === 0 && remoteFp.stdout.trim() === localFp) {
spawnSync('docker', [
'exec',
'-u',
'root',
container,
'bash',
'-lc',
'grep -q s3backup.local /etc/hosts || echo "172.17.0.1 s3backup.local" >> /etc/hosts',
]);
return { ok: true, restarted: false };
}
spawnSync('docker', ['exec', '-u', 'root', container, 'mkdir', '-p', SQL_CA_DIR], {
stdio: 'pipe',
});
spawnSync('docker', [
'exec',
'-u',
'root',
container,
'bash',
'-lc',
`rm -f ${SQL_CA_DIR}/*.pem ${SQL_CA_DIR}/*.crt`,
]);
spawnSync('docker', [
'exec',
'-u',
'root',
container,
'bash',
'-lc',
'grep -q s3backup.local /etc/hosts || echo "172.17.0.1 s3backup.local" >> /etc/hosts',
]);
try {
execSync('grep -q s3backup.local /etc/hosts || echo "172.17.0.1 s3backup.local" >> /etc/hosts', {
stdio: 'pipe',
});
} catch {
// optional on host
}
const copied = spawnSync(
'docker',
['cp', CA_CERT, `${container}:${SQL_CA_DIR}/jtlsrv-s3-ca.pem`],
{ stdio: 'pipe' }
);
if (copied.status !== 0) {
return { ok: false, restarted: false };
}
const perms = spawnSync(
'docker',
[
'exec',
'-u',
'root',
container,
'bash',
'-lc',
`chown mssql:mssql ${SQL_CA_DIR}/jtlsrv-s3-ca.pem && chmod 644 ${SQL_CA_DIR}/jtlsrv-s3-ca.pem`,
],
{ stdio: 'pipe' }
);
if (perms.status !== 0) {
return { ok: false, restarted: false };
}
const restarted = spawnSync('docker', ['restart', container], { stdio: 'pipe' });
return { ok: true, restarted: restarted.status === 0 };
}

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

@@ -0,0 +1,335 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
import { URL, pathToFileURL } from 'node:url';
import {
ACCESS_KEY,
BIND,
BUCKET,
DATA_DIR,
HOST,
PORT,
REGION,
SECRET_KEY,
SERVER_CERT,
SERVER_KEY,
TMP_DIR,
} from './config.mjs';
import { ensureCerts } from './ensure-certs.mjs';
import { etagFor, verifyRequest } from './sigv4.mjs';
const uploads = new Map();
function xml(body) {
return `<?xml version="1.0" encoding="UTF-8"?>\n${body}`;
}
function send(res, status, body = '', headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/xml', ...headers });
res.end(body);
}
function readBody(req) {
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'DELETE') {
return Promise.resolve(Buffer.alloc(0));
}
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function objectPath(key) {
return path.join(DATA_DIR, key);
}
function ensureObjectDir(key) {
fs.mkdirSync(path.dirname(objectPath(key)), { recursive: true });
}
function listObjects(prefix = '') {
if (!fs.existsSync(DATA_DIR)) return [];
const out = [];
const walk = (dir, rel = '') => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full, relPath);
} else if (!prefix || relPath.startsWith(prefix)) {
const stat = fs.statSync(full);
out.push({ key: relPath, size: stat.size, mtime: stat.mtime });
}
}
};
walk(DATA_DIR);
return out;
}
function parseRoute(urlString) {
const url = new URL(urlString, 'https://local');
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length === 0) {
return { type: 'root' };
}
if (parts[0] !== BUCKET) {
return { type: 'missing' };
}
const key = parts.slice(1).join('/');
return { type: 'object', key, query: url.searchParams };
}
function authOk(req, body) {
if (!req.headers.authorization) return false;
return verifyRequest(req, body, {
accessKey: ACCESS_KEY,
secretKey: SECRET_KEY,
region: REGION,
});
}
function listBucketsXml() {
return xml(`<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Owner><ID>jtlsrv</ID><DisplayName>jtlsrv</DisplayName></Owner>
<Buckets>
<Bucket>
<Name>${BUCKET}</Name>
<CreationDate>2026-01-01T00:00:00.000Z</CreationDate>
</Bucket>
</Buckets>
</ListAllMyBucketsResult>`);
}
function listBucketXml(prefix) {
const items = listObjects(prefix).map((item) => {
const etag = etagFor(fs.readFileSync(path.join(DATA_DIR, item.key)));
return `<Contents>
<Key>${item.key}</Key>
<LastModified>${item.mtime.toISOString()}</LastModified>
<ETag>${etag}</ETag>
<Size>${item.size}</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>`;
}).join('\n');
return xml(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>${BUCKET}</Name>
<Prefix>${prefix}</Prefix>
<MaxKeys>1000</MaxKeys>
<IsTruncated>false</IsTruncated>
${items}
</ListBucketResult>`);
}
function initiateMultipart(key) {
const uploadId = crypto.randomUUID();
const dir = path.join(TMP_DIR, uploadId);
fs.mkdirSync(dir, { recursive: true });
uploads.set(uploadId, { key, dir, parts: new Map() });
return xml(`<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>${BUCKET}</Bucket>
<Key>${key}</Key>
<UploadId>${uploadId}</UploadId>
</InitiateMultipartUploadResult>`);
}
function savePart(uploadId, partNumber, body) {
const upload = uploads.get(uploadId);
if (!upload) return null;
const partPath = path.join(upload.dir, String(partNumber).padStart(5, '0'));
fs.writeFileSync(partPath, body);
upload.parts.set(partNumber, partPath);
return etagFor(body);
}
function completeMultipart(uploadId) {
const upload = uploads.get(uploadId);
if (!upload) return null;
ensureObjectDir(upload.key);
const partNumbers = [...upload.parts.keys()].sort((a, b) => a - b);
const chunks = partNumbers.map((n) => fs.readFileSync(upload.parts.get(n)));
const finalData = Buffer.concat(chunks);
fs.writeFileSync(objectPath(upload.key), finalData);
fs.rmSync(upload.dir, { recursive: true, force: true });
uploads.delete(uploadId);
const etag = etagFor(finalData);
return xml(`<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Location>https://${HOST}:${PORT}/${BUCKET}/${upload.key}</Location>
<Bucket>${BUCKET}</Bucket>
<Key>${upload.key}</Key>
<ETag>${etag}</ETag>
</CompleteMultipartUploadResult>`);
}
function abortMultipart(uploadId) {
const upload = uploads.get(uploadId);
if (!upload) return false;
fs.rmSync(upload.dir, { recursive: true, force: true });
uploads.delete(uploadId);
return true;
}
function debug(...args) {
if (process.env.S3_BACKUP_DEBUG) {
console.error(...args);
}
}
async function handle(req, res) {
const body = await readBody(req);
debug(`${req.method} ${req.url} len=${body.length}`);
if (!authOk(req, body)) {
debug(`auth failed ${req.method} ${req.url}`);
return send(res, 403, xml('<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>'));
}
const route = parseRoute(req.url);
if (route.type === 'missing') {
return send(res, 404, xml('<Error><Code>NoSuchBucket</Code><Message>Not Found</Message></Error>'));
}
if (route.type === 'root' && req.method === 'GET') {
return send(res, 200, listBucketsXml());
}
if (route.type !== 'object') {
return send(res, 404, xml('<Error><Code>NoSuchKey</Code><Message>Not Found</Message></Error>'));
}
const { key, query } = route;
if (req.method === 'GET' && !key) {
return send(res, 200, listBucketXml(query.get('prefix') || ''));
}
if (req.method === 'HEAD' && !key) {
return send(res, 200, '');
}
if (req.method === 'GET' && key) {
const file = objectPath(key);
if (!fs.existsSync(file)) {
return send(res, 404, xml('<Error><Code>NoSuchKey</Code><Message>Not Found</Message></Error>'));
}
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, {
'Content-Type': 'application/octet-stream',
'Content-Length': stat.size,
'Accept-Ranges': 'bytes',
ETag: etag,
});
return fs.createReadStream(file).pipe(res);
}
if (req.method === 'HEAD' && key) {
const file = objectPath(key);
if (!fs.existsSync(file)) {
return send(res, 404, '');
}
const stat = fs.statSync(file);
return send(res, 200, '', {
'Content-Length': stat.size,
'Accept-Ranges': 'bytes',
ETag: etagFor(fs.readFileSync(file)),
});
}
if (req.method === 'PUT' && key && query.has('uploadId') && query.has('partNumber')) {
const etag = savePart(query.get('uploadId'), Number(query.get('partNumber')), body);
if (!etag) {
return send(res, 404, xml('<Error><Code>NoSuchUpload</Code><Message>Not Found</Message></Error>'));
}
return send(res, 200, '', { ETag: etag });
}
if (req.method === 'PUT' && key) {
ensureObjectDir(key);
fs.writeFileSync(objectPath(key), body);
return send(res, 200, '', { ETag: etagFor(body) });
}
if (req.method === 'POST' && key && query.has('uploads')) {
return send(res, 200, initiateMultipart(key));
}
if (req.method === 'POST' && key && query.has('uploadId')) {
const result = completeMultipart(query.get('uploadId'));
if (!result) {
return send(res, 404, xml('<Error><Code>NoSuchUpload</Code><Message>Not Found</Message></Error>'));
}
return send(res, 200, result);
}
if (req.method === 'DELETE' && key && query.has('uploadId')) {
if (!abortMultipart(query.get('uploadId'))) {
return send(res, 404, xml('<Error><Code>NoSuchUpload</Code><Message>Not Found</Message></Error>'));
}
return send(res, 204, '');
}
return send(res, 405, xml('<Error><Code>MethodNotAllowed</Code><Message>Not allowed</Message></Error>'));
}
export function startServer() {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.mkdirSync(TMP_DIR, { recursive: true });
const server = https.createServer(
{ key: fs.readFileSync(SERVER_KEY), cert: fs.readFileSync(SERVER_CERT) },
(req, res) => {
handle(req, res).catch((err) => {
console.error(err);
send(res, 500, xml('<Error><Code>InternalError</Code><Message>Server error</Message></Error>'));
});
}
);
return new Promise((resolve, reject) => {
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
reject(new Error(`Port ${PORT} is already in use`));
return;
}
reject(err);
});
server.on('secureConnection', (tlsSocket) => {
debug(`tls ${tlsSocket.remoteAddress}:${tlsSocket.remotePort}`);
});
server.listen(PORT, BIND, () => {
console.log(`S3 endpoint https://${HOST}:${PORT}/${BUCKET} -> ${DATA_DIR}`);
resolve(server);
});
});
}
if (process.env.S3_BACKUP_CHILD || import.meta.url === pathToFileURL(process.argv[1]).href) {
startServer().catch((err) => {
console.error(err.message || err);
process.exit(1);
});
}

View File

@@ -0,0 +1,90 @@
import crypto from 'node:crypto';
function hmac(key, data, encoding) {
return crypto.createHmac('sha256', key).update(data, 'utf8').digest(encoding);
}
function hash(data) {
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
}
function parseAuthHeader(header) {
const parts = Object.fromEntries(
header.replace(/^AWS4-HMAC-SHA256\s+/, '').split(',').map((part) => {
const idx = part.indexOf('=');
const key = part.slice(0, idx).trim();
const value = part.slice(idx + 1).trim().replace(/^"|"$/g, '');
return [key, value];
})
);
const credential = parts.Credential.split('/');
return {
accessKey: credential[0],
date: credential[1],
region: credential[2],
signedHeaders: parts.SignedHeaders.split(';'),
signature: parts.Signature,
};
}
function getHeader(req, name) {
return req.headers[name.toLowerCase()] || '';
}
function canonicalQuery(query) {
if (!query) return '';
const params = new URLSearchParams(query.startsWith('?') ? query.slice(1) : query);
return [...params.entries()]
.map(([k, v]) => [encodeURIComponent(k), encodeURIComponent(v)])
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&');
}
function canonicalHeaders(req, signedHeaders) {
return signedHeaders
.map((name) => `${name}:${getHeader(req, name).trim().replace(/\s+/g, ' ')}`)
.join('\n');
}
export function verifyRequest(req, body, { accessKey, secretKey, region = 'us-east-1' }) {
const auth = getHeader(req, 'authorization');
if (!auth.startsWith('AWS4-HMAC-SHA256')) {
return false;
}
const parsed = parseAuthHeader(auth);
if (parsed.accessKey !== accessKey) {
return false;
}
const regionToUse = parsed.region || region;
const amzDate = getHeader(req, 'x-amz-date');
const declaredPayload = getHeader(req, 'x-amz-content-sha256');
const payloadHash =
declaredPayload === 'UNSIGNED-PAYLOAD' ? 'UNSIGNED-PAYLOAD' : declaredPayload || hash(body);
const canonical = [
req.method,
req.url.split('?')[0] || '/',
canonicalQuery(req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''),
`${canonicalHeaders(req, parsed.signedHeaders)}\n`,
parsed.signedHeaders.join(';'),
payloadHash,
].join('\n');
const scope = `${parsed.date}/${regionToUse}/s3/aws4_request`;
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, hash(canonical)].join('\n');
const signingKey = hmac(
hmac(hmac(hmac(`AWS4${secretKey}`, parsed.date), regionToUse), 's3'),
'aws4_request'
);
const expected = hmac(signingKey, stringToSign, 'hex');
if (expected.length !== parsed.signature.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parsed.signature, 'hex'));
}
export function etagFor(data) {
return `"${crypto.createHash('md5').update(data).digest('hex')}"`;
}

102
server.js
View File

@@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url';
import 'dotenv/config';
import { connectDb, closeDb } from './src/db.js';
import { isDemoMode } from './src/demo/mode.js';
import { loadDemoCatalog } from './src/demo/store.js';
import { readCertMetadata } from './src/cert-meta.js';
import { createJtlPosServer } from './src/jtl-server.js';
import { createPairingStore } from './src/pairing.js';
import { closeOrderLog } from './src/order-log.js';
@@ -36,33 +39,14 @@ function truncateUrl(url) {
return `${url.slice(0, CONSOLE_URL_MAX_LENGTH)}...`;
}
const lastInitState = { url: '', result: '', timestamp: 0 };
let suppressedCount = 0;
let lastLoggedInitUrl = null;
let suppressedInitCount = 0;
setInterval(() => {
if (suppressedCount > 0) {
logger.info(`Suppressed ${suppressedCount} init log(s) in the last hour`);
suppressedCount = 0;
function flushSuppressedInitLogs() {
if (suppressedInitCount > 0) {
logger.info(`Suppressed ${suppressedInitCount} duplicate init log(s)`);
suppressedInitCount = 0;
}
}, 60 * 60 * 1000);
function shouldLogInit(url, statusCode, durationMs, responseBody) {
if (statusCode !== 200) return true;
if (durationMs > 400) return true;
const now = Date.now();
const timeSinceLast = now - lastInitState.timestamp;
if (timeSinceLast < 30_000 && url === lastInitState.url && responseBody === lastInitState.result) {
lastInitState.timestamp = now;
suppressedCount++;
return false;
}
lastInitState.url = url;
lastInitState.result = responseBody;
lastInitState.timestamp = now;
return true;
}
function formatBody(buffer) {
@@ -76,11 +60,18 @@ function formatBody(buffer) {
return `[binary ${buffer.length} bytes]`;
}
const certPem = fs.readFileSync(certPath);
const keyPem = fs.readFileSync(keyPath);
const certMeta = readCertMetadata(certPem);
const pairingStore = createPairingStore();
pairingStore.setPairingCode(PAIRING_CODE, '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 started = Date.now();
@@ -100,37 +91,54 @@ const loggedJtlHandler = async (req, res) => {
const durationMs = Date.now() - started;
const isInit = req.url.startsWith('/api/v1/init');
if (!isInit || shouldLogInit(req.url, res.statusCode, durationMs, responseBody)) {
logger.info(`${req.socket.remoteAddress} ${req.method} ${truncateUrl(req.url)} ${res.statusCode} ${durationMs}ms`);
logRequest({
remoteAddress: req.socket.remoteAddress,
method: req.method,
url: req.url,
statusCode: res.statusCode,
durationMs,
response: responseBody,
});
if (isInit) {
if (lastLoggedInitUrl === req.url) {
suppressedInitCount++;
return;
}
lastLoggedInitUrl = req.url;
} else {
flushSuppressedInitLogs();
lastLoggedInitUrl = null;
}
logger.info(`${req.socket.remoteAddress} ${req.method} ${truncateUrl(req.url)} ${res.statusCode} ${durationMs}ms`);
logRequest({
remoteAddress: req.socket.remoteAddress,
method: req.method,
url: req.url,
statusCode: res.statusCode,
durationMs,
response: responseBody,
});
};
const httpsServer = https.createServer(
{
key: fs.readFileSync(keyPath),
cert: fs.readFileSync(certPath),
key: keyPem,
cert: certPem,
},
loggedJtlHandler
);
async function start() {
try {
const pool = await connectDb();
logger.success(`MSSQL connected: ${process.env.MSSQL_SERVER}/${process.env.MSSQL_DATABASE}`);
const activeShop = await fetchActiveShop(pool);
logger.info(`Active shop ID: ${activeShop}`);
} catch (err) {
logger.warn(`MSSQL connection skipped: ${err.message}`);
logger.warn('POS handshake will still work; sync from database is not available yet.');
if (isDemoMode()) {
const stats = await loadDemoCatalog();
logger.success(
`DEMO_MODE: loaded catalog (${stats.products} products, ${stats.categories} categories, ${stats.customerGroups} customer groups, ${stats.composites} composite links)`
);
logger.info('MSSQL is skipped while DEMO_MODE=true');
} else {
try {
const pool = await connectDb();
logger.success(`MSSQL connected: ${process.env.MSSQL_SERVER}/${process.env.MSSQL_DATABASE}`);
const activeShop = await fetchActiveShop(pool);
logger.info(`Active shop ID: ${activeShop}`);
} catch (err) {
logger.warn(`MSSQL connection skipped: ${err.message}`);
logger.warn('POS handshake will still work; sync from database is not available yet.');
}
}
httpsServer.listen(PORT, '0.0.0.0', () => {

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 (pairingStore.hasPairingCode(authCode)) {
pairingStore.revokePairingCode(authCode);
pairingStore.registerDevice(config.authToken, name);
return sendJson(res, 200, buildClientStep2(authCode, config));
}

View File

@@ -1,6 +1,6 @@
import { sendJson } from '../http.js';
import { getMaxExternalId } from '../order-log.js';
import { getCategoryCount } from '../queries/category-count.js';
import { getMaxOrderIdCount } from '../queries/max-order-id.js';
import { getCompositeProductCount } from '../queries/composite-product-count.js';
import { getCustomerGroupCount } from '../queries/customer-groups.js';
import { getDeletedEntityCount } from '../queries/deleted-entity-count.js';
@@ -16,12 +16,13 @@ export async function handle(_req, res, { url }) {
const compositeProductCursor = Number(url.searchParams.get('lastChangedCompositeProduct')) || 0;
const deletedEntityCursor = Number(url.searchParams.get('lastChangedDeletedEntity')) || 0;
const [productCount, categoryCount, customerGroupCount, compositeProductCount, deletedEntityCount] = await Promise.all([
const [productCount, categoryCount, customerGroupCount, compositeProductCount, deletedEntityCount, maxOrderIdCount] = await Promise.all([
getProductCount({ cursor: productCursor }),
getCategoryCount({ cursor: categoryCursor }),
getCustomerGroupCount({ cursor: customerGroupCursor }),
getCompositeProductCount({ cursor: compositeProductCursor }),
getDeletedEntityCount({ cursor: deletedEntityCursor }),
getMaxOrderIdCount(),
]);
return sendJson(res, 200, {
@@ -34,6 +35,6 @@ export async function handle(_req, res, { url }) {
configurationGroup_count: '0',
configurationItem_count: '0',
deletedEntity_count: String(deletedEntityCount),
max_orderId_count: getMaxExternalId(),
max_orderId_count: String(maxOrderIdCount),
});
}

View File

@@ -43,12 +43,22 @@ export async function handle(req, res) {
try {
const created = await createOrder(order);
logger.success(`order ${created.orderNumber} (kAuftrag=${created.orderId}) created for externalId=${externalOrderId}`);
results.push({
status: 'OK',
externalOrderId,
message: '',
});
if (created.alreadyExists) {
const msg = `order already mapped, skipped save (kAuftrag=${created.orderId}, ${created.orderNumber})`;
logger.error(`order externalId=${externalOrderId} skipped: ${msg}`);
results.push({
status: 'ERROR',
externalOrderId,
message: msg,
});
} else {
logger.success(`order ${created.orderNumber} (kAuftrag=${created.orderId}) created for externalId=${externalOrderId}`);
results.push({
status: 'OK',
externalOrderId,
message: '',
});
}
} catch (err) {
logger.error(`order externalId=${externalOrderId} failed: ${err.message}`);
results.push({
@@ -59,5 +69,7 @@ export async function handle(req, res) {
}
}
return sendJson(res, 200, results);
const failed = results.filter((r) => r.status === 'ERROR').length;
const httpStatus = failed > 0 ? 500 : 200;
return sendJson(res, httpStatus, results);
}

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) {
const responseBody = JSON.stringify(body);
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(responseBody),
...CORS_HEADERS,
});
res.end(responseBody);
}
@@ -26,10 +34,19 @@ export function sendBinary(res, statusCode, buffer, contentType) {
res.writeHead(statusCode, {
'Content-Type': contentType,
'Content-Length': buffer.length,
...CORS_HEADERS,
});
res.end(buffer);
}
export function sendCorsPreflight(res) {
res.writeHead(204, {
'Content-Length': 0,
...CORS_HEADERS,
});
res.end();
}
export function normalizePath(pathname) {
return pathname.replace(/^\/api(?=\/v1\/)/, '');
}

View File

@@ -1,21 +1,12 @@
import { endpoints } from './endpoints/index.js';
import { normalizePath, readBody, sendJson } from './http.js';
import { normalizePath, readBody, sendCorsPreflight, sendJson } from './http.js';
function buildConfig(config = {}) {
return {
authToken: config.authToken || process.env.AUTH_TOKEN || 'df40ad2067954646abb0499548a52241',
certificateFingerprint:
config.certificateFingerprint ||
process.env.CERTIFICATE_FINGERPRINT ||
'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',
certificateFingerprint: config.certificateFingerprint || '',
certificateSerialNumber: config.certificateSerialNumber || '',
serverFingerprint: config.serverFingerprint || '',
mandantId: config.mandantId || process.env.MANDANT_ID || '1',
mandantName: config.mandantName || process.env.MANDANT_NAME || 'eB-Standard',
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]));
async function handle(req, res) {
if (req.method === 'OPTIONS') {
return sendCorsPreflight(res);
}
const url = new URL(req.url, 'https://localhost');
const pathname = normalizePath(url.pathname);
const routeKey = `${req.method} ${pathname}`;

View File

@@ -7,20 +7,13 @@ fs.mkdirSync(path.dirname(ORDER_LOG_FILE), { recursive: true });
const stream = fs.createWriteStream(ORDER_LOG_FILE, { flags: 'a' });
let orderSequence = 0;
let maxExternalId = 0;
export function logOrder(order) {
orderSequence += 1;
maxExternalId += 1;
const externalId = order?.externalId ?? '';
const line = `${new Date().toISOString()} #${orderSequence} externalId=${maxExternalId} ${JSON.stringify(order)}\n`;
const line = `${new Date().toISOString()} #${orderSequence} externalId=${externalId} ${JSON.stringify(order)}\n`;
stream.write(line);
return maxExternalId;
}
export function getMaxExternalId() {
return String(maxExternalId);
}
export function closeOrderLog() {

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoCategoryCount } from '../demo/store.js';
import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
@@ -12,6 +14,10 @@ WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree)
`;
export async function getCategoryCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
if (isDemoMode()) {
return getDemoCategoryCount({ cursor });
}
const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor)
.query(CATEGORY_COUNT_SQL);

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoCategoryList } from '../demo/store.js';
import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
@@ -26,6 +28,10 @@ ORDER BY lastChanged ASC;
`;
export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId = getRootCategoryId() } = {}) {
if (isDemoMode()) {
return getDemoCategoryList({ cursor, limit });
}
const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor)
.input('limit', sql.Int, limit)

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoCompositeProductCount } from '../demo/store.js';
import { getPool } from '../db.js';
import { getActiveShopId } from '../shop.js';
@@ -16,6 +18,10 @@ WHERE a.kStueckliste <> 0
`;
export async function getCompositeProductCount({ cursor = 0 } = {}) {
if (isDemoMode()) {
return getDemoCompositeProductCount({ cursor });
}
const result = await getPool()
.request()
.input('cursor', sql.BigInt, cursor)

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoCompositeProductList } from '../demo/store.js';
import { getPool } from '../db.js';
import { getActiveShopId } from '../shop.js';
@@ -21,6 +23,10 @@ ORDER BY lastChanged ASC;
`;
export async function getCompositeProductList({ cursor = 0, limit = 100 } = {}) {
if (isDemoMode()) {
return getDemoCompositeProductList({ cursor, limit });
}
const result = await getPool()
.request()
.input('cursor', sql.BigInt, cursor)

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { createDemoOrder } from '../demo/store.js';
import { getPool } from '../db.js';
import { getActiveShopId, getActiveShopSubshopId } from '../shop.js';
import { deliverOrder } from './delivery/index.js';
@@ -133,32 +135,134 @@ async function allocatePk(transaction, tableName) {
return pk;
}
const VERSANDPOSITION_TYPE = 2;
const ZAHLUNG_TYPE_ZAHLUNG = 10;
const NIST_READONLY_NICHT_AENDERBAR = 2;
const NIST_EXTERNE_RECHNUNG_KEINE = 2;
function parseImportSetting(order) {
return toNumber(order.settings?.importSetting, 0);
}
function parseInvoiceSetting(order) {
return toNumber(order.settings?.invoiceSetting, 0);
}
/** PosOrderSettingsService: importSetting=0 → NichtAenderbar. */
function resolveNIstReadOnly(order) {
return parseImportSetting(order) === 0 ? NIST_READONLY_NICHT_AENDERBAR : 0;
}
/**
* PosOrderSettingsService: importSetting=0 → KeineRechnungKeineAusgabe;
* importSetting 25 or invoiceSetting Create flag → Wawi invoice.
*/
function resolveNIstExterneRechnung(order) {
const importSetting = parseImportSetting(order);
const invoiceSetting = parseInvoiceSetting(order);
if (importSetting >= 2 && importSetting <= 5) return 0;
if (invoiceSetting & 1) return 0;
if (importSetting === 0) return NIST_EXTERNE_RECHNUNG_KEINE;
return 0;
}
function isVersandposition(item) {
return toNumber(item.type, 0) === VERSANDPOSITION_TYPE;
}
function hasVersandposition(items) {
return items.some(isVersandposition);
}
function hasNonReturnSaleItems(items) {
return items.some((item) => toNumber(item.isReturn, 0) === 0);
}
function shouldInjectSelbstabholerShipping(items) {
if (hasVersandposition(items)) return false;
return hasNonReturnSaleItems(items);
}
async function lookupVersandArt(transaction, shippingName) {
const name = String(shippingName ?? '').trim() || 'Selbstabholer';
const tryLookup = async (cName) => {
const result = await new sql.Request(transaction)
.input('cName', sql.NVarChar, cName)
.query(`
SELECT TOP 1 kVersandArt, cName, fPrice, fMwSt
FROM dbo.tVersandArt
WHERE cName = @cName
`);
return result.recordset[0] ?? null;
};
let row = await tryLookup(name);
if (!row && name !== 'Selbstabholer') {
row = await tryLookup('Selbstabholer');
}
return row;
}
function syntheticShippingItem(versandArt) {
const vat = toNumber(versandArt.fMwSt, 19);
const gross = toNumber(versandArt.fPrice, 0);
const net = gross / (1 + vat / 100);
return {
type: String(VERSANDPOSITION_TYPE),
quantity: '1',
name: versandArt.cName,
priceGross: String(gross),
priceNet: String(net),
vat: String(vat),
isReturn: '0',
};
}
/**
* Resolves a Zahlungsart by name; creates it on the fly (including its PK
* from dbo.tpk) when it does not exist yet.
*/
async function resolveZahlungsart(transaction, name, cache) {
const key = String(name || 'Bar');
const cached = cache.get(key.toLowerCase());
const lookupName = String(name || 'Bar');
const cacheKey = lookupName.toLowerCase();
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
const existing = await new sql.Request(transaction)
.input('cName', sql.NVarChar, key)
let row = null;
const exact = await new sql.Request(transaction)
.input('cName', sql.NVarChar, lookupName)
.query('SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE cName = @cName');
row = exact.recordset[0] ?? null;
if (!row) {
const lang = await new sql.Request(transaction)
.input('cName', sql.NVarChar, lookupName)
.query(`
SELECT TOP 1 z.kZahlungsart, z.cName
FROM dbo.tZahlungsArtSprache zs
INNER JOIN dbo.tZahlungsart z ON z.kZahlungsart = zs.kZahlungsart
WHERE zs.cName = @cName
`);
row = lang.recordset[0] ?? null;
}
if (!row) {
const ci = await new sql.Request(transaction)
.input('cName', sql.NVarChar, lookupName)
.query('SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE UPPER(cName) = UPPER(@cName)');
row = ci.recordset[0] ?? null;
}
let zahlungsart;
if (existing.recordset[0]) {
zahlungsart = {
kZahlungsart: existing.recordset[0].kZahlungsart,
cName: existing.recordset[0].cName,
};
if (row) {
zahlungsart = { kZahlungsart: row.kZahlungsart, cName: row.cName };
} else {
const kZahlungsart = await allocatePk(transaction, 'tZahlungsart');
await new sql.Request(transaction)
.input('kZahlungsart', sql.Int, kZahlungsart)
.input('cName', sql.NVarChar, key)
.input('cName', sql.NVarChar, lookupName)
.query(`
INSERT INTO dbo.tZahlungsart
(kZahlungsart, cName, cPrtString, nLastschrift, cPrtStringVor, cPaymentOption, cKonto,
@@ -166,10 +270,10 @@ async function resolveZahlungsart(transaction, name, cache) {
nMatchingOptionen, nIstStandard, nAktiv)
VALUES (@kZahlungsart, @cName, '', 0, '', '', '', 0, 0, 0, 0, 0, 0, 0, 1)
`);
zahlungsart = { kZahlungsart, cName: key };
zahlungsart = { kZahlungsart, cName: lookupName };
}
cache.set(key.toLowerCase(), zahlungsart);
cache.set(cacheKey, zahlungsart);
return zahlungsart;
}
@@ -182,7 +286,7 @@ async function nextCustomerNumber(transaction) {
* the search tables). Columns of the table type are named explicitly because
* their order differs between JTL versions.
*/
async function createCustomer(transaction, { customerNumber, address, isPosCustomer, defaults }) {
async function createCustomer(transaction, { customerNumber, address, defaults }) {
const a = address || {};
const iso = (a.countryIso || 'DE').toUpperCase();
const kKundengruppe = Number(a.customerGroupId) || defaults.kKundengruppe;
@@ -209,13 +313,13 @@ async function createCustomer(transaction, { customerNumber, address, isPosCusto
.input('kSprache', sql.Int, config.kSprache)
.input('cISO', sql.NVarChar, iso)
.input('cBundesland', sql.NVarChar, a.state || '')
.input('cHerkunft', sql.NVarChar, isPosCustomer ? 'Kasse' : 'POS-API')
.input('cKassenKunde', sql.Char(1), isPosCustomer ? 'Y' : 'N')
.input('cHerkunft', sql.NVarChar, 'Kasse')
.input('cKassenKunde', sql.Char(1), 'Y')
.input('nDebitorennr', sql.Int, Number(a.debtorNumber) || 0)
.query(`
DECLARE @returnValue INT;
DECLARE @p1 dbo.TYPE_spkundeInsert;
INSERT INTO @p1
DECLARE @kunde_daten dbo.TYPE_spkundeInsert;
INSERT INTO @kunde_daten
(kInetKunde, kKundenKategorie, cKundenNr, cFirma, cAnrede, cTitel, cVorname, cName,
cStrasse, cPLZ, cOrt, cLand, cTel, cFax, cEMail, dErstellt, cMobil, fRabatt, cUSTID, cNewsletter,
cZusatz, cEbayName, kBuyer, cAdressZusatz, cGeburtstag, cWWW, cSperre, cPostID, kKundenGruppe,
@@ -229,7 +333,7 @@ async function createCustomer(transaction, { customerNumber, address, isPosCusto
0, @kSprache, @cISO, @cBundesland, @cHerkunft, @cKassenKunde, N'', 0,
@nDebitorennr, N'', 0, 0, 0, 0, 0,
NULL, 0, 0, 0);
EXEC @returnValue = Kunde.spKundeInsert @daten = @p1;
EXEC @returnValue = Kunde.spKundeInsert @daten = @kunde_daten;
SELECT @returnValue AS kKunde;
`);
@@ -240,46 +344,59 @@ async function createCustomer(transaction, { customerNumber, address, isPosCusto
return { kKunde, kKundengruppe };
}
function isWalkInOrder(order) {
const customerNumber = String(order.customerNumber || '').trim();
if (!customerNumber || customerNumber === '0') return true;
const billing = order.billingAddress || {};
return (billing.lastName || '') === 'Laufkunde' && !(billing.firstName || '').trim();
}
function resolveAuftragCKundenNr(order) {
return isWalkInOrder(order) ? '0' : String(order.customerNumber || '');
}
async function lookupKassenkunde(transaction, defaults) {
const result = await new sql.Request(transaction).query(
"SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKassenKunde = 'Y' ORDER BY kKunde"
);
if (result.recordset[0]) {
return {
kKunde: result.recordset[0].kKunde,
kKundengruppe: result.recordset[0].kKundenGruppe || defaults.kKundengruppe,
};
}
return null;
}
/**
* Resolves the customer by cKundenNr; creates the Kunde on the fly when it
* does not exist. Orders without a customer number use (or create) the
* Kassenkunde (walk-in customer).
* does not exist. Walk-in orders use (or create) the Kassenkunde.
*/
async function resolveCustomer(transaction, order, defaults) {
const customerNumber = String(order.customerNumber || '').trim();
if (customerNumber) {
const result = await new sql.Request(transaction)
.input('cKundenNr', sql.NVarChar, customerNumber)
.query('SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKundenNr = @cKundenNr');
if (result.recordset[0]) {
return {
kKunde: result.recordset[0].kKunde,
kKundengruppe: result.recordset[0].kKundenGruppe || defaults.kKundengruppe,
};
}
if (isWalkInOrder(order)) {
const kassenKunde = await lookupKassenkunde(transaction, defaults);
if (kassenKunde) return kassenKunde;
return createCustomer(transaction, {
customerNumber,
customerNumber: await nextCustomerNumber(transaction),
address: order.billingAddress,
isPosCustomer: false,
defaults,
});
}
const kassenKunde = await new sql.Request(transaction).query(
"SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKassenKunde = 'Y' ORDER BY kKunde"
);
if (kassenKunde.recordset[0]) {
const customerNumber = String(order.customerNumber || '').trim();
const result = await new sql.Request(transaction)
.input('cKundenNr', sql.NVarChar, customerNumber)
.query('SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKundenNr = @cKundenNr');
if (result.recordset[0]) {
return {
kKunde: kassenKunde.recordset[0].kKunde,
kKundengruppe: kassenKunde.recordset[0].kKundenGruppe || defaults.kKundengruppe,
kKunde: result.recordset[0].kKunde,
kKundengruppe: result.recordset[0].kKundenGruppe || defaults.kKundengruppe,
};
}
return createCustomer(transaction, {
customerNumber: await nextCustomerNumber(transaction),
customerNumber,
address: order.billingAddress,
isPosCustomer: true,
defaults,
});
}
@@ -328,15 +445,19 @@ async function insertOrderItem(transaction, kAuftrag, item) {
const discount = toNumber(item.discountPercent, 0);
const kSteuerklasse = steuerklasseForVat(vat);
const sku = String(item.sku || '').trim();
const positionType = isVersandposition(item) ? VERSANDPOSITION_TYPE : toNumber(item.type, 0);
let kArtikel = null;
if (sku) {
if (sku && positionType !== VERSANDPOSITION_TYPE) {
const result = await new sql.Request(transaction)
.input('cArtNr', sql.NVarChar, sku)
.query('SELECT TOP 1 kArtikel FROM dbo.tArtikel WHERE cArtNr = @cArtNr');
kArtikel = result.recordset[0]?.kArtikel ?? null;
}
const nType = positionType === VERSANDPOSITION_TYPE ? VERSANDPOSITION_TYPE : (kArtikel ? 1 : 0);
const nReserviert = nType === VERSANDPOSITION_TYPE ? 0 : 1;
const result = await new sql.Request(transaction)
.input('kArtikel', sql.Int, kArtikel)
.input('kAuftrag', sql.Int, kAuftrag)
@@ -347,7 +468,8 @@ async function insertOrderItem(transaction, kAuftrag, item) {
.input('fVkNetto', sql.Float, priceNet)
.input('fMwSt', sql.Float, vat)
.input('kSteuerklasse', sql.Int, kSteuerklasse)
.input('nType', sql.Int, kArtikel ? 1 : 0)
.input('nType', sql.Int, nType)
.input('nReserviert', sql.Int, nReserviert)
.input('cEinheit', sql.NVarChar, item.unit || '')
.input('fRabatt', sql.Float, discount)
.query(`
@@ -356,7 +478,7 @@ async function insertOrderItem(transaction, kAuftrag, item) {
(kArtikel, kAuftrag, cArtNr, nReserviert, cName, cHinweis, fAnzahl, fVkNetto, fMwSt,
cNameStandard, kSteuerklasse, nType, cEinheit, fFaktor, kSteuerschluessel, fRabatt)
OUTPUT inserted.kAuftragPosition INTO @t
VALUES (@kArtikel, @kAuftrag, @cArtNr, 1, @cName, @cHinweis, @fAnzahl, @fVkNetto, @fMwSt,
VALUES (@kArtikel, @kAuftrag, @cArtNr, @nReserviert, @cName, @cHinweis, @fAnzahl, @fVkNetto, @fMwSt,
@cName, @kSteuerklasse, @nType, @cEinheit, 1.0, 3, @fRabatt);
SELECT kAuftragPosition FROM @t;
`);
@@ -364,15 +486,62 @@ async function insertOrderItem(transaction, kAuftrag, item) {
}
/**
* Links a JTL-Wawi order (and its positions) back to the originating
* POS order/position ids via Pos.tAuftragMapping / Pos.tAuftragPositionMapping,
* so the POS system can recognise orders it already pushed into JTL-Wawi.
* JTL PosOrderCreationService.CheckIfOrderExists — skip re-import when the POS
* order id is already mapped to a Wawi order for this subshop with the same
* external receipt number.
*/
async function insertPosOrderMapping(transaction, kAuftrag, kPosAuftrag) {
function externalOrderNumbersMatch(mapped, incoming) {
if (!incoming) return true;
return String(mapped || '').toLowerCase() === String(incoming).toLowerCase();
}
async function findExistingPosOrderMapping(kPosAuftrag, externalOrderNumber) {
const kShopSubShop = getActiveShopSubshopId();
if (!Number.isInteger(kPosAuftrag) || !kShopSubShop) {
return null;
}
const result = await getPool()
.request()
.input('kPosAuftrag', sql.Int, kPosAuftrag)
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(`
SELECT TOP 1 m.kAuftrag, a.cAuftragsNr, ISNULL(a.cExterneAuftragsnummer, '') AS cExterneAuftragsnummer
FROM Pos.tAuftragMapping m
LEFT JOIN Verkauf.tAuftrag a ON a.kAuftrag = m.kAuftrag
WHERE m.kPosAuftrag = @kPosAuftrag
AND m.kShopSubShop = @kShopSubShop
AND m.kAuftrag IS NOT NULL
ORDER BY m.kAuftrag DESC
`);
const row = result.recordset[0];
if (!row?.kAuftrag) {
return null;
}
if (!externalOrderNumbersMatch(row.cExterneAuftragsnummer, externalOrderNumber)) {
return null;
}
return row;
}
/**
* Links a JTL-Wawi order back to the originating POS order id.
* MERGE matches JTL CreateOrUpdate outcome for the final committed row.
*/
async function upsertPosOrderMapping(transaction, kAuftrag, kPosAuftrag) {
const kShopSubShop = getActiveShopSubshopId();
if (!Number.isInteger(kPosAuftrag) || !kShopSubShop) {
return;
}
await new sql.Request(transaction)
.input('kPosAuftrag', sql.Int, kPosAuftrag)
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(`
DELETE FROM Pos.tAuftragMapping
WHERE kPosAuftrag = @kPosAuftrag
AND kShopSubShop = @kShopSubShop
`);
await new sql.Request(transaction)
.input('kAuftrag', sql.Int, kAuftrag)
.input('kPosAuftrag', sql.Int, kPosAuftrag)
@@ -418,11 +587,43 @@ function isOrderDelivered(order) {
return deliver === true || String(deliver) === '1' || String(deliver).toLowerCase() === 'true';
}
async function recalculateAuftragEckdaten(transaction, kAuftrag) {
await new sql.Request(transaction).input('kAuftrag', sql.Int, kAuftrag).query(`
DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;
INSERT INTO @eckdaten_calc VALUES (@kAuftrag);
EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc;
`);
}
async function getOffenerAuftragswert(transaction, kAuftrag) {
const result = await new sql.Request(transaction)
.input('kAuftrag', sql.Int, kAuftrag)
.query(`
SELECT ROUND(tAuftragEckdaten.fOffenerWertOhneStorno, 2) AS fOffenerAuftragswert
FROM Verkauf.tAuftrag
LEFT JOIN Verkauf.tAuftragEckdaten ON tAuftragEckdaten.kAuftrag = tAuftrag.kAuftrag
WHERE tAuftrag.kAuftrag = @kAuftrag
`);
const value = result.recordset[0]?.fOffenerAuftragswert;
return value == null ? null : toNumber(value, 0);
}
function isNewPayment(payment) {
const paymentId = toNumber(payment.paymentId, 0);
return paymentId <= 0;
}
async function insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache) {
const zahlungsart = await resolveZahlungsart(transaction, payment.paymentMethodName || order.paymentMethodName, zahlungsartCache);
const kZahlung = await allocatePk(transaction, 'tZahlung');
await new sql.Request(transaction)
await recalculateAuftragEckdaten(transaction, kAuftrag);
const fOffenerWert = await getOffenerAuftragswert(transaction, kAuftrag);
if (fOffenerWert == null) {
throw new Error(`no open order amount for kAuftrag=${kAuftrag}`);
}
const result = await new sql.Request(transaction)
.input('kZahlung', sql.Int, kZahlung)
.input('cName', sql.NVarChar, zahlungsart.cName)
.input('dDatum', sql.DateTime, orderDate)
@@ -431,16 +632,46 @@ async function insertPayment(transaction, kAuftrag, payment, order, orderDate, z
.input('kBenutzer', sql.Int, config.kBenutzer)
.input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart)
.input('cExternalTransactionId', sql.NVarChar, order.externalOrderNumber || '')
.input('fOffenerWert', sql.Float, fOffenerWert)
.query(`
INSERT INTO dbo.tZahlung
(kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart,
nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung)
VALUES (@kZahlung, @cName, @dDatum, @fBetrag, @kBestellung, @kBenutzer, 0, '', @kZahlungsart,
0, @cExternalTransactionId, 0, 0, '', 0)
IF EXISTS (
SELECT 1 FROM Verkauf.tAuftragEckdaten
WHERE kAuftrag = @kBestellung
AND ROUND(fOffenerWertOhneStorno, 2) = ROUND(@fOffenerWert, 2)
)
BEGIN
INSERT INTO dbo.tZahlung
(kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart,
nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung)
VALUES (@kZahlung, @cName, @dDatum, @fBetrag, @kBestellung, @kBenutzer, 0, '', @kZahlungsart,
0, @cExternalTransactionId, 0, ${ZAHLUNG_TYPE_ZAHLUNG}, '', 0);
END
SELECT @@ROWCOUNT AS inserted;
`);
if (!result.recordset[0]?.inserted) {
throw new Error(`payment insert skipped: open amount changed for kAuftrag=${kAuftrag}`);
}
}
export async function createOrder(order) {
if (isDemoMode()) {
return createDemoOrder(order);
}
const kPosAuftrag = Number.parseInt(order.externalId, 10);
const externalOrderNumber = order.externalOrderNumber || '';
if (Number.isInteger(kPosAuftrag) && kPosAuftrag > 0) {
const existing = await findExistingPosOrderMapping(kPosAuftrag, externalOrderNumber);
if (existing) {
return {
orderId: String(existing.kAuftrag),
orderNumber: existing.cAuftragsNr || '',
alreadyExists: true,
};
}
}
const defaults = await getDefaults();
const transaction = new sql.Transaction(getPool());
await transaction.begin(sql.ISOLATION_LEVEL.READ_COMMITTED);
@@ -456,6 +687,16 @@ export async function createOrder(order) {
const zahlungsart = await resolveZahlungsart(transaction, order.paymentMethodName, zahlungsartCache);
const cAuftragsNr = await nextOrderNumber(transaction, orderDate);
const orderItems = [...(order.orderItems || [])];
const versandArt = await lookupVersandArt(transaction, order.shippingName);
let kVersandArt = versandArt?.kVersandArt ?? defaults.kVersandArt;
if (shouldInjectSelbstabholerShipping(orderItems) && versandArt) {
orderItems.push(syntheticShippingItem(versandArt));
}
const nIstReadOnly = resolveNIstReadOnly(order);
const nIstExterneRechnung = resolveNIstExterneRechnung(order);
const resultAuftrag = await new sql.Request(transaction)
.input('cAuftragsNr', sql.NVarChar, cAuftragsNr)
.input('dErstellt', sql.DateTime, orderDate)
@@ -466,62 +707,67 @@ export async function createOrder(order) {
.input('cWaehrung', sql.NVarChar, order.currencyIso || 'EUR')
.input('kPlattform', sql.Int, defaults.kPlattform)
.input('kShop', sql.Int, getActiveShopId() || null)
.input('cKundenNr', sql.NVarChar, String(order.customerNumber || ''))
.input('cKundenNr', sql.NVarChar, resolveAuftragCKundenNr(order))
.input('cVersandlandISO', sql.NVarChar, (order.shippingAddress?.countryIso || 'DE').toUpperCase())
.input('kVersandArt', sql.Int, defaults.kVersandArt)
.input('kVersandArt', sql.Int, kVersandArt)
.input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart)
.input('kKundengruppe', sql.Int, kKundengruppe)
.input('cExterneAuftragsnummer', sql.NVarChar, order.externalOrderNumber || '')
.input('nIstExterneRechnung', sql.Int, nIstExterneRechnung)
.input('nIstReadOnly', sql.Int, nIstReadOnly)
.query(`
DECLARE @t TABLE ([kAuftrag] INT);
INSERT INTO Verkauf.tAuftrag
(cAuftragsNr, dErstellt, nKomplettAusgeliefert, kBenutzer, kKunde, kBenutzerErstellt, nType, fFaktor,
kFirmaHistory, kSprache, cVersandlandWaehrung, fVersandlandWaehrungFaktor, fFinanzierungskosten,
cWaehrung, kPlattform, kShop, cKundenNr, cVersandlandISO, kVersandArt, kZahlungsart, kKundengruppe,
cExterneAuftragsnummer)
cExterneAuftragsnummer, nIstExterneRechnung, cInet, nIstReadOnly, kShopauftrag, nLieferPrioritaet)
OUTPUT inserted.kAuftrag INTO @t
VALUES (@cAuftragsNr, @dErstellt, 0, @kBenutzer, @kKunde, @kBenutzer, 1, 1.0,
@kFirmaHistory, @kSprache, @cWaehrung, 1.0, 0.0,
@cWaehrung, @kPlattform, @kShop, @cKundenNr, @cVersandlandISO, @kVersandArt, @kZahlungsart, @kKundengruppe,
@cExterneAuftragsnummer);
@cExterneAuftragsnummer, @nIstExterneRechnung, 'Y', @nIstReadOnly, 0, 10);
SELECT kAuftrag FROM @t;
`);
const kAuftrag = resultAuftrag.recordset[0].kAuftrag;
const kPosAuftrag = Number.parseInt(order.externalId, 10);
await insertPosOrderMapping(transaction, kAuftrag, kPosAuftrag);
await upsertPosOrderMapping(transaction, kAuftrag, kPosAuftrag);
await insertOrderAddress(transaction, kAuftrag, kKunde, order.shippingAddress, 0);
await insertOrderAddress(transaction, kAuftrag, kKunde, order.billingAddress, 1);
const deliveredItems = [];
for (const item of order.orderItems || []) {
for (const item of orderItems) {
const kAuftragPosition = await insertOrderItem(transaction, kAuftrag, item);
const kPosAuftragPosition = Number.parseInt(item.externalId, 10);
await insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition);
if (kAuftragPosition != null) {
if (Number.isInteger(kPosAuftragPosition)) {
await insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition);
}
if (kAuftragPosition != null && !isVersandposition(item)) {
deliveredItems.push({ kAuftragPosition, quantity: toNumber(item.quantity, 1) });
}
}
for (const payment of order.payments || []) {
if (!isNewPayment(payment)) continue;
await insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache);
}
if (isOrderDelivered(order)) {
await deliverOrder(transaction, config.kBenutzer, kAuftrag, defaults.kVersandArt, deliveredItems);
await deliverOrder(transaction, config.kBenutzer, kAuftrag, kVersandArt, deliveredItems);
}
await new sql.Request(transaction).input('kAuftrag', sql.Int, kAuftrag).query(`
DECLARE @p1 Verkauf.TYPE_spAuftragEckdatenBerechnen;
INSERT INTO @p1 VALUES (@kAuftrag);
EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @p1;
DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;
INSERT INTO @eckdaten_calc VALUES (@kAuftrag);
EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc;
`);
await transaction.commit();
return { orderId: String(kAuftrag), orderNumber: cAuftragsNr };
return { orderId: String(kAuftrag), orderNumber: cAuftragsNr, alreadyExists: false };
} catch (err) {
try {
await transaction.rollback();

View File

@@ -1,4 +1,10 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import {
getDemoCustomerGroupCount,
getDemoCustomerGroupIds,
getDemoCustomerGroupList,
} from '../demo/store.js';
import { getPool } from '../db.js';
const CUSTOMER_GROUP_IDS_SQL = `
@@ -26,11 +32,19 @@ WHERE CONVERT(BIGINT, bRowversion) > @cursor;
`;
export async function getCustomerGroupIds() {
if (isDemoMode()) {
return getDemoCustomerGroupIds();
}
const result = await getPool().request().query(CUSTOMER_GROUP_IDS_SQL);
return result.recordset.map((row) => row.kKundenGruppe);
}
export async function getCustomerGroupList({ cursor = 0 } = {}) {
if (isDemoMode()) {
return getDemoCustomerGroupList({ cursor });
}
const result = await getPool().request().input('cursor', sql.BigInt, cursor).query(CUSTOMER_GROUP_LIST_SQL);
return result.recordset.map((row) => ({
@@ -43,6 +57,10 @@ export async function getCustomerGroupList({ cursor = 0 } = {}) {
}
export async function getCustomerGroupCount({ cursor = 0 } = {}) {
if (isDemoMode()) {
return getDemoCustomerGroupCount({ cursor });
}
const result = await getPool().request().input('cursor', sql.BigInt, cursor).query(CUSTOMER_GROUP_COUNT_SQL);
return result.recordset[0]?.CustomerGroupCount ?? 0;
}

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoDeletedEntityCount } from '../demo/store.js';
import { getPool } from '../db.js';
const DELETED_ENTITY_COUNT_SQL = `
@@ -8,6 +10,10 @@ WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > @cursor;
`;
export async function getDeletedEntityCount({ cursor = 0 } = {}) {
if (isDemoMode()) {
return getDemoDeletedEntityCount({ cursor });
}
const result = await getPool()
.request()
.input('cursor', sql.BigInt, cursor)

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoDeletedEntityList } from '../demo/store.js';
import { getPool } from '../db.js';
const DELETED_ENTITY_LIST_SQL = `
@@ -12,6 +14,10 @@ ORDER BY lastChanged ASC;
`;
export async function getDeletedEntityList({ cursor = 0, limit = 600 } = {}) {
if (isDemoMode()) {
return getDemoDeletedEntityList({ cursor, limit });
}
const result = await getPool()
.request()
.input('cursor', sql.BigInt, cursor)

View File

@@ -1,6 +1,7 @@
import { openSession, discardSession, closeSession } from './session.js';
import { getOutgoingWarehouse } from './warehouse.js';
import { getOutgoingWarehouse, getWarehousePlace } from './warehouse.js';
import { reservePositions } from './reserve.js';
import { bookStockShortfallsAndRereserve } from './stock-shortage.js';
import { commitPicklists } from './commit.js';
import { deliverPicklists } from './deliver.js';
@@ -23,10 +24,19 @@ export async function deliverOrder(transaction, kBenutzer, kAuftrag, kVersandArt
}
const kWarenLager = await getOutgoingWarehouse(transaction);
const kWarenLagerPlatz = await getWarehousePlace(transaction, kWarenLager);
const kSessionId = await openSession(transaction, kBenutzer);
try {
await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, deliveredItems);
await bookStockShortfallsAndRereserve(
transaction,
kBenutzer,
kSessionId,
kWarenLager,
kWarenLagerPlatz,
deliveredItems,
);
await commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag);
await deliverPicklists(transaction, kBenutzer, kSessionId, kAuftrag, kVersandArt);
} finally {

View File

@@ -0,0 +1,112 @@
import sql from 'mssql';
import { reservePositions } from './reserve.js';
const POS_SHORTAGE_COMMENT = 'Korrekturbuchung erstellt durch POS-Abgleich';
const BUCHUNGSART_WARENEINGANG = 10;
async function getReservedQuantity(transaction, kSessionId, kAuftragPosition) {
const result = await new sql.Request(transaction)
.input('kSessionId', sql.Int, kSessionId)
.input('kBestellPos', sql.Int, kAuftragPosition)
.query(`
SELECT ISNULL(SUM(pp.fAnzahl), 0) AS reserved
FROM dbo.tPicklistePos pp
INNER JOIN dbo.tPickliste p ON p.kPickliste = pp.kPickliste
WHERE p.kSessionId = @kSessionId
AND pp.kBestellPos = @kBestellPos
`);
return Number(result.recordset[0]?.reserved ?? 0);
}
async function getPositionArtikel(transaction, kAuftragPosition) {
const result = await new sql.Request(transaction)
.input('kAuftragPosition', sql.Int, kAuftragPosition)
.query(`
SELECT kArtikel
FROM Verkauf.tAuftragPosition
WHERE kAuftragPosition = @kAuftragPosition
`);
return result.recordset[0]?.kArtikel ?? 0;
}
async function bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtikel, fehlmenge) {
await new sql.Request(transaction)
.input('kArtikel', sql.Int, kArtikel)
.input('kWarenLagerPlatz', sql.Int, kWarenLagerPlatz)
.input('kBenutzer', sql.Int, kBenutzer)
.input('fAnzahl', sql.Float, fehlmenge)
.input('cKommentar', sql.NVarChar, POS_SHORTAGE_COMMENT)
.input('kBuchungsart', sql.Int, BUCHUNGSART_WARENEINGANG)
.query(`
DECLARE @kWarenlagerEingang INT;
EXEC dbo.spWarenlagerEingangSchreiben
@kArtikel = @kArtikel,
@kWarenLagerPlatz = @kWarenLagerPlatz,
@kLieferantenBestellungPos = 0,
@kBenutzer = @kBenutzer,
@fAnzahl = @fAnzahl,
@fEkEinzel = 0,
@cLieferscheinNr = '',
@cChargenNr = NULL,
@dMHD = NULL,
@dGeliefertAm = NULL,
@cKommentar = @cKommentar,
@kGutschriftPos = 0,
@kLHM = 0,
@kSessionId = 0,
@kBuchungsart = @kBuchungsart,
@kBestellPosUmlagerung = 0,
@kRMRetourePos = 0,
@nHistorieNichtSchreiben = 0,
@kWarenlagerEingang = @kWarenlagerEingang OUTPUT;
SELECT @kWarenlagerEingang AS kWarenlagerEingang;
`);
}
/**
* PosStockPositionService.FehlbestandEinbuchen — book missing stock, then re-reserve.
*/
export async function bookStockShortfallsAndRereserve(
transaction,
kBenutzer,
kSessionId,
kWarenLager,
kWarenLagerPlatz,
positions,
) {
const rereserve = [];
for (const { kAuftragPosition, quantity } of positions) {
if (!kAuftragPosition || quantity <= 0) {
continue;
}
const reserved = await getReservedQuantity(transaction, kSessionId, kAuftragPosition);
const shortage = quantity - reserved;
if (shortage <= 0.0001) {
continue;
}
const kArtikel = await getPositionArtikel(transaction, kAuftragPosition);
if (!kArtikel) {
continue;
}
await bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtikel, shortage);
rereserve.push({ kAuftragPosition, quantity: shortage });
}
if (rereserve.length) {
await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, rereserve);
}
for (const { kAuftragPosition, quantity } of positions) {
if (!kAuftragPosition || quantity <= 0) {
continue;
}
const reserved = await getReservedQuantity(transaction, kSessionId, kAuftragPosition);
if (reserved + 0.0001 < quantity) {
throw new Error(`insufficient stock after POS shortage booking for kBestellPos=${kAuftragPosition}`);
}
}
}

View File

@@ -2,6 +2,7 @@ import sql from 'mssql';
/** DB-resolved (or env-overridden) outgoing warehouse, cached after first lookup. */
let cachedWarenLager = null;
let cachedWarenLagerPlatz = null;
/**
* Resolves the local warehouse (dbo.tWarenLager.nFulfillment = 0) to book
@@ -36,3 +37,34 @@ export async function getOutgoingWarehouse(transaction) {
cachedWarenLager = row.kWarenLager;
return cachedWarenLager;
}
/**
* Default pick place for the outgoing warehouse (dbo.tWarenLagerPlatz).
*/
export async function getWarehousePlace(transaction, kWarenLager) {
if (cachedWarenLagerPlatz != null) {
return cachedWarenLagerPlatz;
}
const configured = Number(process.env.JTL_KWARENLAGERPLATZ);
if (Number.isInteger(configured) && configured > 0) {
cachedWarenLagerPlatz = configured;
return cachedWarenLagerPlatz;
}
const result = await new sql.Request(transaction)
.input('kWarenLager', sql.Int, kWarenLager)
.query(`
SELECT TOP 1 kWarenLagerPlatz
FROM dbo.tWarenLagerPlatz
WHERE kWarenLager = @kWarenLager
AND ISNULL(nGesperrt, 0) = 0
ORDER BY nPrio, kWarenLagerPlatz
`);
const row = result.recordset[0];
if (!row) {
throw new Error(`No warehouse place found for kWarenLager=${kWarenLager}; set JTL_KWARENLAGERPLATZ explicitly.`);
}
cachedWarenLagerPlatz = row.kWarenLagerPlatz;
return cachedWarenLagerPlatz;
}

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoImageByHash } from '../demo/store.js';
import { getPool } from '../db.js';
const IMAGE_BY_HASH_SQL = `
@@ -24,6 +26,10 @@ function contentTypeFor(cQuelle) {
}
export async function getImageByHash(hash, size) {
if (isDemoMode()) {
return getDemoImageByHash(hash);
}
const result = await getPool().request().input('hash', sql.NVarChar, hash).query(IMAGE_BY_HASH_SQL);
const row = result.recordset[0];

View File

@@ -0,0 +1,29 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoMaxOrderIdCount } from '../demo/store.js';
import { getPool } from '../db.js';
import { getActiveShopSubshopId } from '../shop.js';
const MAX_ORDER_ID_SQL = `
SELECT ISNULL(MAX(kPosAuftrag), 0) AS MaxOrderId
FROM Pos.tAuftragMapping
WHERE kShopSubShop = @kShopSubShop;
`;
export async function getMaxOrderIdCount() {
if (isDemoMode()) {
return getDemoMaxOrderIdCount();
}
const kShopSubShop = getActiveShopSubshopId();
if (!kShopSubShop) {
return 0;
}
const result = await getPool()
.request()
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(MAX_ORDER_ID_SQL);
return result.recordset[0]?.MaxOrderId ?? 0;
}

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoProductCount } from '../demo/store.js';
import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
@@ -22,6 +24,10 @@ WHERE a.cAktiv = 'Y'
`;
export async function getProductCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
if (isDemoMode()) {
return getDemoProductCount({ cursor });
}
const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor)
.query(PRODUCT_COUNT_SQL);

View File

@@ -1,4 +1,6 @@
import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoProductList } from '../demo/store.js';
import { getPool } from '../db.js';
import { getCustomerGroupIds } from './customer-groups.js';
import { getProductAttributes } from './product-attributes.js';
@@ -50,7 +52,8 @@ SELECT TOP (@limit)
SELECT TOP 1 pv.cVariantName
FROM Pos.vProductVariant pv
WHERE pv.kProduct = a.kArtikel
) AS variantName
) AS variantName,
a.cBarcode AS barcode
FROM dbo.tArtikel a
INNER JOIN dbo.tArtikelBeschreibung ab ON ab.kArtikel = a.kArtikel AND ab.kSprache = @languageId
LEFT JOIN TaxRates tr ON tr.kSteuerklasse = a.kSteuerklasse
@@ -92,6 +95,10 @@ function grossPrice(netPrice, taxRate) {
}
export async function getProductList({ cursor = 0, limit = 20 } = {}) {
if (isDemoMode()) {
return getDemoProductList({ cursor, limit });
}
const pool = getPool();
const [productResult, customerGroupIds] = await Promise.all([
@@ -149,6 +156,7 @@ export async function getProductList({ cursor = 0, limit = 20 } = {}) {
imghash: product.imgHash ?? null,
imgsrc: product.imgHash ?? null,
sku: product.sku,
barcode: product.barcode ?? null,
name: product.name,
tax_rate: String(Math.round(Number(product.taxRate || 0))),
price: basePrice,