Compare commits

...

32 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
seb
b7b76c9d39 cpp 2026-07-13 07:34:17 +02:00
seb
d471f2411d da 2026-07-13 03:50:13 +02:00
seb
955fe2c164 delivery seemds to work. 2026-07-13 03:40:14 +02:00
seb
03dde8eff8 u 2026-07-13 02:55:43 +02:00
seb
8d55da79b0 close 2026-07-13 02:30:04 +02:00
seb
53a7924c7f u 2026-07-12 23:19:40 +02:00
seb
cbc4b8315e u 2026-07-12 23:12:23 +02:00
seb
23c5b76027 u 2026-07-12 21:55:10 +02:00
seb
ee09434ea9 u 2026-07-12 21:46:49 +02:00
seb
7f6095013b u 2026-07-12 21:14:43 +02:00
110 changed files with 65787 additions and 245 deletions

View File

@@ -1,3 +1,6 @@
# Demo catalog (skips MSSQL; requires `npm run demo:generate` first)
DEMO_MODE=false
# HTTPS POS server # HTTPS POS server
PORT=4443 PORT=4443
AUTH_TOKEN=df40ad2067954646abb0499548a52241 AUTH_TOKEN=df40ad2067954646abb0499548a52241
@@ -5,25 +8,20 @@ PAIRING_CODE=307018
LOG_FILE=logs/requests.log LOG_FILE=logs/requests.log
ORDER_LOG_FILE=logs/orders.log ORDER_LOG_FILE=logs/orders.log
# TLS certificate metadata returned during pairing
CERTIFICATE_FINGERPRINT=BC2114CF407A42724BEEF417960F76DCBF9DE879
CERTIFICATE_SERIAL_NUMBER=00BFC8BEACDB981B165210EF111CB9D3
SERVER_FINGERPRINT=39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE
# Mandant # Mandant
MANDANT_ID=1 MANDANT_ID=1
MANDANT_NAME=eB-Standard MANDANT_NAME=eB-Standard
MANDANT_DATABASE=eazybusiness MANDANT_DATABASE=eazybusiness
ROOT_CATEGORY_ID=1 ROOT_CATEGORY_ID=1
# Category sync (tKategorieSprache / tKategoriebildPlattform lookups) # Category sync (tKategorieSprache lookups)
LANGUAGE_ID=1 LANGUAGE_ID=1
IMAGE_PLATFORM_ID=1
IMAGE_SHOP_ID=0
# Product sync (tSteuerzone.cName used to look up tax rates per tSteuerklasse) # Product sync (tSteuerzone.cName used to look up tax rates per tSteuerklasse)
TAX_ZONE_NAME=Zone-EU TAX_ZONE_NAME=Zone-EU
# Shop filter: active shop is queried from tShopSubshop at startup
# MSSQL (connection data for JTL-Wawi database) # MSSQL (connection data for JTL-Wawi database)
MSSQL_SERVER=localhost MSSQL_SERVER=localhost
MSSQL_PORT=1433 MSSQL_PORT=1433

18
.gitignore vendored
View File

@@ -1,6 +1,12 @@
node_modules/ /node_modules/
.env /.env
certs/ /certs/
logs/ /logs/
capturedDataReference /capturedDataReference
decompiledReference /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”.

666
delivery.md Normal file
View File

@@ -0,0 +1,666 @@
# Delivery investigation
## Problem
Orders created via `jtlsrv` (`AU-202607-10025`) get `nKomplettAusgeliefert = 1` and a
`dbo.tLieferschein` / `dbo.tLieferscheinPos` row, but **no** `dbo.tPickliste` /
`dbo.tPicklistePos` rows and no real warehouse stock booking. Orders created by the
real JTL-Wawi/POS stack (`AU-202607-10026`) get all of that correctly (Pickliste with
proper status progression 5 → 10 → 40, a numbered Lieferschein like
`AU-202607-10026-001`, etc).
Root cause: our code (`src/queries/create-order.js`) calls the low-level
`Versand.spLieferscheinErstellen` / `Versand.spLieferscheinPosErstellen` procedures
directly. These only insert the Lieferschein rows and call
`Verkauf.spAuftragEckdatenBerechnen` (which derives `nKomplettAusgeliefert`). They
never touch the Pickliste/warehouse-reservation subsystem, so nothing is actually
picked/booked out of stock. The real JTL code path is much deeper and goes through
the same "Auslieferung" (fulfillment) engine used by the Wawi GUI's "Auftrag
ausliefern" feature. This document records that engine in full, down to exact SQL
parameter names/types and XML schemas, gathered from:
- Decompiled C# (`ilspycmd`) of `jtlCore.dll` (obfuscated string literals, but
control flow / call graph is readable) and `jtlDatabase.dll` (**not** obfuscated —
this is where the actual ADO.NET parameter wiring lives).
- `sp_helptext` of the actual stored procedures in `eazybusiness` (SQL Server ground
truth — these fully document their own XML input schemas in comments and in the
`.nodes()`/`.value()` shredding code).
## 0. Step-by-step SQL to issue for one delivery (ready to run/adapt)
This is the literal sequence of statements to execute, in order, inside the same
transaction as the rest of order creation, to deliver one order
(`@kAuftrag`) for one user (`@kBenutzer`). Everything here is derived 1:1 from the
stored-procedure signatures and XML schemas documented in §3; treat this section as
the concrete checklist, §3 as the "why"/evidence.
Placeholders to fill in per order: `@kAuftrag`, `@kBenutzer`, `@kWarenLager` (see
§7 — outgoing warehouse, still unresolved), and one `<Bestellposition>`/`<Lager>`
pair per order position (only positions with real, stock-tracked articles need to go
through the reservation dance below at all — see §7 for free positions).
```sql
-- Step 1: open a session (mirrors jtlCore's SessionManager / dbo.tSessionId row)
DECLARE @kSessionId INT;
INSERT INTO dbo.tSessionId (cRechnername, kBenutzer, dLastAction)
VALUES ('jtlsrv', @kBenutzer, DATEADD(day, 10, GETDATE()));
SET @kSessionId = SCOPE_IDENTITY();
-- Step 2: reserve every order position against the outgoing warehouse
-- (one call per position, or batch them all into @Bestellpositionen and call
-- Auslieferung.spReserviereBestellpositionen once for the whole order — same effect)
DECLARE @Bestellpositionen XML = '
<Bestellposition><kBestellPos>{kAuftragPosition}</kBestellPos><fAnzahl>{menge}</fAnzahl></Bestellposition>
<!-- repeat one <Bestellposition> per order position -->
';
DECLARE @Laeger XML = '
<Lager><kWarenlager>{kWarenLager}</kWarenlager><nPrio>0</nPrio><kLieferant>0</kLieferant><kAnsprechpartner>0</kAnsprechpartner></Lager>
';
EXEC Auslieferung.spReserviereBestellpositionen
@Bestellpositionen = @Bestellpositionen,
@Laeger = @Laeger,
@Warenlagereingaenge = NULL,
@nOptions = 0x0102, -- ChargenVorbelegen (0x0002) | Stücklistenkorrektur (0x0100)
@kBenutzer = @kBenutzer,
@kSessionId = @kSessionId;
-- -> creates dbo.tPickliste (kSessionId = @kSessionId) + dbo.tPicklistePos rows, status 0 then 5
-- Step 3: commit the session's reservations into "real" Picklisten
DECLARE @Bestellungen XML = '<Bestellung><kBestellung>{kAuftrag}</kBestellung></Bestellung>';
DECLARE @xResultUebernehmen XML;
EXEC Auslieferung.spPicklistenUebernehmen
@Bestellungen = @Bestellungen,
@kBenutzer = @kBenutzer,
@nTeillieferung = 0, -- 0 = partial delivery forbidden (we always deliver in full)
@kSessionId = @kSessionId,
@xResult = @xResultUebernehmen OUTPUT;
-- -> bumps dbo.tPicklistePos.nStatus from <10 to 10 (or 20 for position types 18/20) for this session
-- Step 4: actually deliver — creates the numbered Lieferschein, decrements real
-- stock, marks positions delivered
--
-- IMPORTANT: @Pakete must NOT be NULL/empty if you want dbo.tVersand populated.
-- The nested Auslieferung.spPicklistenAusliefern_PaketeErzeugen builds a temp
-- table straight from @Pakete.nodes('/Paket'); if @Pakete is NULL that temp
-- table stays empty and NOT A SINGLE dbo.tVersand row gets written for ANY
-- Lieferschein in the session (not even the "no Paket given" fallback row,
-- because even that fallback branch is keyed off a per-kBestellung JOIN
-- against the temp table). So pass one <Paket> per order, at minimum with
-- <kBestellung>/<kVersandart>/<fGewicht> (fGewicht=0 is the sentinel the proc
-- itself later replaces with an article-weight-based value; the column is
-- NOT NULL so it can't be omitted).
DECLARE @Pakete XML = '<Paket><kBestellung>{kAuftrag}</kBestellung><kVersandart>{kVersandArt}</kVersandart><fGewicht>0</fGewicht></Paket>';
DECLARE @xResultAusliefern XML;
EXEC Auslieferung.spPicklistenAusliefern
@xHinweise = NULL, -- optional <Hinweis><kBestellPos>/<kBestellung></kBestellung><cHinweis>...
@Pakete = @Pakete,
@nOptions = 0x0002, -- VersandSetzen (DeliveryStep always sets this); OR 0x0001 for one Lieferschein per warehouse
@kBenutzer = @kBenutzer,
@kSessionId = @kSessionId,
@xResult = @xResultAusliefern OUTPUT;
-- -> creates dbo.tLieferschein/tLieferscheinPos (properly numbered "<AuftragsNr>-NNN"),
-- links kLieferscheinPos back onto tPicklistePos, calls dbo.spWarenlagerAusgangPicklistePos
-- (real stock decrement), bumps tPicklistePos.nStatus to 40, AND (via
-- spPicklistenAusliefern_PaketeErzeugen) inserts a dbo.tVersand row per
-- Lieferschein carrying {kVersandArt} and, since VersandSetzen is set and this
-- is a local (non-fulfillment) warehouse, dVersendet = GETDATE()
-- Step 5: recalculate order eckdaten (nKomplettAusgeliefert etc.) — already implemented today
DECLARE @p1 Verkauf.TYPE_spAuftragEckdatenBerechnen;
INSERT INTO @p1 VALUES (@kAuftrag);
EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @p1;
-- Step 6: discard anything left in this session that never got committed/delivered.
-- Safe to call unconditionally (success AND error path) — its body is just
-- `DELETE FROM dbo.tPickliste WHERE kSessionId = @kSessionId AND nStatus < 10`,
-- so it never touches already-delivered (nStatus > 10) Picklisten. On the error
-- path it also cleans up whatever Step 2 managed to reserve before the failure.
EXEC Auslieferung.spPicklistenVerwerfen @kBenutzer = @kBenutzer, @kSessionId = @kSessionId;
-- Step 7 (optional cleanup, mirrors SessionManager.Dispose()):
DELETE FROM dbo.tSessionId WHERE kSessionId = @kSessionId;
```
Notes on the XML construction above (see §3 for full schema/behavior detail):
- `{kAuftragPosition}` = `Verkauf.tAuftragPosition.kAuftragPosition` (what our code
calls `kAuftragPosition`), `{menge}` = ordered quantity, `{kWarenLager}` = the
resolved outgoing warehouse (§7, open question), `{kAuftrag}` =
`Verkauf.tAuftrag.kAuftrag`.
- `@Bestellpositionen`/`@Laeger` are XML *fragments* — SQL Server's `xml` type
happily holds multiple top-level elements, so just concatenate one
`<Bestellposition>...</Bestellposition>` per position (same for `<Lager>` if ever
passing multiple candidate warehouses).
- Steps 2-4 must run inside the *same* transaction/connection context as order
creation so `@kSessionId` and the temp Pickliste rows are visible to each other and
everything rolls back together on failure.
## 0.1 Cross-check against another (incomplete) implementation
A second, independently-written `deliverOrder()` implementation was reviewed. It was
apparently built by SQL-tracing manual "Auftrag ausliefern" actions in the Wawi GUI
(comments like `// Based on Statement 30 from JTL dump`). Cross-checking it against
the decompiled C#/`sp_helptext` ground truth above:
**Confirms our findings:**
- Same overall call order: `Auslieferung.spReserviereBestellungen`/`spReserviereBestellpositionen`
`Auslieferung.spPicklistenUebernehmen``Auslieferung.spPicklistenAusliefern`
`Verkauf.spAuftragEckdatenBerechnen`.
- Session handling via a `dbo.tSessionId` row (`kSessionId`), matching §2 exactly.
- `@nOptions = 258` (`0x102`) for `Auslieferung.spReserviereBestellpositionen` — exact
match with our decompiled-C#-derived value in §3.1.
- `@nOptions = 2` (no `Stücklistenkorrektur` bit) for `Auslieferung.spReserviereBestellungen`
— matches the decompiled C# wrapper for that specific overload, which (unlike the
`...Bestellpositionen` wrapper) does **not** OR in `Stücklistenkorrektur`.
- Real example `@Laeger` XML with multiple prioritised warehouses (`kWarenlager` 1,
4, 5, 6, 7) plus dropshipping-supplier fallback rows (`kLieferant` 3/1/2/`n`,
i.e. default/cheapest/fastest/specific supplier) — confirms the magic-value scheme
documented in §3.1 with a concrete real-world example, and confirms
`@Laeger` supports arbitrarily many `<Lager>` candidates, not just one.
- After delivery, Picklisten/`tPicklistePos` rows are deliberately **kept** (not
deleted) — their `cleanupSessions()` only ever deletes `dbo.tSessionId` (the
`picklistId` branch that would delete `tPickliste`/`tPicklistePos` is called with
`null` and never actually runs). Matches expectations: the Pickliste is the audit
trail the Wawi GUI still shows after delivery, so it must survive.
- Calls `Auslieferung.spPicklistenVerwerfen` again at the very end, even after a
successful `spPicklistenAusliefern`. Checked its SQL body
(`Auslieferung.spPicklistenVerwerfen`, `/tmp/sp_verwerfen.sql`):
```sql
DELETE FROM dbo.tPickliste WHERE kSessionId = @kSessionId AND nStatus < 10
```
It only ever removes *uncommitted* (`nStatus < 10`) Picklisten for the session, so
calling it unconditionally at the end of a successful delivery is harmless/a good
defensive habit (cleans up any leftover reservation that didn't make it into the
delivery, e.g. a position that failed stock checks), not just an error-path call
as §6 step 6 assumed. **Recommendation: call it unconditionally after step 4/5,
not only on error.**
**Contradicts/corrects our findings — resolved in favor of the ground truth:**
- It resolves order positions through legacy `dbo.tBestellung`/`dbo.tBestellPos`
tables, joined back to `Verkauf.tAuftragPosition` by **matching `kArtikel`**
(`ap.kArtikel = bp.tArtikel_kArtikel AND ap.kAuftrag = a.kAuftrag`) rather than by
ID. This is unnecessary and fragile (breaks for two order lines with the same
SKU/article). Checked directly: `Auslieferung.vBestellPos`
(`/tmp/vbestellpos.sql`) —
```sql
CREATE VIEW [Auslieferung].[vBestellPos] AS
SELECT tAuftragPosition.kAuftragPosition AS kBestellPos,
tAuftrag.kAuftrag AS kBestellung, ...
```
confirms **`kBestellPos` *is* `Verkauf.tAuftragPosition.kAuftragPosition` and
`kBestellung` *is* `Verkauf.tAuftrag.kAuftrag`, directly** — "Bestellung"/"BestellPos"
is just the historical/legacy parameter naming carried over from pre-3.0 JTL, not a
separate table to join through. Our `create-order.js` already has these ids
directly (`kAuftrag`, `kAuftragPosition`) — no extra lookup needed, use them as-is
in all the `@Bestellpositionen`/`@Bestellungen`/`@Laeger` XML.
- It calls `Auslieferung.spPicklistenAusliefern` with **`@nOptions = 0`** (no
`VersandSetzen`). This contradicts `DeliveryStep.cs` (POS/API order path), which
explicitly does:
```csharp
auslieferungAusliefernContext.Optionen.VersandSetzen = true;
```
(`decompiledReference/decompiled/jtlCore/jtlCore.Classes.Sync.PosOrderSteps/DeliveryStep.cs:213`,
confirmed present, not just inferred). The other implementation was likely traced
from a manual GUI delivery where the "Versand setzen" checkbox happened to be
unchecked. **For reproducing POS/API-style immediate delivery, use `@nOptions = 2`
(`VersandSetzen`), not `0`, per DeliveryStep's actual behavior — keep §0/§3.3 as
documented.**
- It also creates a `dbo.tUserSession` row (`createJTLSessions`) alongside
`dbo.tSessionId`, mirroring a full GUI login. No stored procedure body read so far
(§3, plus `spWarenlagerAusgangPicklistePos`, `spPicklistenVerwerfen`,
`vBestellPos`) references or validates against `tUserSession` — the only
server-side check seen is `kBenutzer` existing in `dbo.tbenutzer` (a plain FK-style
check, e.g. inside `spWarenlagerAusgangPicklistePos`). **Recommendation: skip
`tUserSession` entirely** unless a not-yet-encountered procedure turns out to
require it.
- Its partial-delivery path (multiple `spReserviereBestellungen` calls interleaved
with manual `DELETE FROM tPicklistePos`/quantity-adjustment dances) is real
evidence of how the Wawi GUI implements *partial* delivery, but is unnecessary
complexity for us — `create-order.js` always delivers the full ordered quantity
(mirrors `DeliveryStep`'s non-voucher path), so the single-pass §0 sequence
(reserve once, commit with `nTeillieferung = 0`, deliver) is sufficient and matches
what `DeliveryStep.Run` itself does for a new POS order.
## 1. C# call chain: HTTP request → delivery commit
1. **`OrderController.Put`**
(`decompiledReference/JTL.Wawi.PosServer.Controller/OrderController.cs:33-67`)
Receives `PUT /api/v1/order`, delegates to `Q5hG5CfaiXW` (line 101-127), which
builds a `PosFrontend<...>` and calls `.ExecuteAsync(new PosOrderCommand {
Origin = OriginType.Bestellung, ... })`.
2. **`PosFrontend.ExecuteAsync` → `PosFrontend.HandleOrders`**
(`decompiledReference/JTL.Wawi.Sync.Core.decompiled.cs:6168-6122`)
Calls `new PosOrderImportService(...).ImportOrders(...)`.
3. **`PosOrderImportService.ImportOrders`**
(`decompiledReference/JTL.Wawi.Sync.Core.decompiled.cs:6231-6281`)
For a brand-new order (no existing `OrderNumber`/`OrderId` match — our case):
`CreateOrUpdatePosAuftrag(order, list, shopSubShopId)` →
`SyncCoreService.Service.CreateOrUpdatePosAuftrag(...)`.
4. **`SyncCoreService.CreateOrUpdatePosAuftrag`**
(`decompiledReference/decompiled/jtlCore/jtlCore.Classes.Sync/SyncCoreService.cs:77-80`)
Delegates to `new PosOrderCreationService(...).CreateOrUpdatePosAuftrag(...)`.
5. **`PosOrderCreationService.CreateOrUpdatePosAuftrag`**
(`decompiledReference/decompiled/jtlCore/jtlCore.Classes.Sync.Pos/PosOrderCreationService.cs:102-221`)
Creates the order via `importer.TrySaveOrder(...)`, then runs a step pipeline:
```
new RoundingErrorStep(...).Run(jtlAuftrag, orderEntity);
new DeliveryStep(...).Run(jtlAuftrag, orderEntity);
new InvoicePrintStep(...).Run(jtlAuftrag, orderEntity);
new VoucherPrintStep(...).Run(jtlAuftrag, orderEntity);
```
6. **`DeliveryStep.Run`**
(`decompiledReference/decompiled/jtlCore/jtlCore.Classes.Sync.PosOrderSteps/DeliveryStep.cs:184-273`)
`bool flag = orderEntity.Settings?.Deliver ?? true;` — this is where `deliver`
from the POS payload is consumed. When true:
- `PosWarehouseService.FindeWarenlagerFürAusgang()` — resolves the outgoing
warehouse (`kWarenLager`) to use. **Still unresolved in this doc — see "Open
questions" below.**
- `AuslieferungAusliefernContext.CreateForAufträge([kAuftrag], ...)` — opens a
delivery context (constructs an `AuslieferungContext`, which immediately opens
a `SessionManager`/`kSessionId`, see §2).
- `auslieferungContext.LadeBestände()` — loads current stock (read-only, for UI).
- For every position with a real `ArtikelId`:
`PosBookPositionService.BookPosition(...)` →
`auslieferungContext.NeuReservieren(positions, warehouseDetails)` → calls
`Auslieferung.spReserviereBestellpositionen` (§3.1).
- For positions without an `ArtikelId` (shipping, deposit/Pfand lines):
`auslieferungContext.NeuReservieren(...)` directly with the full quantity (same
stored procedure).
- If the order is paid (or the payment method allows delivery-before-payment):
`posStockPositionService.FehlbestandEinbuchen(...)` — books any stock shortfall
so delivery isn't blocked by missing stock.
- **`auslieferungAusliefernContext.Commit();`** — persists everything (§2).
- If backdated: `ApTVuv7PTPl.SetVersanddatum(auftrag.kAuftrag, orderEntity.CreationDate)`.
7. **`AuslieferungAusliefernContext.Commit()`**
(`decompiledReference/decompiled/jtlCore/jtlCore.Classes.Versand.Auslieferung/AuslieferungAusliefernContext.cs:223-321`)
```csharp
Validiere();
PicklistenCommitten(); // -> Auslieferung.spPicklistenUebernehmen (§3.2)
...
auslieferungSpResult = IScXmooPhGf(); // -> Auslieferung.spPicklistenAusliefern (§3.3)
...
kHtXmjSeHbm(T5jXmkcZPKf()); // -> Verkauf.spAuftragEckdatenBerechnen (already replicated)
ExecuteWorkflows(auslieferungSpResult); // UI/event notifications only, no DB writes
```
## 2. The session concept (`kSessionId`)
Every reservation/commit call is scoped by a `kSessionId`. This comes from
`jtlCore.Classes.SessionManager` (`decompiledReference/decompiled/jtlCore/jtlCore.Classes/SessionManager.cs`),
which lazily does, on first use:
```csharp
var s = new jtlSessionid {
cRechnername = Environment.MachineName,
kBenutzer = BenutzerManager.Current.kAngemeldeterBenutzer,
dLastAction = DateTime.Now + TimeSpan.FromDays(10.0)
};
s.Save(); // INSERT INTO dbo.tSessionId (...)
s.Touch();
```
`jtlSessionidBase` (`jtlDatabase.dll`, decompiled) maps to table **`dbo.tSessionId`**:
| Column | Type | Notes |
|---------------|-----------------|-----------------------------------------|
| `kSessionId` | `int` identity | PK, returned via `SCOPE_IDENTITY()` |
| `cRechnername`| `nvarchar(255)` | machine name (cosmetic, can be any string) |
| `kBenutzer` | `int` | FK `dbo.tBenutzer` |
| `dLastAction` | `datetime` | kept alive by a 60s "touch" timer; only relevant for GUI housekeeping/timeouts |
So reproducing this is just: **insert one row into `dbo.tSessionId`, read back
`kSessionId`**, use it for the whole delivery operation, nothing else needed (no need
to periodically "touch" it for a short-lived one-shot API request).
On `Dispose()`, if the context was constructed for "new reservation" mode
(`BQ9XLnEpso5 == true`, i.e. our case, an `AuslieferungAusliefernContext`), and
`Commit()` was never called, it rolls back via `Auslieferung.spPicklistenVerwerfen`
instead. We don't need this if we always commit; keep it in mind for error handling
(wrap in try/catch and call `spPicklistenVerwerfen` on failure to avoid leaving orphan
Picklisten in that session).
## 3. The stored procedures, in call order
All four procedures below live in `jtlDatabase.classes.jtlDBClasses.StoredProcedures`
(decompiled with `ilspycmd -t jtlDatabase.classes.jtlDBClasses.StoredProcedures
jtlDatabase.dll`) and their exact ADO.NET parameter wiring is unobfuscated. Their SQL
bodies were read directly via `sp_helptext` against `eazybusiness`.
### 3.1 `Auslieferung.spReserviereBestellpositionen` — soft-reserve positions
C# wrapper (`StoredProcedures.cs:1413-1440`):
```csharp
private static int? Auslieferung_spReserviereBestellpositionen(
string Bestellpositionen, string Laeger, string Warenlagereingaenge,
int? nOptions, int? kBenutzer, int? kSessionId, IDbConnection connection = null)
```
SQL parameters (`@Bestellpositionen XML, @Laeger XML, @Warenlagereingaenge XML,
@nOptions INT, @kBenutzer INT, @kSessionId INT`):
```xml
<!-- @Bestellpositionen: one per order position to reserve -->
<Bestellposition>
<kBestellPos>1</kBestellPos> <!-- = Verkauf.tAuftragPosition.kAuftragPosition -->
<fAnzahl>1.234</fAnzahl> <!-- quantity to reserve -->
</Bestellposition>
<!-- @Laeger: candidate warehouses, tried in nPrio order -->
<Lager>
<kWarenlager>1</kWarenlager>
<nPrio>0</nPrio>
<kLieferant>0</kLieferant> <!-- 0 = normal; -1/-2/-3 = cheapest/fastest/default supplier (dropshipping) -->
<kAnsprechpartner>0</kAnsprechpartner>
</Lager>
<!-- @Warenlagereingaenge: optional, specific goods-receipt rows (FIFO/batch/serial) — leave empty/NULL for "any" -->
<Warenlagereingang>
<kWarenlagereingang>17</kWarenlagereingang>
<fAnzahl>2.0</fAnzahl>
<nPrio>0</nPrio>
</Warenlagereingang>
```
`nOptions` bitmask (`StoredProcedures.ReserviereBestellpositionenOptionen`):
`0x0001 PicklisteProBestellung`, `0x0002 ChargenVorbelegen`, `0x0100
Stücklistenkorrektur`. The C# `AuslieferungContext` ctor always sets
`ChargenVorbelegen` (`0x0002`), and the low-level SP wrapper always OR's in
`Stücklistenkorrektur` (`0x0100`) — so the effective default is **`0x0102` (258)**
unless "one Pickliste per order" is also wanted (`+0x0001`).
Behavior (from `Auslieferung.spReserviereBestellposition`, called once per position
in a cursor loop, `/tmp/sp_reserviere_single.sql`):
- Resolves candidate stock sources from `@Laeger`/`@Warenlagereingaenge` into a temp
table (`#WARENLAGEREINGANG`), ordered by `nPrio`, `dMHD`, `cCharge`,
`kWarenlagereingang`.
- For each source, in order, until `@fAnzahl` is exhausted:
- Finds or creates a `dbo.tPickliste` row (`kSessionId = @kSessionId`, `nStatus =
0`, `cPicklisteNr` from `dbo.spGetNextNummer('Pickliste', ...)`). Reused across
positions of the same session/warehouse/supplier (one Pickliste per
warehouse+supplier+session, unless `PicklisteProBestellung` requested).
- Inserts a `dbo.tPicklistePos` row: `(kPickliste, kWarenLager, kWarenLagerEingang,
fAnzahl, kBestellPos, kPicklistePosStatus=0, kArtikel, kWarenlagerPlatz,
kPicklistePos_Ursprung=0, kLieferscheinPos=0, kBestellung)`.
- After the loop: deletes zero-quantity `tPicklistePos` rows for this
`kBestellPos`/session, then inserts a `dbo.tPicklistePosStatus` row with
**`nStatus = 5`** ("angelegt"/created) for every newly created `tPicklistePos`
(`kPicklistePosStatus = 0`).
This is the step that's entirely missing from our code — **this is why no Pickliste
exists for jtlsrv orders.**
### 3.2 `Auslieferung.spPicklistenUebernehmen` — commit reservations into "real" Picklisten
C# wrapper (`StoredProcedures.cs:1358-1386`):
```csharp
private static int? Auslieferung_spPicklistenUebernehmen(
string Bestellungen, int? kBenutzer, bool? nTeillieferung, int? kSessionId,
ref string xResult, IDbConnection connection = null)
```
SQL parameters (`@Bestellungen XML, @kBenutzer INT, @nTeillieferung BIT, @kSessionId
INT, @xResult XML OUTPUT`):
```xml
<Bestellung>
<kBestellung>7</kBestellung> <!-- Verkauf.tAuftrag.kAuftrag, one element per order -->
</Bestellung>
```
`@nTeillieferung`: `0` = partial delivery forbidden (raises an error if any reserved
order has both open and already-picked quantity outstanding — not our concern for a
"deliver everything now" POS order, so pass `0`); the C# side derives it from
`AuslieferungTeillieferungOptionen.GetCodeForSql() >= 1`.
Behavior (`/tmp/sp_uebernehmen.sql`):
- If `@nTeillieferung = 0`, verifies no order in `@Bestellungen` would end up
partially delivered; raises `RAISERROR` with an XML error payload otherwise.
- Deletes now-superfluous "Stücklistenvater" pick positions and any now-empty
Picklisten in this session.
- Bumps every `dbo.tPicklistePos` row in this session from status `< 10` to
**`nStatus = 10`** ("übernommen"/committed) via a new `tPicklistePosStatus` row.
- For a subset of position types (`tbestellpos.nType IN (18, 20)`) bumps further to
**`nStatus = 20`**.
- Returns `@xResult`:
```xml
<Result><NewPicklisten><Pickliste><kPickliste>...</kPickliste></Pickliste>...</NewPicklisten></Result>
```
### 3.3 `Auslieferung.spPicklistenAusliefern` — the actual delivery
C# wrapper (`StoredProcedures.cs:1327-1356`) — **this is the procedure the user
correctly identified as missing:**
```csharp
private static int? Auslieferung_spPicklistenAusliefern(
string xHinweise, string Pakete, int? nOptions, int? kBenutzer, int? kSessionId,
ref string xResult, IDbConnection connection = null)
```
SQL parameters (`@xHinweise XML, @Pakete XML, @nOptions INT, @kBenutzer INT,
@kSessionId INT, @xResult XML OUTPUT`):
```xml
<!-- @xHinweise: optional per-position/per-order notes copied onto the Lieferschein(Pos); can be empty/NULL -->
<Hinweis>
<kBestellPos>5</kBestellPos> <!-- OR <kBestellung>7</kBestellung> for the order-level note -->
<cHinweis>blah</cHinweis>
</Hinweis>
<!-- @Pakete: optional shipping/tracking info; can be empty/NULL for "no shipment info yet" -->
<Paket>
<kBestellung>1</kBestellung>
<kVersandart>5</kVersandart>
<dVersanddatum>...</dVersanddatum>
<cTrackingId>trackingid</cTrackingId>
<cEnclosedReturnIdentCode>...</cEnclosedReturnIdentCode>
<cHinweis>...</cHinweis>
</Paket>
```
`@nOptions` bitmask (`StoredProcedures.PicklistenAusliefernOptionen`): `0x0001
LieferscheinProLager` (one Lieferschein per warehouse), `0x0002 VersandSetzen` (also
create a `dbo.tVersandInfo`/package row + set shipped date on local-warehouse
deliveries). `DeliveryStep` always sets `VersandSetzen`, so the effective value is
**`2`** (or `3` if also splitting by warehouse).
Behavior (`/tmp/sp_ausliefern.sql`, delegates most of the work to sub-procedures, all
scoped to `WHERE ... kSessionId = @kSessionId`):
1. Validates (raises on partially-assigned serial numbers / empty serials for
serial-tracked articles — not relevant unless articles use `cLagerArtikel = 'Y'`
serial tracking).
2. **`Auslieferung.spPicklistenAusliefern_LokaleLager`** (`/tmp/sp_lokal.sql`) — the
part that matters for a normal local-warehouse delivery:
- Builds `@xLieferschein` XML for every order that has committed `tPicklistePos`
rows in this session on a local warehouse (`tWarenLager.nFulfillment = 0`), and
calls **`Versand.spLieferscheinErstellen`** (same proc our code already calls,
but now driven from the Pickliste data, with a properly numbered
`cLieferscheinNr` = `<Auftragsnummer>-NNN`).
- Builds `@xLieferscheinPos` XML (`kLieferschein`, `kBestellPos`,
`fAnzahl` summed per position) and calls **`Versand.spLieferscheinPosErstellen`**
(again, same proc, but now the quantities come from the Picklisten, not
directly from the order).
- **Links the new `kLieferscheinPos` back onto the `tPicklistePos` rows**
(`UPDATE dbo.tPicklistePos SET kLieferscheinPos = ...`) — this is the row our
current code never populates, which is presumably one of the visible
differences you saw between order 25 and order 26.
3. `Auslieferung.spPicklistenAusliefern_Fulfillment` /
`..._Dropshipping` — not relevant for local-warehouse orders.
4. Marks serial-numbered stock items (`dbo.tlagerartikel`) with the resulting
`kBestellPos`/`kLieferscheinPos` (only relevant for `cLagerArtikel = 'Y'`
articles).
5. **Real stock decrement**: builds `@xPicklistePos` (`kPicklistePos`, `kBenutzer`,
`cKommentar`) for every committed local-warehouse `tPicklistePos` row and calls
**`dbo.spWarenlagerAusgangPicklistePos`** (`/tmp/sp_ausgang.sql`), which in turn
builds a `WarenAusgang` XML per position (`kWarenLagerEingang`, `kLieferscheinPos`,
`fAnzahl`, `kWarenlagerPlatz`, `kArtikel`, `kBuchungsart = 20`) and calls
**`dbo.spWarenlagerAusgangSchreiben`** — the actual, final stock-decrement
procedure (writes the goods-issue row and reduces
`tWarenLagerEingang.fAnzahlAktuell`/warehouse stock). **This is the step that
explains the "no real stock decrement" symptom.**
6. Bumps `tPicklistePos` status to **`nStatus = 40`** ("ausgeliefert"/delivered).
7. Copies `@xHinweise` notes onto the new `tLieferschein`/`tLieferscheinPos` rows.
8. `Auslieferung.spPicklistenAusliefern_PaketeErzeugen` (`/tmp/sp_paketeerzeugen.sql`) —
creates the `dbo.tVersand` row(s) and, because `VersandSetzen` is set, marks
local-warehouse deliveries as shipped (`dVersendet = GETDATE()`).
- **Gotcha confirmed by testing**: this sub-proc builds a `#XMLPAKET` temp table
straight from `@Pakete.nodes('/Paket')` and then drives *every* `INSERT INTO
dbo.tVersand` off a join against that temp table (keyed by `kBestellung`). If
`@Pakete` is `NULL`/empty, `#XMLPAKET` has zero rows and the join finds
nothing for **any** order in the session — not even the "no explicit Paket"
fallback branch runs, because that branch is also reached only via the
`cLieferschein` cursor which is itself driven by a `JOIN #XMLPAKET`. Net
effect: passing `@Pakete = NULL` silently skips `dbo.tVersand` entirely, no
error, no shipment method recorded — this is exactly the bug the user
reported (rows shown by the user all have `kVersandArt = 2` /
"Selbstabholer" and a real `dVersendet`, which only happens when `@Pakete`
contains a matching `<Paket>` element for that order).
- Fix: always pass at least
`<Paket><kBestellung>{kAuftrag}</kBestellung><kVersandart>{kVersandArt}</kVersandart><fGewicht>0</fGewicht></Paket>`
(using the order's own `Verkauf.tAuftrag.kVersandArt`). `fGewicht` is a
`NOT NULL` column with no default, so it must be present in the XML — `0` is
safe, it's the exact sentinel value this same proc later looks for
(`WHERE dbo.tVersand.fGewicht = 0`) to recompute a real weight from the
order's article weights a few statements later.
- `cIdentCode`/`cTrackingId`, `dVersanddatum`, `cEnclosedReturnIdentCode`,
`cHinweis` are all optional and can be added later (e.g. once real carrier
integration exists) without changing anything else in this flow.
9. `Auslieferung.spPicklistenAusliefern_Umlagerungen` — stock-transfer orders only,
not relevant here.
10. Returns `@xResult`:
```xml
<Result>
<NewDeliveryNotes><DeliveryNote><kLieferschein>...</kLieferschein></DeliveryNote>...</NewDeliveryNotes>
<ProcessedPicklisten><Pickliste><kPickliste>...</kPickliste></Pickliste>...</ProcessedPicklisten>
<NewFulfillmentauftraege>...</NewFulfillmentauftraege>
<NewLieferantenbestellungen>...</NewLieferantenbestellungen>
<DeliveredOrders><Order><kBestellung>...</kBestellung></Order>...</DeliveredOrders>
<DeliveredUmlagerungen>...</DeliveredUmlagerungen>
</Result>
```
(schema confirmed unobfuscated in `StoredProcedures.AuslieferungSpResult`,
`StoredProcedures.cs:52-186`)
### 3.4 `Verkauf.spAuftragEckdatenBerechnen`
Already correctly called by our existing code (`create-order.js`); recalculates
`nKomplettAusgeliefert` and other derived order fields from the (now populated)
Lieferschein/Pickliste data. No change needed here, just needs to run *after* step
3.3 instead of instead of it.
## 4. `tPicklistePos.nStatus` progression (for reference)
| Status | Meaning | Set by |
|--------|---------------------------------------|-------------------------------------------|
| 0 | just inserted | `spReserviereBestellposition` |
| 5 | reserved / "angelegt" | `spReserviereBestellpositionen` (end) |
| 10 | committed / "übernommen" | `spPicklistenUebernehmen` |
| 20 | committed (special position types 18/20) | `spPicklistenUebernehmen` |
| 40 | delivered / "ausgeliefert" | `spPicklistenAusliefern` (via `_LokaleLager`) |
## 5. What our code does today (`src/queries/create-order.js`)
`deliverOrder()` calls `Versand.spLieferscheinErstellen` +
`Versand.spLieferscheinPosErstellen` directly per position — i.e. it starts at
step 3.3's *inner* Lieferschein-creation calls, skipping everything before it
(§3.1 reserve, §3.2 commit) and everything after within 3.3 (Pickliste linking,
real stock decrement, package/ship-date handling). It happens to still flip
`nKomplettAusgeliefert` because `spLieferscheinPosErstellen`/our subsequent call to
`Verkauf.spAuftragEckdatenBerechnen` don't care where the Lieferschein numbers came
from — they just see delivered quantities recorded on `tLieferschein`. Net effect:
- No `dbo.tPickliste` / `dbo.tPicklistePos` rows.
- No real warehouse stock (`dbo.tWarenLagerEingang.fAnzahlAktuell` /
`dbo.tlagerbestand`) decrement.
- `cLieferscheinNr` isn't numbered per-order the way the real flow does it
(`<AuftragsNr>-001`).
## 6. Plan to reproduce exactly
Rewrite `deliverOrder()` in `create-order.js` to, per order:
1. `INSERT INTO dbo.tSessionId (cRechnername, kBenutzer, dLastAction) VALUES
('jtlsrv', @kBenutzer, DATEADD(day, 10, GETDATE()))`, read back `kSessionId`
(`SCOPE_IDENTITY()`).
2. For every order position with a real `kArtikel` (skip pure text/shipping-only
rows if the article resolves to 0 — those go through the "Freiposition" path in
§3.1, which our `@Laeger` XML already supports the same way): build
`@Bestellpositionen` (one `<Bestellposition>` per position, full ordered
quantity) and `@Laeger` (single `<Lager>` with the resolved outgoing
`kWarenlager`, `nPrio=0`, `kLieferant=0`, `kAnsprechpartner=0`), leave
`@Warenlagereingaenge` `NULL`, `@nOptions = 0x0102` (`ChargenVorbelegen |
Stücklistenkorrektur`). Call `Auslieferung.spReserviereBestellpositionen`.
3. Call `Auslieferung.spPicklistenUebernehmen` with `@Bestellungen =
<Bestellung><kBestellung>{kAuftrag}</kBestellung></Bestellung>`,
`@nTeillieferung = 0`, same `@kSessionId`.
4. Call `Auslieferung.spPicklistenAusliefern` with `@xHinweise = NULL`, `@Pakete =
<Paket><kBestellung>{kAuftrag}</kBestellung><kVersandart>{kVersandArt}</kVersandart><fGewicht>0</fGewicht></Paket>`
(using the order's own `Verkauf.tAuftrag.kVersandArt`; **must not be `NULL`** or
`dbo.tVersand` never gets a row for this order at all — see §3.3 step 8),
`@nOptions = 2` (`VersandSetzen`), same `@kSessionId`.
5. Call `Verkauf.spAuftragEckdatenBerechnen(@kAuftrag)` (already implemented).
6. On any failure in 2-4, call `Auslieferung.spPicklistenVerwerfen(@kBenutzer,
@kSessionId)` to discard the session's half-finished Picklisten before
re-throwing.
7. Optionally `DELETE FROM dbo.tSessionId WHERE kSessionId = @kSessionId` at the
end (mirrors `SessionManager.Dispose()`); not strictly required for
correctness since it only affects the GUI's "who's editing what" bookkeeping,
but keeps `tSessionId` from accumulating rows.
## 7. Open questions / remaining unknowns
- **Outgoing warehouse resolution** (`PosWarehouseService.FindeWarenlagerFürAusgang()`,
`jtlCore`, obfuscated): need to determine which `dbo.tWarenLager.kWarenLager` to
put in `@Laeger`. Likely candidates: the single active local warehouse
(`nFulfillment = 0`) if there's only one, or a per-shop/subshop default warehouse
setting somewhere in `dbo.tShopSubshop`/shop config. Needs to be resolved (e.g. by
decompiling `PosWarehouseService` from `jtlCore.dll` with `ilspycmd`, or reading
`sp_helptext` of whatever proc/view it ultimately queries) before step 2 above can
pick the right warehouse automatically instead of hard-coding it.
- **Article resolution / stock articles vs. free positions**: `spReserviereBestellposition`
branches heavily on `tArtikel.cLagerVariation`/`cLagerAktiv`/`cLagerArtikel` and
dropshipping supplier data; our current order items are simple retail SKUs, so the
"Artikel mit Lagerbestand (Warenläger)" branch should apply, but this needs
verification against a real low/zero-stock article to see how shortfalls are
handled (`PosStockPositionService.FehlbestandEinbuchen` in `DeliveryStep`, not yet
traced).
- **Serial-number / batch tracked articles**: out of scope for now (our catalog
doesn't seem to use `cLagerArtikel = 'Y'` serial tracking), but `spPicklistenAusliefern`
will raise an error (`RAISERROR(..., 18, 3)` / `(..., 18, 6)`) if it ever does and we
don't supply serial numbers via `Auslieferung.spReserviereSeriennummern` first.
## 8. Implementation status
Implemented in `src/queries/delivery/` (`session.js`, `warehouse.js`, `reserve.js`,
`commit.js`, `deliver.js`, `index.js`), wired into `create-order.js` in place of the
old `Versand.spLieferscheinErstellen`/`spLieferscheinPosErstellen` shortcut. See
those files' doc comments for the mapping back to the steps in §0/§6.
Verified end-to-end against a live test order in `Mandant_3`: real stock decrement,
`dbo.tPickliste`/`tPicklistePos` created with `nStatus = 40` and a real
`kLieferscheinPos` link, properly numbered `Lieferschein` (`<AuftragsNr>-001`),
`nKomplettAusgeliefert = 1`, and the session row cleaned up afterward.
One gotcha found only through this live testing, not visible from the decompiled
C# alone (**`AuslieferungAusliefernContext`/`PosBookPositionService` build their own
`Paket`-equivalent state in-memory before calling the stored procedure, so this
particular pitfall is specific to calling the raw SQL procedure directly**): passing
`@Pakete = NULL` to `Auslieferung.spPicklistenAusliefern` silently skips `dbo.tVersand`
row creation entirely (see §3.3 step 8 for the full mechanism). Fixed by always
passing a `<Paket>` element with the order's `kVersandArt` (and a `fGewicht = 0`
sentinel, since the column is `NOT NULL`). This is what actually makes
`dbo.tVersand` end up with a row like `kVersandArt = 2` ("Selbstabholer") and a real
`dVersendet`, matching native JTL-Wawi/POS orders.
A second, unrelated tedious/mssql driver limitation was hit and fixed: the `xml` SQL
type does not reliably round-trip through `sql.Xml` typed parameters/output
parameters for these particular procedures (`Implicit conversion from data type xml
to nvarchar is not allowed`). Worked around by passing XML as `NVarChar(MAX)` and
`CONVERT(XML, @param)`-ing it inside the SQL batch itself, and reading `@xResult
OUTPUT` back via a trailing `SELECT` instead of a driver-level output parameter.

View File

@@ -1,5 +1,6 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -12,13 +13,68 @@ const certPath = path.join(certsDir, 'cert.pem');
fs.mkdirSync(certsDir, { recursive: true }); fs.mkdirSync(certsDir, { recursive: true });
const subject = '/CN=localhost/O=JTL POS Sync/C=DE'; function isIp(value) {
const san = 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0'; return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(':');
}
function localIpv4s() {
const ips = [];
for (const entries of Object.values(os.networkInterfaces())) {
for (const entry of entries || []) {
if (entry.family !== 'IPv4' || entry.internal) continue;
// Skip link-local / docker / libvirt bridge noise by default — keep LAN + extras via args
if (entry.address.startsWith('169.254.')) continue;
if (entry.address.startsWith('172.17.')) continue;
if (entry.address.startsWith('192.168.122.')) continue;
ips.push(entry.address);
}
}
return ips;
}
const dnsNames = new Set(['localhost']);
const ipAddrs = new Set(['127.0.0.1', '0.0.0.0']);
for (const ip of localIpv4s()) {
ipAddrs.add(ip);
}
const extras = [
...(process.env.CERT_SAN || '').split(/[,\s]+/).filter(Boolean),
...process.argv.slice(2),
];
for (const value of extras) {
if (isIp(value)) ipAddrs.add(value);
else dnsNames.add(value);
}
const sanParts = [
...[...dnsNames].map((name) => `DNS:${name}`),
...[...ipAddrs].map((ip) => `IP:${ip}`),
];
const san = `subjectAltName=${sanParts.join(',')}`;
const cn = [...dnsNames][0] || 'localhost';
const subject = `/CN=${cn}/O=JTL POS Sync/C=DE`;
// ECDSA P-256 keeps pairing QR codes much smaller than RSA-2048
execSync( execSync(
`openssl req -x509 -newkey rsa:2048 -nodes -keyout "${keyPath}" -out "${certPath}" -days 3650 -subj "${subject}" -addext "${san}"`, `openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout "${keyPath}" -out "${certPath}" -days 3650 -subj "${subject}" -addext "${san}"`,
{ stdio: 'inherit' } { stdio: 'inherit' }
); );
const sha1 = execSync(`openssl x509 -in "${certPath}" -noout -fingerprint -sha1`, {
encoding: 'utf8',
})
.trim()
.split('=')[1];
const serial = execSync(`openssl x509 -in "${certPath}" -noout -serial`, {
encoding: 'utf8',
})
.trim()
.split('=')[1];
logger.success(`Wrote ${keyPath}`); logger.success(`Wrote ${keyPath}`);
logger.success(`Wrote ${certPath}`); logger.success(`Wrote ${certPath}`);
logger.info(`SAN: ${sanParts.join(', ')}`);
logger.info(`Fingerprint: ${sha1.replace(/:/g, '')}`);
logger.info(`Serial: ${serial}`);

View File

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

19
jtlsrv-cpp/.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Build
build/
*.o
*.a
jtlsrv
jtlsrv-debug
# ODBC driver (downloaded, not source)
odbc-driver/
# Secrets / config
.env
.env.docker
# Certs (generated)
certs/
# Docker
docker-compose.override.yml

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

64
jtlsrv-cpp/src/config.hpp Normal file
View File

@@ -0,0 +1,64 @@
#pragma once
#include <cstdlib>
#include <fstream>
#include <string>
#include <unordered_map>
namespace config {
// Loads a .env file into the process environment (and returns the map).
// Lines starting with '#' and blank lines are skipped.
// No shell expansion, no quoting -- matches Node's dotenv behavior.
inline std::unordered_map<std::string, std::string> load(const std::string& path = ".env") {
std::unordered_map<std::string, std::string> vars;
std::ifstream file(path);
if (!file.is_open()) return vars;
std::string line;
while (std::getline(file, line)) {
// Trim leading whitespace
size_t start = line.find_first_not_of(" \t\r\n");
if (start == std::string::npos) continue;
line = line.substr(start);
if (line.empty() || line[0] == '#') continue;
size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
std::string val = line.substr(eq + 1);
// Trim trailing whitespace from value
size_t end = val.find_last_not_of(" \t\r\n");
if (end != std::string::npos) val = val.substr(0, end + 1);
// Strip surrounding quotes
if (val.size() >= 2 &&
((val.front() == '"' && val.back() == '"') ||
(val.front() == '\'' && val.back() == '\''))) {
val = val.substr(1, val.size() - 2);
}
setenv(key.c_str(), val.c_str(), 0); // don't overwrite existing
vars[key] = val;
}
return vars;
}
// Read a string env var with a fallback default.
inline std::string get(const char* key, const char* def = "") {
const char* val = std::getenv(key);
return val ? val : def;
}
// Read an integer env var with a fallback default.
inline int get_int(const char* key, int def = 0) {
const char* val = std::getenv(key);
if (!val) return def;
try { return std::stoi(val); }
catch (...) { return def; }
}
} // namespace config

402
jtlsrv-cpp/src/db/pool.cpp Normal file
View File

@@ -0,0 +1,402 @@
#include "pool.hpp"
#include "../config.hpp"
#include "../log.hpp"
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <thread>
#include <chrono>
static OdbcPool g_pool;
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;
rc = SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &henv_);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) return -1;
SQLSetEnvAttr(henv_, SQL_ATTR_ODBC_VERSION, (SQLPOINTER)SQL_OV_ODBC3, 0);
std::string server = config::get("MSSQL_SERVER", "localhost");
int port = config::get_int("MSSQL_PORT", 1433);
std::string database = config::get("MSSQL_DATABASE", "eazybusiness");
std::string user = config::get("MSSQL_USER");
std::string password = config::get("MSSQL_PASSWORD");
bool encrypt = (config::get("MSSQL_ENCRYPT", "true") != "false");
bool trust_cert = (config::get("MSSQL_TRUST_SERVER_CERTIFICATE", "true") != "false");
if (user.empty()) {
logc::warn("MSSQL_USER not set, skipping DB connection");
return -1;
}
std::string conn_str =
"DRIVER={ODBC Driver 18 for SQL Server};"
"SERVER=" + server + "," + std::to_string(port) + ";"
"DATABASE=" + database + ";"
"UID=" + user + ";"
"PWD=" + password + ";"
"Encrypt=" + (encrypt ? std::string("yes") : std::string("Optional")) + ";"
"TrustServerCertificate=" + (trust_cert ? std::string("yes") : std::string("no")) + ";";
logc::info("ODBC connecting to %s:%d/%s as %s", server.c_str(), port, database.c_str(), user.c_str());
const int POOL_SIZE = config::get_int("MSSQL_POOL_SIZE", 1);
conns_.resize(POOL_SIZE);
int connected = 0;
for (int i = 0; i < POOL_SIZE; i++) {
rc = SQLAllocHandle(SQL_HANDLE_DBC, henv_, &conns_[i].hdbc);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) continue;
rc = SQLDriverConnect(conns_[i].hdbc, nullptr,
(SQLCHAR*)conn_str.c_str(), SQL_NTS,
nullptr, 0, nullptr, SQL_DRIVER_COMPLETE);
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 {
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;
}
}
if (connected == 0) {
logc::error("no ODBC connections established");
return -1;
}
logc::success("ODBC pool: %d connections to %s/%s", connected, server.c_str(), database.c_str());
return 0;
}
void OdbcPool::disconnect() {
for (auto& c : conns_) {
if (c.hstmt != SQL_NULL_HSTMT) SQLFreeHandle(SQL_HANDLE_STMT, c.hstmt);
if (c.hdbc != SQL_NULL_HDBC) { SQLDisconnect(c.hdbc); SQLFreeHandle(SQL_HANDLE_DBC, c.hdbc); }
}
conns_.clear();
if (henv_ != SQL_NULL_HENV) { SQLFreeHandle(SQL_HANDLE_ENV, henv_); henv_ = SQL_NULL_HENV; }
}
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;
}
static bool bind_params(SQLHSTMT hstmt, const std::vector<Param>& params, std::vector<SQLLEN>& indicators) {
indicators.assign(params.size(), 0);
// Stable placeholder for null numeric parameters.
static const int64_t null_placeholder = 0;
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 = (SQLPOINTER)&null_placeholder;
SQLLEN buf_len = 0;
SQLSMALLINT c_type = SQL_C_CHAR;
SQLSMALLINT sql_type = SQL_VARCHAR;
SQLULEN column_size = 1;
switch (p.type) {
case ParamType::Int:
c_type = SQL_C_SLONG;
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:
c_type = SQL_C_DOUBLE;
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:
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;
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;
}
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) {
return false;
}
}
return true;
}
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;
}
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;
}
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);
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)) {
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) {
try { return std::stoll(cell.str); } catch (...) {}
}
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

@@ -0,0 +1,98 @@
#pragma once
#include <string>
#include <vector>
#include <mutex>
#include <cstdint>
#include <sql.h>
#include <sqlext.h>
#include <uv.h>
enum class CellType { Null, Int64, Double, String, Blob };
struct Cell {
CellType type = CellType::Null;
int64_t i64 = 0;
double dbl = 0.0;
std::string str;
std::vector<uint8_t> blob;
};
using Row = std::vector<Cell>;
using ResultSet = std::vector<Row>;
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:
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_;
std::string last_error_;
Connection* checkout_raw();
};
OdbcPool& get_pool();

View File

@@ -0,0 +1,11 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../queries/category_list.hpp"
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

@@ -0,0 +1,22 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../log.hpp"
#include "../queries/image.hpp"
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'."}});
return;
}
std::string size = req.get_query_param("size", "200");
ImageResult image = get_image_by_hash(path, size);
if (image.buffer.empty()) {
resp.send_json(404, {{"Message", "No image was found for path '" + path + "'."}});
return;
}
resp.send_binary(200, image.buffer, image.content_type);
}

View File

@@ -0,0 +1,72 @@
// GET /v1/client — pairing handshake (no DB)
// Port of src/endpoints/client.js
#include "../http.hpp"
#include "../pairing.hpp"
#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_string(config, "authToken")},
{"certificateFingerprint", config_string(config, "certificateFingerprint")},
{"certificateSerialNumber", config_string(config, "certificateSerialNumber")},
{"mandantId", config_string(config, "mandantId")},
{"mandantName", nullptr},
{"mandantDatabase", nullptr},
{"serverFingerprint", config_string(config, "serverFingerprint")},
{"name", nullptr},
{"serverTimestamp", server_timestamp()},
};
}
static json build_client_step2(const std::string& auth_code, const json& config) {
return {
{"authCode", auth_code},
{"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()},
};
}
void handle_client(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
std::string auth_code = req.get_query_param("authCode");
std::string name = req.get_query_param("name", "JTL-POS");
if (auth_code.size() <= 4 && !auth_code.empty()) {
return resp.send_json(200, build_client_step1(ctx.config));
}
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(config_string(ctx.config, "authToken"), name);
return resp.send_json(200, build_client_step2(auth_code, ctx.config));
}
return resp.send_json(400, {
{"Message", "Der Authentifizierungscode ist falsch."}
});
}
return resp.send_json(400, {
{"Message", "Keinen passenden Authentifizierungscode gefunden."}
});
}

View File

@@ -0,0 +1,10 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../queries/customer_groups.hpp"
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

@@ -0,0 +1,11 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../queries/deleted_entity_list.hpp"
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

@@ -0,0 +1,50 @@
#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 = 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);
category_count = get_category_count(root, shop, category_cursor);
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, {
{"version", "1.10.12.0"},
{"product_count", std::to_string(product_count)},
{"category_count", std::to_string(category_count)},
{"customer_count", "0"},
{"customerGroup_count", std::to_string(cg_count)},
{"compositeProduct_count", std::to_string(composite_count)},
{"configurationGroup_count", "0"},
{"configurationItem_count", "0"},
{"deletedEntity_count", std::to_string(deleted_count)},
{"max_orderId_count", std::to_string(max_order_id_count)}
});
}

View File

@@ -0,0 +1,83 @@
// 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 "../router.hpp"
#include "../log.hpp"
#include "../order_log.hpp"
#include "../queries/create_order.hpp"
#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);
} catch (...) {
return resp.send_json(500, nlohmann::json::array());
}
nlohmann::json results = nlohmann::json::array();
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", "ERROR"},
{"externalOrderId", externalOrderId},
{"message", ex.what()}
});
++failed;
}
}
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

@@ -0,0 +1,22 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../log.hpp"
#include "../queries/image.hpp"
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'."}});
return;
}
std::string size = req.get_query_param("size", "200");
ImageResult image = get_image_by_hash(path, size);
if (image.buffer.empty()) {
resp.send_json(404, {{"Message", "No image was found for path '" + path + "'."}});
return;
}
resp.send_binary(200, image.buffer, image.content_type);
}

View File

@@ -0,0 +1,11 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../queries/product_list.hpp"
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

@@ -0,0 +1,11 @@
#include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp"
#include "../queries/composite_product_list.hpp"
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);
}

157
jtlsrv-cpp/src/http.cpp Normal file
View File

@@ -0,0 +1,157 @@
#include "http.hpp"
#include "tls_server.hpp"
#include <algorithm>
#include <chrono>
#include <cstdio>
#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
// ---------------------------------------------------------------------------
std::string HttpRequest::get_query_param(const std::string& key, const std::string& def) const {
std::string needle = key + "=";
size_t pos = query_string.find(needle);
if (pos == std::string::npos) return def;
size_t val_start = pos + needle.size();
size_t val_end = query_string.find('&', val_start);
if (val_end == std::string::npos) val_end = query_string.size();
std::string raw = query_string.substr(val_start, val_end - val_start);
// Simple URL decode
std::string decoded;
decoded.reserve(raw.size());
for (size_t i = 0; i < raw.size(); ++i) {
if (raw[i] == '%' && i + 2 < raw.size()) {
char hex[3] = { raw[i+1], raw[i+2], 0 };
decoded += static_cast<char>(std::strtol(hex, nullptr, 16));
i += 2;
} else if (raw[i] == '+') {
decoded += ' ';
} else {
decoded += raw[i];
}
}
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;
std::string body_str = body.dump();
body_for_log = body_str;
std::string resp = "HTTP/1.1 " + std::to_string(code) + " " + reason_phrase(code) + "\r\n"
"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;
session_write(session, resp);
headers_sent = true;
}
void HttpResponse::send_binary(int code, const std::vector<uint8_t>& data, const std::string& content_type) {
if (headers_sent) return;
status_code = code;
std::string header = "HTTP/1.1 " + std::to_string(code) + " " + reason_phrase(code) + "\r\n"
"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);
headers_sent = true;
}
void HttpResponse::send_empty(int code) {
if (headers_sent) return;
status_code = 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);
headers_sent = true;
}
void HttpResponse::finish() {
// Connection keep-alive: don't close after each request.
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
std::string normalize_path(const std::string& pathname) {
// /api/v1/... -> /v1/...
const std::string prefix = "/api";
if (pathname.size() >= prefix.size() &&
pathname.compare(0, prefix.size(), prefix) == 0) {
if (pathname.size() > prefix.size() && pathname[prefix.size()] == '/' &&
pathname.size() > prefix.size() + 1 &&
pathname.compare(prefix.size() + 1, 2, "v1") == 0) {
return pathname.substr(prefix.size());
}
}
return pathname;
}
std::string server_timestamp() {
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
std::tm tm_buf{};
localtime_r(&t, &tm_buf);
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm_buf);
return buf;
}

83
jtlsrv-cpp/src/http.hpp Normal file
View File

@@ -0,0 +1,83 @@
#pragma once
// HTTP request / response types + llhttp glue, and send_json/send_binary.
// Modeled after the Node.js src/http.js.
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
#include <uv.h>
#include <nlohmann/json.hpp>
// Forward-declared; defined in tls_server.
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
// ---------------------------------------------------------------------------
struct HttpRequest {
std::string method; // "GET", "POST", ...
std::string path; // "/v1/client?authCode=..."
std::string version; // "1.1"
std::unordered_map<std::string, std::string> headers;
std::vector<uint8_t> body;
// Parsed query string
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;
};
// ---------------------------------------------------------------------------
// Response writer (wraps a tls_session*)
// ---------------------------------------------------------------------------
struct HttpResponse {
tls_session* session = nullptr;
int status_code = 200;
bool headers_sent = false;
std::string body_for_log; // captured for request logging
void send_json(int code, const json& body);
void send_binary(int code, const std::vector<uint8_t>& data, const std::string& content_type);
void send_empty(int code);
// After response is fully written, close the connection.
void finish();
};
// ---------------------------------------------------------------------------
// Route handler signature
// ---------------------------------------------------------------------------
struct RouteContext {
std::string query_path; // path without query string
std::string full_url; // raw path + query
class PairingStore* pairing_store = nullptr;
json config;
};
using RouteHandler = std::function<void(HttpRequest&, HttpResponse&, RouteContext&)>;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Normalize /api/v1/... -> /v1/... (matches src/http.js normalizePath)
std::string normalize_path(const std::string& pathname);
// Format "YYYY-MM-DD HH:MM:SS" from current time (matches serverTimestamp)
std::string server_timestamp();

45
jtlsrv-cpp/src/log.cpp Normal file
View File

@@ -0,0 +1,45 @@
#include "log.hpp"
#include <chrono>
#include <cstdio>
#include <cstdarg>
#include <ctime>
namespace logc {
static const char* level_label(Level l) {
switch (l) {
case Level::Info: return "\033[36mINFO\033[0m";
case Level::Success: return "\033[32mOK \033[0m";
case Level::Warn: return "\033[33mWARN\033[0m";
case Level::Error: return "\033[31mERROR\033[0m";
}
return "????";
}
static FILE* log_stream(Level l) {
return (l == Level::Warn || l == Level::Error) ? stderr : stdout;
}
void write(Level level, const char* fmt, ...) {
// ISO-8601 timestamp
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
std::tm tm_buf{};
localtime_r(&t, &tm_buf);
char ts[32];
std::strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%S", &tm_buf);
FILE* out = log_stream(level);
fprintf(out, "\033[90m%s\033[0m %s ", ts, level_label(level));
va_list args;
va_start(args, fmt);
vfprintf(out, fmt, args);
va_end(args);
fprintf(out, "\n");
fflush(out);
}
} // namespace logc

35
jtlsrv-cpp/src/log.hpp Normal file
View File

@@ -0,0 +1,35 @@
#pragma once
#include <cstdio>
#include <cstdarg>
#include <ctime>
#include <utility>
namespace logc {
enum class Level { Info, Success, Warn, Error };
void write(Level level, const char* fmt, ...);
// Convenience wrappers
template <typename... Args>
void info(const char* fmt, Args&&... args) {
write(Level::Info, fmt, std::forward<Args>(args)...);
}
template <typename... Args>
void success(const char* fmt, Args&&... args) {
write(Level::Success, fmt, std::forward<Args>(args)...);
}
template <typename... Args>
void warn(const char* fmt, Args&&... args) {
write(Level::Warn, fmt, std::forward<Args>(args)...);
}
template <typename... Args>
void error(const char* fmt, Args&&... args) {
write(Level::Error, fmt, std::forward<Args>(args)...);
}
} // namespace logc

240
jtlsrv-cpp/src/main.cpp Normal file
View File

@@ -0,0 +1,240 @@
// jtlsrv-cpp — main.cpp (Milestone 3: Router + pairing + endpoints)
#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"
#include "tls_server.hpp"
#include "http.hpp"
#include "router.hpp"
#include "pairing.hpp"
#include "db/pool.hpp"
#include <vips/vips.h>
#include "queries/shop.hpp"
#include "queries/customer_groups.hpp"
#include "request_log.hpp"
#include "order_log.hpp"
// ---------------------------------------------------------------------------
// Globals
// ---------------------------------------------------------------------------
static uv_loop_t* loop = nullptr;
static Router router;
static PairingStore pairing_store;
static RequestLog request_log;
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", 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")},
};
}
static json server_config;
// ---------------------------------------------------------------------------
// Endpoint declarations (defined in src/endpoints/*.cpp)
// ---------------------------------------------------------------------------
extern void handle_client(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_init(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_category(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_product(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_productcomposite(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_deleted_entity(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_customergroup(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_order(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_pimage(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_cimage(HttpRequest&, HttpResponse&, RouteContext&);
// ---------------------------------------------------------------------------
// Request handler — dispatches via Router
// ---------------------------------------------------------------------------
static void handle_request(tls_session* sess) {
auto start = std::chrono::steady_clock::now();
router.dispatch(sess, pairing_store, server_config);
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
auto& req = sess->current_request;
auto& resp = sess->current_response;
std::string url = req.path;
if (!req.query_string.empty()) url += "?" + req.query_string;
// Build response body for logging
std::string resp_body;
if (resp.status_code == 200) {
// Re-serialize to get size (body already sent, but we can dump from status)
// We need to capture the body before sending - patch: store it in resp
resp_body = resp.body_for_log;
}
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)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("%s %s %s %d %dms",
ip, req.method.c_str(), url.c_str(), resp.status_code, (int)elapsed);
}
// Request log
request_log.log(ip, req.method, url, resp.status_code, (int)elapsed, resp_body);
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
int main(int /*argc*/, char* argv[]) {
config::load(".env");
if (VIPS_INIT(argv[0])) {
vips_error_exit("unable to init libvips");
}
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(cert_path.c_str());
// Register routes
router.add_route("GET", "/v1/client", handle_client);
router.add_route("GET", "/v1/init", handle_init);
router.add_route("GET", "/v1/category", handle_category);
router.add_route("GET", "/v1/product", handle_product);
router.add_route("GET", "/v1/productcomposite", handle_productcomposite);
router.add_route("GET", "/v1/deletedentity", handle_deleted_entity);
router.add_route("GET", "/v1/customergroup", handle_customergroup);
router.add_route("POST", "/v1/order", handle_order);
router.add_route("GET", "/v1/pimage", handle_pimage);
router.add_route("GET", "/v1/cimage", handle_cimage);
// Initialize pairing store
pairing_store.set_pairing_code(config::get("PAIRING_CODE", "307018"), "JTL-POS");
pairing_store.register_device(server_config.value("authToken", std::string("df40ad2067954646abb0499548a52241")), "JTL-POS");
// Connect to MSSQL
if (get_pool().connect() == 0) {
logc::success("MSSQL connected: %s/%s",
config::get("MSSQL_SERVER").c_str(),
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.");
}
tls_server_set_handler(handle_request);
// Open log files
request_log.open(config::get("LOG_FILE", "logs/requests.log"));
g_order_log.open(config::get("ORDER_LOG_FILE", "logs/orders.log"));
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");
return 1;
}
tls_server_install_signals(loop);
logc::info("pairing code: %s", config::get("PAIRING_CODE", "307018").c_str());
uv_run(loop, UV_RUN_DEFAULT);
request_log.close();
g_order_log.close();
get_pool().disconnect();
vips_shutdown();
logc::info("shutdown complete.");
return 0;
}

View File

@@ -0,0 +1,40 @@
#include "order_log.hpp"
#include <chrono>
#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) {
mkdir(path.substr(0, pos).c_str(), 0755);
}
}
void OrderLog::open(const std::string& path) {
ensure_parent_dir(path);
fp_ = std::fopen(path.c_str(), "a");
}
void OrderLog::close() {
if (fp_) { std::fclose(fp_); fp_ = nullptr; }
}
void OrderLog::log_order(const std::string& order_json, const std::string& external_id) {
std::lock_guard<std::mutex> lock(mutex_);
sequence_++;
if (fp_) {
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
std::tm tm_buf{};
localtime_r(&t, &tm_buf);
char ts[32];
std::strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%S", &tm_buf);
std::fprintf(fp_, "%s #%d externalId=%s %s\n",
ts, sequence_, external_id.c_str(), order_json.c_str());
std::fflush(fp_);
}
}

View File

@@ -0,0 +1,17 @@
#pragma once
#include <cstdio>
#include <string>
#include <mutex>
class OrderLog {
public:
void open(const std::string& path);
void close();
void log_order(const std::string& order_json, const std::string& external_id = "");
private:
FILE* fp_ = nullptr;
std::mutex mutex_;
int sequence_ = 0;
};
extern OrderLog g_order_log;

View File

@@ -0,0 +1,27 @@
#include "pairing.hpp"
#include <chrono>
static uint64_t now_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
void PairingStore::set_pairing_code(const std::string& code, const std::string& name) {
auth_codes_[code] = {code, name, now_ms()};
}
void PairingStore::revoke_pairing_code(const std::string& code) {
auth_codes_.erase(code);
}
bool PairingStore::has_pairing_code(const std::string& code) const {
return auth_codes_.count(code) > 0;
}
void PairingStore::register_device(const std::string& token, const std::string& name) {
paired_devices_[token] = {name, token, now_ms()};
}
const std::unordered_map<std::string, DeviceEntry>& PairingStore::get_paired_devices() const {
return paired_devices_;
}

View File

@@ -0,0 +1,33 @@
#pragma once
// In-memory pairing store - trivial port of src/pairing.js
#include <string>
#include <unordered_map>
#include <cstdint>
struct PairingEntry {
std::string code;
std::string name;
uint64_t created_at;
};
struct DeviceEntry {
std::string name;
std::string token;
uint64_t created_at;
};
class PairingStore {
public:
void set_pairing_code(const std::string& code, const std::string& name = "JTL-POS");
void revoke_pairing_code(const std::string& code);
bool has_pairing_code(const std::string& code) const;
void register_device(const std::string& token, const std::string& name = "JTL-POS");
const std::unordered_map<std::string, DeviceEntry>& get_paired_devices() const;
private:
std::unordered_map<std::string, PairingEntry> auth_codes_;
std::unordered_map<std::string, DeviceEntry> paired_devices_;
};

View File

@@ -0,0 +1,60 @@
#pragma once
#include "../db/pool.hpp"
#include "nlohmann/json.hpp"
#include "shop.hpp"
#include "../http.hpp"
#include "../config.hpp"
inline nlohmann::json get_category_list(int64_t cursor, int limit) {
int root = config::get_int("ROOT_CATEGORY_ID", 1);
int lang = config::get_int("LANGUAGE_ID", 1);
int shop = get_active_shop_id();
std::string sql =
"WITH CategoryTree AS ("
" SELECT kKategorie FROM dbo.tKategorie WHERE kKategorie = ?"
" UNION ALL"
" SELECT t.kKategorie FROM dbo.tKategorie t"
" INNER JOIN CategoryTree ct ON t.kOberKategorie = ct.kKategorie"
") SELECT TOP (?) k.kKategorie AS id, k.kOberKategorie AS pid, "
"k.nSort AS sort, ks.cName AS name, b.cHash AS imgHash, "
"CONVERT(BIGINT, k.bRowversion) AS lastChanged "
"FROM dbo.tKategorie k "
"INNER JOIN dbo.tKategorieSprache ks ON ks.kKategorie = k.kKategorie AND ks.kSprache = ? "
"LEFT JOIN dbo.tKategoriebildPlattform kbp ON kbp.kKategorie = k.kKategorie "
"LEFT JOIN dbo.tBild b ON b.kBild = kbp.kBild "
"WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree WHERE kKategorie <> ?) "
"AND k.cAktiv = 'Y' "
"AND (? = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks2 "
"WHERE ks2.kKategorie = k.kKategorie AND ks2.kShop = ?)) "
"AND CONVERT(BIGINT, k.bRowversion) > ? "
"ORDER BY lastChanged ASC";
std::vector<Param> ps = {
{ParamType::BigInt,"",root},{ParamType::Int,"",limit},
{ParamType::Int,"",lang},{ParamType::BigInt,"",root},
{ParamType::Int,"",shop},{ParamType::Int,"",shop},
{ParamType::BigInt,"",cursor}
};
ResultSet rs;
get_pool().execute(sql, ps, rs);
auto ts = server_timestamp();
nlohmann::json result = nlohmann::json::array();
for (auto& row : rs) {
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)},
{"imgsrc", row[4].str.empty() ? nullptr : nlohmann::json(row[4].str)},
{"name", row[3].str},
{"pid", pid},
{"discounts", nlohmann::json::array()},
{"sort", row[2].str},
{"lastChanged", row[5].str},
{"updated_at", ts},
{"created_at", ts}
});
}
return result;
}

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

@@ -0,0 +1,40 @@
#pragma once
#include "../db/pool.hpp"
#include "nlohmann/json.hpp"
#include "shop.hpp"
inline nlohmann::json get_composite_product_list(int64_t cursor, int limit) {
int shop = get_active_shop_id();
std::string sql =
"SELECT TOP (?) s.kVaterArtikel AS productId, "
"s.kArtikel AS productIdComponent, "
"CONVERT(VARCHAR(20), s.fAnzahl, 2) AS quantity, "
"CONVERT(BIGINT, a.bRowversion) AS lastChanged "
"FROM dbo.tStueckliste s "
"INNER JOIN dbo.tArtikel a ON a.kArtikel = s.kVaterArtikel "
"WHERE a.kStueckliste <> 0 "
"AND (? = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieArtikel ka "
"INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie "
"AND ks.kShop = ? WHERE ka.kArtikel = a.kArtikel)) "
"AND CONVERT(BIGINT, a.bRowversion) > ? "
"ORDER BY lastChanged ASC";
std::vector<Param> ps = {
{ParamType::Int,"",limit},
{ParamType::Int,"",shop},
{ParamType::Int,"",shop},
{ParamType::BigInt,"",cursor}
};
ResultSet rs;
get_pool().execute(sql, ps, rs);
nlohmann::json result = nlohmann::json::array();
for (auto& row : rs) {
result.push_back({
{"productId", row[0].str},
{"productIdComponent", row[1].str},
{"quantity", row[2].str},
{"lastChanged", row[3].str}
});
}
return result;
}

View File

@@ -0,0 +1,74 @@
#pragma once
#include "../db/pool.hpp"
static const char* CATEGORY_COUNT_SQL =
"WITH CategoryTree AS ("
" SELECT kKategorie FROM dbo.tKategorie WHERE kKategorie = ?"
" UNION ALL"
" SELECT t.kKategorie FROM dbo.tKategorie t"
" INNER JOIN CategoryTree ct ON t.kOberKategorie = ct.kKategorie"
") SELECT COUNT(*) AS cnt FROM dbo.tKategorie k "
"WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree) "
"AND (? = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks "
"WHERE ks.kKategorie = k.kKategorie AND ks.kShop = ?)) "
"AND CONVERT(BIGINT, k.bRowversion) > ?";
static const char* PRODUCT_COUNT_SQL =
"WITH CategoryTree AS ("
" SELECT kKategorie FROM dbo.tKategorie WHERE kKategorie = ?"
" UNION ALL"
" SELECT t.kKategorie FROM dbo.tKategorie t"
" INNER JOIN CategoryTree ct ON t.kOberKategorie = ct.kKategorie"
") SELECT COUNT(DISTINCT a.kArtikel) AS cnt FROM dbo.tArtikel a "
"INNER JOIN dbo.tKategorieArtikel ka ON ka.kArtikel = a.kArtikel "
"WHERE a.cAktiv = 'Y' "
"AND ka.kKategorie IN (SELECT kKategorie FROM CategoryTree) "
"AND (? = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks "
"WHERE ks.kKategorie = ka.kKategorie AND ks.kShop = ?)) "
"AND (CONVERT(BIGINT, a.bRowversion) > ? "
"OR EXISTS (SELECT 1 FROM dbo.tArtikelbildPlattform abp "
"WHERE abp.kArtikel = a.kArtikel AND abp.kShop = ? "
"AND CONVERT(BIGINT, abp.bRowversion) > ?))";
static const char* COMPOSITE_PRODUCT_COUNT_SQL =
"SELECT COUNT(DISTINCT a.kArtikel) AS cnt FROM dbo.tArtikel a "
"INNER JOIN dbo.tStueckliste s ON s.kStueckliste = a.kStueckliste "
"WHERE a.kStueckliste <> 0 "
"AND (? = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieArtikel ka "
"INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie "
"AND ks.kShop = ? WHERE ka.kArtikel = a.kArtikel)) "
"AND CONVERT(BIGINT, a.bRowversion) > ?";
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::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::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::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

@@ -0,0 +1,57 @@
#pragma once
#include "../db/pool.hpp"
#include "../log.hpp"
#include "../http.hpp"
#include "nlohmann/json.hpp"
static const char* CUSTOMER_GROUP_IDS_SQL =
"SELECT kKundenGruppe FROM dbo.tKundenGruppe ORDER BY kKundenGruppe";
static const char* CUSTOMER_GROUP_LIST_SQL =
"SELECT kKundenGruppe AS id, cName AS name, nStandard AS standard, "
"fRabatt AS discountPercent, CONVERT(BIGINT, bRowversion) AS lastChanged "
"FROM dbo.tKundenGruppe WHERE CONVERT(BIGINT, bRowversion) > ? "
"ORDER BY lastChanged ASC";
static const char* CUSTOMER_GROUP_COUNT_SQL =
"SELECT COUNT(*) AS cnt FROM dbo.tKundenGruppe "
"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;
if (!get_pool().execute(CUSTOMER_GROUP_IDS_SQL, rs)) {
logc::warn("failed to load customer group ids");
return {};
}
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) {
Param p; p.type = ParamType::BigInt; p.int_val = cursor;
ResultSet rs;
get_pool().execute(CUSTOMER_GROUP_LIST_SQL, {p}, rs);
nlohmann::json result = nlohmann::json::array();
for (auto& row : rs) {
result.push_back({
{"customerGroupId", row[0].str},
{"name", row[1].str},
{"standard", row[2].str},
{"discountPercent", std::to_string(parse_double(row[3].str, 0))},
{"lastChanged", row[4].str}
});
}
return result;
}
inline int64_t get_customer_group_count(int64_t cursor = 0) {
Param p; p.type = ParamType::BigInt; p.int_val = cursor;
return get_pool().execute_scalar(CUSTOMER_GROUP_COUNT_SQL, {p});
}

View File

@@ -0,0 +1,28 @@
#pragma once
#include "../db/pool.hpp"
#include "nlohmann/json.hpp"
inline nlohmann::json get_deleted_entity_list(int64_t cursor, int limit) {
std::string sql =
"SELECT TOP (?) vDeletedEntity.kEntityId, "
"vDeletedEntity.nEntityType, "
"CONVERT(BIGINT, vDeletedEntity.bLastChanged) AS lastChanged "
"FROM Pos.vDeletedEntity "
"WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > ? "
"ORDER BY lastChanged ASC";
std::vector<Param> ps = {
{ParamType::Int,"",limit},
{ParamType::BigInt,"",cursor}
};
ResultSet rs;
get_pool().execute(sql, ps, rs);
nlohmann::json result = nlohmann::json::array();
for (auto& row : rs) {
result.push_back({
{"entityId", row[0].str},
{"entityType", row[1].str},
{"lastChanged", row[2].str}
});
}
return result;
}

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

@@ -0,0 +1,133 @@
#pragma once
#include "../db/pool.hpp"
#include "../log.hpp"
#include "../http.hpp"
#include <string>
#include <vector>
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <vips/vips.h>
struct ImageResult {
std::vector<uint8_t> buffer;
std::string content_type;
};
static std::string content_type_for(const std::string& quelle) {
size_t dot = quelle.rfind('.');
std::string ext = (dot != std::string::npos) ? quelle.substr(dot + 1) : "";
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
if (ext == "png") return "image/png";
if (ext == "gif") return "image/gif";
if (ext == "webp") return "image/webp";
return "image/jpeg";
}
static std::string ext_for_content_type(const std::string& ct) {
if (ct == "image/png") return ".png";
if (ct == "image/gif") return ".gif";
if (ct == "image/webp") return ".webp";
return ".jpg";
}
static ImageResult resize_image(const uint8_t* data, size_t len,
int target_size, const std::string& content_type) {
std::string fmt = ext_for_content_type(content_type);
// Use vips_thumbnail_buffer for fast shrink-on-load
VipsImage* out = nullptr;
if (vips_thumbnail_buffer((void*)data, len, &out, target_size,
"height", target_size,
"no_rotate", TRUE,
nullptr)) {
logc::warn("vips_thumbnail_buffer failed (%d): %s",
vips_error_buffer(), vips_error_buffer());
vips_error_clear();
return {};
}
// Write to memory buffer
void* buf = nullptr;
size_t buf_len = 0;
if (vips_image_write_to_buffer(out, fmt.c_str(), &buf, &buf_len, nullptr)) {
logc::warn("vips: failed to write resized image");
g_object_unref(out);
return {};
}
ImageResult result;
result.buffer.assign((uint8_t*)buf, (uint8_t*)buf + buf_len);
result.content_type = content_type;
g_free(buf);
g_object_unref(out);
return result;
}
inline ImageResult get_image_by_hash(const std::string& hash, const std::string& size) {
std::string sql =
"SELECT bBild, bVorschauBild, nBreite, nHoehe, "
"nVorschauBreite, nVorschauHoehe, cQuelle "
"FROM dbo.tBild WHERE cHash = ?";
Param p; p.type = ParamType::NVarChar; p.str_val = hash;
ResultSet rs;
bool ok = get_pool().execute(sql, {p}, rs);
if (!ok) {
logc::warn("image query failed for hash=%s", hash.c_str());
return {};
}
if (rs.empty()) {
logc::warn("image not found for hash=%s (query ok, 0 rows)", hash.c_str());
return {};
}
auto& row = rs[0];
int target = parse_int(size, 200);
std::string ct = content_type_for(row[6].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();
bool has_preview = !row[1].blob.empty();
if (target <= 0) {
if (!has_full) return {};
ImageResult r;
r.buffer = std::move(row[0].blob);
r.content_type = ct;
return r;
}
// If preview exists and target fits within preview, resize from preview
if (has_preview && preview_max > 0 && target <= preview_max) {
auto r = resize_image(row[1].blob.data(), row[1].blob.size(), target, ct);
if (!r.buffer.empty()) return r;
// Fallback: raw preview
r.buffer = std::move(row[1].blob);
r.content_type = ct;
return r;
}
// Otherwise resize from full image
if (has_full) {
auto r = resize_image(row[0].blob.data(), row[0].blob.size(), target, ct);
if (!r.buffer.empty()) return r;
// Fallback: raw full
r.buffer = std::move(row[0].blob);
r.content_type = ct;
return r;
}
// Last resort: raw preview
if (has_preview) {
ImageResult r;
r.buffer = std::move(row[1].blob);
r.content_type = ct;
return r;
}
return {};
}

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

@@ -0,0 +1,190 @@
#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>
static const char* PRODUCT_LIST_SQL =
"WITH TaxRates AS ("
" SELECT kSteuerklasse, fSteuersatz FROM dbo.tSteuersatz"
" WHERE kSteuerzone IN (SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = ?)"
"), ImageRV AS ("
" SELECT kArtikel, MAX(CONVERT(BIGINT, bRowversion)) AS maxImageRV"
" FROM dbo.tArtikelbildPlattform WHERE kShop = ? GROUP BY kArtikel"
") SELECT TOP (?) "
"a.kArtikel AS id, a.cArtNr AS sku, ab.cName AS name, "
"a.fVKNetto AS netPrice, tr.fSteuersatz AS taxRate, "
"a.dErstelldatum AS createdAt, "
"CASE WHEN ir.maxImageRV IS NOT NULL AND ir.maxImageRV > CONVERT(BIGINT, a.bRowversion) "
"THEN ir.maxImageRV ELSE CONVERT(BIGINT, a.bRowversion) END AS lastChanged, "
"(SELECT TOP 1 img.cHash FROM dbo.tArtikelbildPlattform abp "
"INNER JOIN dbo.tBild img ON img.kBild = abp.kBild "
"WHERE abp.kArtikel = a.kArtikel ORDER BY abp.nNr) AS imgHash, "
"(SELECT STRING_AGG(CAST(ka.kKategorie AS varchar(20)), ',') "
"FROM dbo.tkategorieartikel ka WHERE ka.kArtikel = a.kArtikel) AS categoryIds, "
"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, "
"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 "
"LEFT JOIN ImageRV ir ON ir.kArtikel = a.kArtikel "
"WHERE a.cAktiv = 'Y' "
"AND (? = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieArtikel ka "
"INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie AND ks.kShop = ? "
"WHERE ka.kArtikel = a.kArtikel)) "
"AND (CONVERT(BIGINT, a.bRowversion) > ? "
"OR (ir.maxImageRV IS NOT NULL AND ir.maxImageRV > ?)) "
"ORDER BY lastChanged ASC";
static std::string gross_price(const std::string& net, const std::string& 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;
}
inline nlohmann::json get_product_list(int64_t cursor, int limit) {
std::string tax_zone = config::get("TAX_ZONE_NAME", "Zone-EU");
int lang = config::get_int("LANGUAGE_ID", 1);
int shop = get_active_shop_id();
std::vector<Param> ps = {
{ParamType::NVarChar, tax_zone, 0},
{ParamType::Int,"",shop},
{ParamType::Int,"",limit},
{ParamType::Int,"",lang},
{ParamType::Int,"",shop},
{ParamType::Int,"",shop},
{ParamType::BigInt,"",cursor},
{ParamType::BigInt,"",cursor}
};
ResultSet 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) {
std::string base_price = gross_price(row[3].str, row[4].str);
std::string cats_raw = row[8].str;
std::vector<std::string> cat_ids;
if (!cats_raw.empty()) {
size_t pos = 0;
while ((pos = cats_raw.find(',')) != std::string::npos) {
cat_ids.push_back(cats_raw.substr(0, pos));
cats_raw.erase(0, pos + 1);
}
cat_ids.push_back(cats_raw);
}
nlohmann::json cats = nlohmann::json::array();
for (auto& c : cat_ids) cats.push_back({{"categoryId", c}});
nlohmann::json prices = nlohmann::json::array();
for (auto& cgid : cg_ids) {
prices.push_back({
{"customerGroupId", std::to_string(cgid)},
{"customerId", "0"},
{"price", base_price},
{"quantity", "0"}
});
}
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(parse_double(row[4].str, 0)))},
{"price", base_price},
{"created_at", row[5].str},
{"lastChanged", row[6].str},
{"categories_id", cat_ids.empty() ? "0" : cat_ids[0]},
{"categories", cats},
{"prices", prices},
{"is_parent", row[9].str == "1" ? "1" : "0"},
{"parent", parse_int64(row[10].str, 0) > 0 ? row[10].str : "0"},
{"variants", row[12].str},
{"isCompositeProduct", row[11].str},
{"attributes", std::move(attributes)},
{"sort", "0"},
{"p_price", "0.00"},
{"discountable", "0"},
{"deposit", deposit},
{"discount", ""},
{"d_price", d_price},
{"tax_rate2", ""},
{"use_in_out_tax", "0"},
{"barcode", row[13].str.empty() ? nullptr : nlohmann::json(row[13].str)},
{"use_stock", "0"},
{"q_div", "0"},
{"quantity", "0"},
{"unit", nullptr},
{"single_bookable", "0"},
{"annotation", ""},
{"status", "0"},
{"tags", ""},
{"variants", row[12].str},
{"print_kitchen_receipt", "0"},
{"deposit_name", deposit_name},
{"updated_at", "0001-01-01 00:00:00"},
{"configurationGroups", ""},
{"options", nullptr},
{"hasBestBeforeDate", "0"},
{"hasLotNumber", "0"},
{"hasSerialNumber", "0"},
{"PLU", ""},
{"short_description", ""},
{"minStock", "0"},
{"container", nlohmann::json::array()},
{"reservedQuantity", "0.00"},
{"deliveryDetails", nlohmann::json::array()},
{"isbn", ""},
{"manufacturerName", nullptr},
{"han", nullptr},
{"productType", "0"},
{"voucherData", nullptr},
{"inputPrice", "0"},
{"inputQuantity", "0"}
};
result.push_back(std::move(product));
}
return result;
}

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

@@ -0,0 +1,10 @@
#pragma once
#include "../db/pool.hpp"
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; }
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

@@ -0,0 +1,40 @@
#include "request_log.hpp"
#include "log.hpp"
#include <chrono>
#include <ctime>
#include <sys/stat.h>
static void ensure_parent_dir(const std::string& path) {
size_t pos = path.rfind('/');
if (pos != std::string::npos) {
mkdir(path.substr(0, pos).c_str(), 0755);
}
}
void RequestLog::open(const std::string& path) {
ensure_parent_dir(path);
fp_ = std::fopen(path.c_str(), "a");
if (!fp_) logc::warn("cannot open request log: %s", path.c_str());
}
void RequestLog::close() {
if (fp_) { std::fclose(fp_); fp_ = nullptr; }
}
void RequestLog::log(const std::string& remote, const std::string& method,
const std::string& url, int status, int duration_ms,
const std::string& response) {
if (!fp_) return;
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
std::tm tm_buf{};
localtime_r(&t, &tm_buf);
char ts[32];
std::strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%S", &tm_buf);
std::lock_guard<std::mutex> lock(mutex_);
std::fprintf(fp_, "%s %s %s %s %d %dms %s\n",
ts, remote.c_str(), method.c_str(), url.c_str(),
status, duration_ms, response.c_str());
std::fflush(fp_);
}

View File

@@ -0,0 +1,16 @@
#pragma once
#include <cstdio>
#include <string>
#include <mutex>
class RequestLog {
public:
void open(const std::string& path);
void close();
void log(const std::string& remote, const std::string& method,
const std::string& url, int status, int duration_ms,
const std::string& response);
private:
FILE* fp_ = nullptr;
std::mutex mutex_;
};

74
jtlsrv-cpp/src/router.cpp Normal file
View File

@@ -0,0 +1,74 @@
#include "router.hpp"
#include "log.hpp"
#include <exception>
void Router::add_route(const std::string& method, const std::string& path, Handler handler) {
std::string key = method + " " + path;
routes_[key] = std::move(handler);
}
void Router::dispatch(tls_session* sess, PairingStore& pairing, const json& config) {
auto& req = sess->current_request;
auto& resp = sess->current_response;
// Build the full URL path (without host) for query param access
std::string full_url = req.path;
if (!req.query_string.empty()) {
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()) {
RouteContext ctx;
ctx.query_path = req.path;
ctx.full_url = full_url;
ctx.pairing_store = &pairing;
ctx.config = config;
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;
}
// 404
resp.send_json(404, {
{"Message", "No HTTP resource was found that matches the request URI '" + full_url + "'."}
});
}
// 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_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();
}

34
jtlsrv-cpp/src/router.hpp Normal file
View File

@@ -0,0 +1,34 @@
#pragma once
// Router: maps "METHOD /path" -> handler. Port of src/jtl-server.js.
#include "http.hpp"
#include "pairing.hpp"
#include "tls_server.hpp"
#include <functional>
#include <string>
#include <unordered_map>
class Router {
public:
using Handler = RouteHandler;
void add_route(const std::string& method, const std::string& path, Handler handler);
// Dispatch a request. Called from the TLS on_request callback.
void dispatch(tls_session* sess, PairingStore& pairing, const json& config);
// 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_logged_init_url_;
int suppressed_init_count_ = 0;
};

View File

@@ -0,0 +1,383 @@
// tls_server.cpp — OpenSSL memory-BIOs pumped over uv_tcp_t + llhttp.
//
// This is the genuinely new piece compared to the Node.js original (~300 lines).
// Pattern: accept TCP -> SSL_new with mem BIOs -> uv_read_start feeds encrypted
// bytes into rbio -> SSL_read drains plaintext into llhttp -> SSL_write puts
// response plaintext into wbio -> flush wbio to socket.
#include "tls_server.hpp"
#include "log.hpp"
#include <cstring>
// ---------------------------------------------------------------------------
// Globals
// ---------------------------------------------------------------------------
static uv_loop_t* g_loop = nullptr;
static SSL_CTX* g_ssl_ctx = nullptr;
static uv_tcp_t g_server{};
static uv_signal_t g_sigint{};
static uv_signal_t g_sigterm{};
static void (*g_on_request)(tls_session*) = nullptr;
// ---------------------------------------------------------------------------
// Forward declarations for signal shutdown
// ---------------------------------------------------------------------------
static void tls_server_shutdown();
// ---------------------------------------------------------------------------
// SSL error logging helper
// ---------------------------------------------------------------------------
static void ssl_log_errors(const char* ctx) {
unsigned long e;
while ((e = ERR_get_error()) != 0) {
char buf[256];
ERR_error_string_n(e, buf, sizeof(buf));
logc::warn("[%s] SSL: %s", ctx, buf);
}
}
// ---------------------------------------------------------------------------
// Encrypted data flush: wbio -> socket
// ---------------------------------------------------------------------------
static void flush_encrypted(tls_session* sess) {
char buf[16384];
for (;;) {
int n = BIO_read(sess->wbio, buf, sizeof(buf));
if (n <= 0) break;
auto* req = new uv_write_t{};
char* data = new char[n];
std::memcpy(data, buf, n);
uv_buf_t wbuf = uv_buf_init(data, n);
req->data = data;
uv_write(req, reinterpret_cast<uv_stream_t*>(&sess->tcp_handle),
&wbuf, 1, [](uv_write_t* r, int) {
delete[] static_cast<char*>(r->data);
delete r;
});
}
}
// ---------------------------------------------------------------------------
// Drain SSL plaintext -> llhttp
// ---------------------------------------------------------------------------
static void drain_ssl_to_llhttp(tls_session* sess) {
char buf[16384];
for (;;) {
int n = SSL_read(sess->ssl, buf, sizeof(buf));
if (n <= 0) break;
llhttp_errno_t err = llhttp_execute(&sess->parser, buf, n);
if (err != HPE_OK && err != HPE_PAUSED) {
logc::warn("llhttp: %s", llhttp_errno_name(err));
session_close(sess);
return;
}
}
}
// ---------------------------------------------------------------------------
// llhttp callbacks (store parsed data into the tls_session's HttpRequest)
// ---------------------------------------------------------------------------
static int cb_begin(llhttp_t* p) {
auto* s = static_cast<tls_session*>(p->data);
s->current_request = HttpRequest{};
return 0;
}
static int cb_method(llhttp_t* p, const char* at, size_t len) {
auto* s = static_cast<tls_session*>(p->data);
s->current_request.method.assign(at, len);
return 0;
}
static int cb_url(llhttp_t* p, const char* at, size_t len) {
auto* s = static_cast<tls_session*>(p->data);
std::string raw(at, len);
size_t q = raw.find('?');
if (q != std::string::npos) {
s->current_request.query_string = raw.substr(q + 1);
s->current_request.path = raw.substr(0, q);
} else {
s->current_request.path = raw;
}
return 0;
}
static int cb_header_field(llhttp_t* p, const char* at, size_t len) {
auto* s = static_cast<tls_session*>(p->data);
s->current_header_field.assign(at, len);
return 0;
}
static int cb_header_value(llhttp_t* p, const char* at, size_t len) {
auto* s = static_cast<tls_session*>(p->data);
s->current_request.headers[s->current_header_field].assign(at, len);
return 0;
}
static int cb_headers_complete(llhttp_t* p) {
auto* s = static_cast<tls_session*>(p->data);
auto it = s->current_request.headers.find("Content-Length");
if (it != s->current_request.headers.end()) {
s->body_length = static_cast<uint32_t>(std::stoul(it->second));
}
return 0;
}
static int cb_body(llhttp_t* p, const char* at, size_t len) {
auto* s = static_cast<tls_session*>(p->data);
auto& body = s->current_request.body;
body.insert(body.end(),
reinterpret_cast<const uint8_t*>(at),
reinterpret_cast<const uint8_t*>(at + len));
return 0;
}
static int cb_message_complete(llhttp_t* p) {
auto* s = static_cast<tls_session*>(p->data);
s->current_response = HttpResponse{};
s->current_response.session = s;
// Normalize /api/v1 -> /v1
s->current_request.path = normalize_path(s->current_request.path);
// Reset idle timer
uv_timer_stop(&s->timer_handle);
uv_timer_start(&s->timer_handle,
[](uv_timer_t* h) {
session_close(static_cast<tls_session*>(h->data));
}, 300'000, 0);
if (g_on_request) g_on_request(s);
return 0;
}
static void init_parser(tls_session* s) {
llhttp_settings_init(&s->parser_settings);
s->parser_settings.on_message_begin = cb_begin;
s->parser_settings.on_method = cb_method;
s->parser_settings.on_url = cb_url;
s->parser_settings.on_header_field = cb_header_field;
s->parser_settings.on_header_value = cb_header_value;
s->parser_settings.on_headers_complete = cb_headers_complete;
s->parser_settings.on_body = cb_body;
s->parser_settings.on_message_complete = cb_message_complete;
llhttp_init(&s->parser, HTTP_REQUEST, &s->parser_settings);
s->parser.data = s;
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
static void on_alloc(uv_handle_t*, size_t suggested, uv_buf_t* buf) {
buf->base = new char[suggested];
buf->len = suggested;
}
static void on_read(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) {
auto* sess = static_cast<tls_session*>(stream->data);
if (nread > 0 && sess->ssl) {
// Feed encrypted bytes into the read BIO
BIO_write(sess->rbio, buf->base, nread);
// TLS handshake (may need multiple rounds)
if (!sess->handshake_done) {
int ret = SSL_do_handshake(sess->ssl);
if (ret == 1) {
sess->handshake_done = true;
flush_encrypted(sess);
} else {
int err = SSL_get_error(sess->ssl, ret);
if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE) {
flush_encrypted(sess);
delete[] buf->base;
return;
}
ssl_log_errors("handshake");
delete[] buf->base;
session_close(sess);
return;
}
}
// Read plaintext and pump through llhttp
drain_ssl_to_llhttp(sess);
}
if (nread < 0 && nread != UV_EOF) {
logc::info("client disconnected: %s", uv_strerror(static_cast<int>(nread)));
}
if (nread < 0) {
delete[] buf->base;
session_close(sess);
return;
}
delete[] buf->base;
}
static void on_close(uv_handle_t* handle) {
auto* sess = static_cast<tls_session*>(handle->data);
if (sess->ssl) { SSL_free(sess->ssl); sess->ssl = nullptr; }
delete sess;
}
// ---------------------------------------------------------------------------
// New connection
// ---------------------------------------------------------------------------
static void on_connection(uv_stream_t* server, int status) {
if (status < 0) {
logc::error("accept: %s", uv_strerror(status));
return;
}
auto* sess = new tls_session{};
sess->tcp_handle.data = sess;
uv_tcp_init(g_loop, &sess->tcp_handle);
if (uv_accept(server, reinterpret_cast<uv_stream_t*>(&sess->tcp_handle)) != 0) {
uv_close(reinterpret_cast<uv_handle_t*>(&sess->tcp_handle), on_close);
return;
}
uv_tcp_nodelay(&sess->tcp_handle, 1);
// OpenSSL
sess->ssl = SSL_new(g_ssl_ctx);
sess->rbio = BIO_new(BIO_s_mem());
sess->wbio = BIO_new(BIO_s_mem());
SSL_set_bio(sess->ssl, sess->rbio, sess->wbio);
SSL_set_accept_state(sess->ssl);
// llhttp
init_parser(sess);
// Peer address
struct sockaddr_storage saddr;
int slen = sizeof(saddr);
uv_tcp_getpeername(&sess->tcp_handle, reinterpret_cast<struct sockaddr*>(&saddr), &slen);
char addr_buf[INET6_ADDRSTRLEN] = {};
if (saddr.ss_family == AF_INET)
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));
sess->peer_ip = addr_buf;
// Read encrypted data
uv_read_start(reinterpret_cast<uv_stream_t*>(&sess->tcp_handle), on_alloc, on_read);
// Idle timer
uv_timer_init(g_loop, &sess->timer_handle);
sess->timer_handle.data = sess;
uv_timer_start(&sess->timer_handle,
[](uv_timer_t* h) {
logc::info("idle timeout");
session_close(static_cast<tls_session*>(h->data));
}, 300'000, 0);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
void tls_server_set_handler(void (*handler)(tls_session*)) {
g_on_request = handler;
}
int tls_server_init(uv_loop_t* loop, const char* host, int port,
const char* cert_path, const char* key_path) {
g_loop = loop;
g_ssl_ctx = SSL_CTX_new(TLS_server_method());
if (!g_ssl_ctx) { ssl_log_errors("SSL_CTX_new"); return -1; }
SSL_CTX_set_min_proto_version(g_ssl_ctx, TLS1_2_VERSION);
if (SSL_CTX_use_certificate_chain_file(g_ssl_ctx, cert_path) != 1) {
ssl_log_errors("cert"); return -1;
}
if (SSL_CTX_use_PrivateKey_file(g_ssl_ctx, key_path, SSL_FILETYPE_PEM) != 1) {
ssl_log_errors("key"); return -1;
}
if (SSL_CTX_check_private_key(g_ssl_ctx) != 1) {
ssl_log_errors("check_key"); return -1;
}
uv_tcp_init(loop, &g_server);
g_server.data = nullptr;
struct sockaddr_in addr;
uv_ip4_addr(host, port, &addr);
int r = uv_tcp_bind(&g_server, reinterpret_cast<struct sockaddr*>(&addr), 0);
if (r) { logc::error("bind: %s", uv_strerror(r)); return r; }
r = uv_listen(reinterpret_cast<uv_stream_t*>(&g_server), 128, on_connection);
if (r) { logc::error("listen: %s", uv_strerror(r)); return r; }
logc::success("HTTPS server listening on %s:%d", host, port);
return 0;
}
static void tls_server_shutdown() {
logc::info("shutting down...");
uv_signal_stop(&g_sigint);
uv_signal_stop(&g_sigterm);
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) {
uv_signal_init(loop, &g_sigint);
uv_signal_start(&g_sigint, [](uv_signal_t*, int) { tls_server_shutdown(); }, SIGINT);
uv_signal_init(loop, &g_sigterm);
uv_signal_start(&g_sigterm, [](uv_signal_t*, int) { tls_server_shutdown(); }, SIGTERM);
}
void session_write(tls_session* sess, const std::string& data) {
if (!sess->ssl || sess->tcp_handle.type != UV_TCP) return;
SSL_write(sess->ssl, data.data(), data.size());
flush_encrypted(sess);
}
void session_write_binary(tls_session* sess, const std::string& header,
const std::vector<uint8_t>& data) {
if (!sess->ssl || sess->tcp_handle.type != UV_TCP) return;
SSL_write(sess->ssl, header.data(), header.size());
if (!data.empty())
SSL_write(sess->ssl, data.data(), data.size());
flush_encrypted(sess);
}
void session_close(tls_session* sess) {
if (sess->tcp_handle.type != UV_TCP) return;
uv_timer_stop(&sess->timer_handle);
uv_close(reinterpret_cast<uv_handle_t*>(&sess->timer_handle), nullptr);
uv_close(reinterpret_cast<uv_handle_t*>(&sess->tcp_handle), on_close);
}
const char* reason_phrase(int code) {
switch (code) {
case 200: return "OK";
case 400: return "Bad Request";
case 404: return "Not Found";
case 500: return "Internal Server Error";
default: return "Unknown";
}
}

View File

@@ -0,0 +1,67 @@
#pragma once
// TLS server: OpenSSL memory-BIOs pumped over uv_tcp_t + llhttp for HTTP parsing.
// This is the genuinely new piece compared to the Node.js original.
#include <cstdint>
#include <string>
#include <vector>
#include <uv.h>
#include <openssl/ssl.h>
#include <openssl/err.h>
#include <llhttp.h>
#include "http.hpp"
// ---------------------------------------------------------------------------
// Per-connection TLS session
// ---------------------------------------------------------------------------
struct tls_session {
uv_tcp_t tcp_handle{};
uv_timer_t timer_handle{};
SSL* ssl = nullptr;
BIO* rbio = nullptr; // we feed encrypted data here
BIO* wbio = nullptr; // we read encrypted data from here
llhttp_t parser{};
llhttp_settings_t parser_settings{};
HttpRequest current_request{};
HttpResponse current_response{};
std::string current_header_field{}; // tracks header name during parsing
bool handshake_done = false;
uint32_t body_length = 0;
std::string peer_ip;
};
// ---------------------------------------------------------------------------
// Server API
// ---------------------------------------------------------------------------
// Initialize the OpenSSL context and start listening.
// Returns 0 on success.
int tls_server_init(uv_loop_t* loop, const char* host, int port,
const char* cert_path, const char* key_path);
// Set the callback invoked for each fully-parsed HTTP request.
void tls_server_set_handler(void (*handler)(tls_session*));
// Write an encrypted response buffer to the client.
void session_write(tls_session* sess, const std::string& data);
// Write an encrypted response with binary payload to the client.
void session_write_binary(tls_session* sess, const std::string& header,
const std::vector<uint8_t>& data);
// Get the reason phrase for an HTTP status code.
const char* reason_phrase(int code);
// Close a TLS session cleanly.
void session_close(tls_session* sess);
// Install SIGINT/SIGTERM handlers for graceful shutdown.
void tls_server_install_signals(uv_loop_t* loop);

510
jtlsrv-cpp/vendor/api.c vendored Normal file
View File

@@ -0,0 +1,510 @@
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include "llhttp.h"
#define CALLBACK_MAYBE(PARSER, NAME) \
do { \
const llhttp_settings_t* settings; \
settings = (const llhttp_settings_t*) (PARSER)->settings; \
if (settings == NULL || settings->NAME == NULL) { \
err = 0; \
break; \
} \
err = settings->NAME((PARSER)); \
} while (0)
#define SPAN_CALLBACK_MAYBE(PARSER, NAME, START, LEN) \
do { \
const llhttp_settings_t* settings; \
settings = (const llhttp_settings_t*) (PARSER)->settings; \
if (settings == NULL || settings->NAME == NULL) { \
err = 0; \
break; \
} \
err = settings->NAME((PARSER), (START), (LEN)); \
if (err == -1) { \
err = HPE_USER; \
llhttp_set_error_reason((PARSER), "Span callback error in " #NAME); \
} \
} while (0)
void llhttp_init(llhttp_t* parser, llhttp_type_t type,
const llhttp_settings_t* settings) {
llhttp__internal_init(parser);
parser->type = type;
parser->settings = (void*) settings;
}
#if defined(__wasm__)
extern int wasm_on_message_begin(llhttp_t * p);
extern int wasm_on_url(llhttp_t* p, const char* at, size_t length);
extern int wasm_on_status(llhttp_t* p, const char* at, size_t length);
extern int wasm_on_header_field(llhttp_t* p, const char* at, size_t length);
extern int wasm_on_header_value(llhttp_t* p, const char* at, size_t length);
extern int wasm_on_headers_complete(llhttp_t * p, int status_code,
uint8_t upgrade, int should_keep_alive);
extern int wasm_on_body(llhttp_t* p, const char* at, size_t length);
extern int wasm_on_message_complete(llhttp_t * p);
static int wasm_on_headers_complete_wrap(llhttp_t* p) {
return wasm_on_headers_complete(p, p->status_code, p->upgrade,
llhttp_should_keep_alive(p));
}
const llhttp_settings_t wasm_settings = {
wasm_on_message_begin,
wasm_on_url,
wasm_on_status,
NULL,
NULL,
wasm_on_header_field,
wasm_on_header_value,
NULL,
NULL,
wasm_on_headers_complete_wrap,
wasm_on_body,
wasm_on_message_complete,
NULL,
NULL,
NULL,
NULL,
NULL,
NULL,
NULL,
NULL,
NULL,
NULL,
NULL,
};
llhttp_t* llhttp_alloc(llhttp_type_t type) {
llhttp_t* parser = malloc(sizeof(llhttp_t));
llhttp_init(parser, type, &wasm_settings);
return parser;
}
void llhttp_free(llhttp_t* parser) {
free(parser);
}
#endif // defined(__wasm__)
/* Some getters required to get stuff from the parser */
uint8_t llhttp_get_type(llhttp_t* parser) {
return parser->type;
}
uint8_t llhttp_get_http_major(llhttp_t* parser) {
return parser->http_major;
}
uint8_t llhttp_get_http_minor(llhttp_t* parser) {
return parser->http_minor;
}
uint8_t llhttp_get_method(llhttp_t* parser) {
return parser->method;
}
int llhttp_get_status_code(llhttp_t* parser) {
return parser->status_code;
}
uint8_t llhttp_get_upgrade(llhttp_t* parser) {
return parser->upgrade;
}
void llhttp_reset(llhttp_t* parser) {
llhttp_type_t type = parser->type;
const llhttp_settings_t* settings = parser->settings;
void* data = parser->data;
uint16_t lenient_flags = parser->lenient_flags;
llhttp__internal_init(parser);
parser->type = type;
parser->settings = (void*) settings;
parser->data = data;
parser->lenient_flags = lenient_flags;
}
llhttp_errno_t llhttp_execute(llhttp_t* parser, const char* data, size_t len) {
return llhttp__internal_execute(parser, data, data + len);
}
void llhttp_settings_init(llhttp_settings_t* settings) {
memset(settings, 0, sizeof(*settings));
}
llhttp_errno_t llhttp_finish(llhttp_t* parser) {
int err;
/* We're in an error state. Don't bother doing anything. */
if (parser->error != 0) {
return 0;
}
switch (parser->finish) {
case HTTP_FINISH_SAFE_WITH_CB:
CALLBACK_MAYBE(parser, on_message_complete);
if (err != HPE_OK) return err;
/* FALLTHROUGH */
case HTTP_FINISH_SAFE:
return HPE_OK;
case HTTP_FINISH_UNSAFE:
parser->reason = "Invalid EOF state";
return HPE_INVALID_EOF_STATE;
default:
abort();
}
}
void llhttp_pause(llhttp_t* parser) {
if (parser->error != HPE_OK) {
return;
}
parser->error = HPE_PAUSED;
parser->reason = "Paused";
}
void llhttp_resume(llhttp_t* parser) {
if (parser->error != HPE_PAUSED) {
return;
}
parser->error = 0;
}
void llhttp_resume_after_upgrade(llhttp_t* parser) {
if (parser->error != HPE_PAUSED_UPGRADE) {
return;
}
parser->error = 0;
}
llhttp_errno_t llhttp_get_errno(const llhttp_t* parser) {
return parser->error;
}
const char* llhttp_get_error_reason(const llhttp_t* parser) {
return parser->reason;
}
void llhttp_set_error_reason(llhttp_t* parser, const char* reason) {
parser->reason = reason;
}
const char* llhttp_get_error_pos(const llhttp_t* parser) {
return parser->error_pos;
}
const char* llhttp_errno_name(llhttp_errno_t err) {
#define HTTP_ERRNO_GEN(CODE, NAME, _) case HPE_##NAME: return "HPE_" #NAME;
switch (err) {
HTTP_ERRNO_MAP(HTTP_ERRNO_GEN)
default: abort();
}
#undef HTTP_ERRNO_GEN
}
const char* llhttp_method_name(llhttp_method_t method) {
#define HTTP_METHOD_GEN(NUM, NAME, STRING) case HTTP_##NAME: return #STRING;
switch (method) {
HTTP_ALL_METHOD_MAP(HTTP_METHOD_GEN)
default: abort();
}
#undef HTTP_METHOD_GEN
}
const char* llhttp_status_name(llhttp_status_t status) {
#define HTTP_STATUS_GEN(NUM, NAME, STRING) case HTTP_STATUS_##NAME: return #STRING;
switch (status) {
HTTP_STATUS_MAP(HTTP_STATUS_GEN)
default: abort();
}
#undef HTTP_STATUS_GEN
}
void llhttp_set_lenient_headers(llhttp_t* parser, int enabled) {
if (enabled) {
parser->lenient_flags |= LENIENT_HEADERS;
} else {
parser->lenient_flags &= ~LENIENT_HEADERS;
}
}
void llhttp_set_lenient_chunked_length(llhttp_t* parser, int enabled) {
if (enabled) {
parser->lenient_flags |= LENIENT_CHUNKED_LENGTH;
} else {
parser->lenient_flags &= ~LENIENT_CHUNKED_LENGTH;
}
}
void llhttp_set_lenient_keep_alive(llhttp_t* parser, int enabled) {
if (enabled) {
parser->lenient_flags |= LENIENT_KEEP_ALIVE;
} else {
parser->lenient_flags &= ~LENIENT_KEEP_ALIVE;
}
}
void llhttp_set_lenient_transfer_encoding(llhttp_t* parser, int enabled) {
if (enabled) {
parser->lenient_flags |= LENIENT_TRANSFER_ENCODING;
} else {
parser->lenient_flags &= ~LENIENT_TRANSFER_ENCODING;
}
}
void llhttp_set_lenient_version(llhttp_t* parser, int enabled) {
if (enabled) {
parser->lenient_flags |= LENIENT_VERSION;
} else {
parser->lenient_flags &= ~LENIENT_VERSION;
}
}
void llhttp_set_lenient_data_after_close(llhttp_t* parser, int enabled) {
if (enabled) {
parser->lenient_flags |= LENIENT_DATA_AFTER_CLOSE;
} else {
parser->lenient_flags &= ~LENIENT_DATA_AFTER_CLOSE;
}
}
void llhttp_set_lenient_optional_lf_after_cr(llhttp_t* parser, int enabled) {
if (enabled) {
parser->lenient_flags |= LENIENT_OPTIONAL_LF_AFTER_CR;
} else {
parser->lenient_flags &= ~LENIENT_OPTIONAL_LF_AFTER_CR;
}
}
void llhttp_set_lenient_optional_crlf_after_chunk(llhttp_t* parser, int enabled) {
if (enabled) {
parser->lenient_flags |= LENIENT_OPTIONAL_CRLF_AFTER_CHUNK;
} else {
parser->lenient_flags &= ~LENIENT_OPTIONAL_CRLF_AFTER_CHUNK;
}
}
void llhttp_set_lenient_optional_cr_before_lf(llhttp_t* parser, int enabled) {
if (enabled) {
parser->lenient_flags |= LENIENT_OPTIONAL_CR_BEFORE_LF;
} else {
parser->lenient_flags &= ~LENIENT_OPTIONAL_CR_BEFORE_LF;
}
}
void llhttp_set_lenient_spaces_after_chunk_size(llhttp_t* parser, int enabled) {
if (enabled) {
parser->lenient_flags |= LENIENT_SPACES_AFTER_CHUNK_SIZE;
} else {
parser->lenient_flags &= ~LENIENT_SPACES_AFTER_CHUNK_SIZE;
}
}
/* Callbacks */
int llhttp__on_message_begin(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_message_begin);
return err;
}
int llhttp__on_url(llhttp_t* s, const char* p, const char* endp) {
int err;
SPAN_CALLBACK_MAYBE(s, on_url, p, endp - p);
return err;
}
int llhttp__on_url_complete(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_url_complete);
return err;
}
int llhttp__on_status(llhttp_t* s, const char* p, const char* endp) {
int err;
SPAN_CALLBACK_MAYBE(s, on_status, p, endp - p);
return err;
}
int llhttp__on_status_complete(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_status_complete);
return err;
}
int llhttp__on_method(llhttp_t* s, const char* p, const char* endp) {
int err;
SPAN_CALLBACK_MAYBE(s, on_method, p, endp - p);
return err;
}
int llhttp__on_method_complete(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_method_complete);
return err;
}
int llhttp__on_version(llhttp_t* s, const char* p, const char* endp) {
int err;
SPAN_CALLBACK_MAYBE(s, on_version, p, endp - p);
return err;
}
int llhttp__on_version_complete(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_version_complete);
return err;
}
int llhttp__on_header_field(llhttp_t* s, const char* p, const char* endp) {
int err;
SPAN_CALLBACK_MAYBE(s, on_header_field, p, endp - p);
return err;
}
int llhttp__on_header_field_complete(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_header_field_complete);
return err;
}
int llhttp__on_header_value(llhttp_t* s, const char* p, const char* endp) {
int err;
SPAN_CALLBACK_MAYBE(s, on_header_value, p, endp - p);
return err;
}
int llhttp__on_header_value_complete(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_header_value_complete);
return err;
}
int llhttp__on_headers_complete(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_headers_complete);
return err;
}
int llhttp__on_message_complete(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_message_complete);
return err;
}
int llhttp__on_body(llhttp_t* s, const char* p, const char* endp) {
int err;
SPAN_CALLBACK_MAYBE(s, on_body, p, endp - p);
return err;
}
int llhttp__on_chunk_header(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_chunk_header);
return err;
}
int llhttp__on_chunk_extension_name(llhttp_t* s, const char* p, const char* endp) {
int err;
SPAN_CALLBACK_MAYBE(s, on_chunk_extension_name, p, endp - p);
return err;
}
int llhttp__on_chunk_extension_name_complete(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_chunk_extension_name_complete);
return err;
}
int llhttp__on_chunk_extension_value(llhttp_t* s, const char* p, const char* endp) {
int err;
SPAN_CALLBACK_MAYBE(s, on_chunk_extension_value, p, endp - p);
return err;
}
int llhttp__on_chunk_extension_value_complete(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_chunk_extension_value_complete);
return err;
}
int llhttp__on_chunk_complete(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_chunk_complete);
return err;
}
int llhttp__on_reset(llhttp_t* s, const char* p, const char* endp) {
int err;
CALLBACK_MAYBE(s, on_reset);
return err;
}
/* Private */
void llhttp__debug(llhttp_t* s, const char* p, const char* endp,
const char* msg) {
if (p == endp) {
fprintf(stderr, "p=%p type=%d flags=%02x next=null debug=%s\n", s, s->type,
s->flags, msg);
} else {
fprintf(stderr, "p=%p type=%d flags=%02x next=%02x debug=%s\n", s,
s->type, s->flags, *p, msg);
}
}

170
jtlsrv-cpp/vendor/http.c vendored Normal file
View File

@@ -0,0 +1,170 @@
#include <stdio.h>
#ifndef LLHTTP__TEST
# include "llhttp.h"
#else
# define llhttp_t llparse_t
#endif /* */
int llhttp_message_needs_eof(const llhttp_t* parser);
int llhttp_should_keep_alive(const llhttp_t* parser);
int llhttp__before_headers_complete(llhttp_t* parser, const char* p,
const char* endp) {
/* Set this here so that on_headers_complete() callbacks can see it */
if ((parser->flags & F_UPGRADE) &&
(parser->flags & F_CONNECTION_UPGRADE)) {
/* For responses, "Upgrade: foo" and "Connection: upgrade" are
* mandatory only when it is a 101 Switching Protocols response,
* otherwise it is purely informational, to announce support.
*/
parser->upgrade =
(parser->type == HTTP_REQUEST || parser->status_code == 101);
} else {
parser->upgrade = (parser->method == HTTP_CONNECT);
}
return 0;
}
/* Return values:
* 0 - No body, `restart`, message_complete
* 1 - CONNECT request, `restart`, message_complete, and pause
* 2 - chunk_size_start
* 3 - body_identity
* 4 - body_identity_eof
* 5 - invalid transfer-encoding for request
*/
int llhttp__after_headers_complete(llhttp_t* parser, const char* p,
const char* endp) {
int hasBody;
hasBody = parser->flags & F_CHUNKED || parser->content_length > 0;
if (
(parser->upgrade && (parser->method == HTTP_CONNECT ||
(parser->flags & F_SKIPBODY) || !hasBody)) ||
/* See RFC 2616 section 4.4 - 1xx e.g. Continue */
(parser->type == HTTP_RESPONSE && parser->status_code == 101)
) {
/* Exit, the rest of the message is in a different protocol. */
return 1;
}
if (parser->type == HTTP_RESPONSE && parser->status_code == 100) {
/* No body, restart as the message is complete */
return 0;
}
/* See RFC 2616 section 4.4 */
if (
parser->flags & F_SKIPBODY || /* response to a HEAD request */
(
parser->type == HTTP_RESPONSE && (
parser->status_code == 102 || /* Processing */
parser->status_code == 103 || /* Early Hints */
parser->status_code == 204 || /* No Content */
parser->status_code == 304 /* Not Modified */
)
)
) {
return 0;
} else if (parser->flags & F_CHUNKED) {
/* chunked encoding - ignore Content-Length header, prepare for a chunk */
return 2;
} else if (parser->flags & F_TRANSFER_ENCODING) {
if (parser->type == HTTP_REQUEST &&
(parser->lenient_flags & LENIENT_CHUNKED_LENGTH) == 0 &&
(parser->lenient_flags & LENIENT_TRANSFER_ENCODING) == 0) {
/* RFC 7230 3.3.3 */
/* If a Transfer-Encoding header field
* is present in a request and the chunked transfer coding is not
* the final encoding, the message body length cannot be determined
* reliably; the server MUST respond with the 400 (Bad Request)
* status code and then close the connection.
*/
return 5;
} else {
/* RFC 7230 3.3.3 */
/* If a Transfer-Encoding header field is present in a response and
* the chunked transfer coding is not the final encoding, the
* message body length is determined by reading the connection until
* it is closed by the server.
*/
return 4;
}
} else {
if (!(parser->flags & F_CONTENT_LENGTH)) {
if (!llhttp_message_needs_eof(parser)) {
/* Assume content-length 0 - read the next */
return 0;
} else {
/* Read body until EOF */
return 4;
}
} else if (parser->content_length == 0) {
/* Content-Length header given but zero: Content-Length: 0\r\n */
return 0;
} else {
/* Content-Length header given and non-zero */
return 3;
}
}
}
int llhttp__after_message_complete(llhttp_t* parser, const char* p,
const char* endp) {
int should_keep_alive;
should_keep_alive = llhttp_should_keep_alive(parser);
parser->finish = HTTP_FINISH_SAFE;
parser->flags = 0;
/* NOTE: this is ignored in loose parsing mode */
return should_keep_alive;
}
int llhttp_message_needs_eof(const llhttp_t* parser) {
if (parser->type == HTTP_REQUEST) {
return 0;
}
/* See RFC 2616 section 4.4 */
if (parser->status_code / 100 == 1 || /* 1xx e.g. Continue */
parser->status_code == 204 || /* No Content */
parser->status_code == 304 || /* Not Modified */
(parser->flags & F_SKIPBODY)) { /* response to a HEAD request */
return 0;
}
/* RFC 7230 3.3.3, see `llhttp__after_headers_complete` */
if ((parser->flags & F_TRANSFER_ENCODING) &&
(parser->flags & F_CHUNKED) == 0) {
return 1;
}
if (parser->flags & (F_CHUNKED | F_CONTENT_LENGTH)) {
return 0;
}
return 1;
}
int llhttp_should_keep_alive(const llhttp_t* parser) {
if (parser->http_major > 0 && parser->http_minor > 0) {
/* HTTP/1.1 */
if (parser->flags & F_CONNECTION_CLOSE) {
return 0;
}
} else {
/* HTTP/1.0 or earlier */
if (!(parser->flags & F_CONNECTION_KEEP_ALIVE)) {
return 0;
}
}
return !llhttp_message_needs_eof(parser);
}

10168
jtlsrv-cpp/vendor/llhttp.c vendored Normal file

File diff suppressed because it is too large Load Diff

903
jtlsrv-cpp/vendor/llhttp.h vendored Normal file
View File

@@ -0,0 +1,903 @@
#ifndef INCLUDE_LLHTTP_H_
#define INCLUDE_LLHTTP_H_
#define LLHTTP_VERSION_MAJOR 9
#define LLHTTP_VERSION_MINOR 2
#define LLHTTP_VERSION_PATCH 1
#ifndef INCLUDE_LLHTTP_ITSELF_H_
#define INCLUDE_LLHTTP_ITSELF_H_
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
typedef struct llhttp__internal_s llhttp__internal_t;
struct llhttp__internal_s {
int32_t _index;
void* _span_pos0;
void* _span_cb0;
int32_t error;
const char* reason;
const char* error_pos;
void* data;
void* _current;
uint64_t content_length;
uint8_t type;
uint8_t method;
uint8_t http_major;
uint8_t http_minor;
uint8_t header_state;
uint16_t lenient_flags;
uint8_t upgrade;
uint8_t finish;
uint16_t flags;
uint16_t status_code;
uint8_t initial_message_completed;
void* settings;
};
int llhttp__internal_init(llhttp__internal_t* s);
int llhttp__internal_execute(llhttp__internal_t* s, const char* p, const char* endp);
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif /* INCLUDE_LLHTTP_ITSELF_H_ */
#ifndef LLLLHTTP_C_HEADERS_
#define LLLLHTTP_C_HEADERS_
#ifdef __cplusplus
extern "C" {
#endif
enum llhttp_errno {
HPE_OK = 0,
HPE_INTERNAL = 1,
HPE_STRICT = 2,
HPE_CR_EXPECTED = 25,
HPE_LF_EXPECTED = 3,
HPE_UNEXPECTED_CONTENT_LENGTH = 4,
HPE_UNEXPECTED_SPACE = 30,
HPE_CLOSED_CONNECTION = 5,
HPE_INVALID_METHOD = 6,
HPE_INVALID_URL = 7,
HPE_INVALID_CONSTANT = 8,
HPE_INVALID_VERSION = 9,
HPE_INVALID_HEADER_TOKEN = 10,
HPE_INVALID_CONTENT_LENGTH = 11,
HPE_INVALID_CHUNK_SIZE = 12,
HPE_INVALID_STATUS = 13,
HPE_INVALID_EOF_STATE = 14,
HPE_INVALID_TRANSFER_ENCODING = 15,
HPE_CB_MESSAGE_BEGIN = 16,
HPE_CB_HEADERS_COMPLETE = 17,
HPE_CB_MESSAGE_COMPLETE = 18,
HPE_CB_CHUNK_HEADER = 19,
HPE_CB_CHUNK_COMPLETE = 20,
HPE_PAUSED = 21,
HPE_PAUSED_UPGRADE = 22,
HPE_PAUSED_H2_UPGRADE = 23,
HPE_USER = 24,
HPE_CB_URL_COMPLETE = 26,
HPE_CB_STATUS_COMPLETE = 27,
HPE_CB_METHOD_COMPLETE = 32,
HPE_CB_VERSION_COMPLETE = 33,
HPE_CB_HEADER_FIELD_COMPLETE = 28,
HPE_CB_HEADER_VALUE_COMPLETE = 29,
HPE_CB_CHUNK_EXTENSION_NAME_COMPLETE = 34,
HPE_CB_CHUNK_EXTENSION_VALUE_COMPLETE = 35,
HPE_CB_RESET = 31
};
typedef enum llhttp_errno llhttp_errno_t;
enum llhttp_flags {
F_CONNECTION_KEEP_ALIVE = 0x1,
F_CONNECTION_CLOSE = 0x2,
F_CONNECTION_UPGRADE = 0x4,
F_CHUNKED = 0x8,
F_UPGRADE = 0x10,
F_CONTENT_LENGTH = 0x20,
F_SKIPBODY = 0x40,
F_TRAILING = 0x80,
F_TRANSFER_ENCODING = 0x200
};
typedef enum llhttp_flags llhttp_flags_t;
enum llhttp_lenient_flags {
LENIENT_HEADERS = 0x1,
LENIENT_CHUNKED_LENGTH = 0x2,
LENIENT_KEEP_ALIVE = 0x4,
LENIENT_TRANSFER_ENCODING = 0x8,
LENIENT_VERSION = 0x10,
LENIENT_DATA_AFTER_CLOSE = 0x20,
LENIENT_OPTIONAL_LF_AFTER_CR = 0x40,
LENIENT_OPTIONAL_CRLF_AFTER_CHUNK = 0x80,
LENIENT_OPTIONAL_CR_BEFORE_LF = 0x100,
LENIENT_SPACES_AFTER_CHUNK_SIZE = 0x200
};
typedef enum llhttp_lenient_flags llhttp_lenient_flags_t;
enum llhttp_type {
HTTP_BOTH = 0,
HTTP_REQUEST = 1,
HTTP_RESPONSE = 2
};
typedef enum llhttp_type llhttp_type_t;
enum llhttp_finish {
HTTP_FINISH_SAFE = 0,
HTTP_FINISH_SAFE_WITH_CB = 1,
HTTP_FINISH_UNSAFE = 2
};
typedef enum llhttp_finish llhttp_finish_t;
enum llhttp_method {
HTTP_DELETE = 0,
HTTP_GET = 1,
HTTP_HEAD = 2,
HTTP_POST = 3,
HTTP_PUT = 4,
HTTP_CONNECT = 5,
HTTP_OPTIONS = 6,
HTTP_TRACE = 7,
HTTP_COPY = 8,
HTTP_LOCK = 9,
HTTP_MKCOL = 10,
HTTP_MOVE = 11,
HTTP_PROPFIND = 12,
HTTP_PROPPATCH = 13,
HTTP_SEARCH = 14,
HTTP_UNLOCK = 15,
HTTP_BIND = 16,
HTTP_REBIND = 17,
HTTP_UNBIND = 18,
HTTP_ACL = 19,
HTTP_REPORT = 20,
HTTP_MKACTIVITY = 21,
HTTP_CHECKOUT = 22,
HTTP_MERGE = 23,
HTTP_MSEARCH = 24,
HTTP_NOTIFY = 25,
HTTP_SUBSCRIBE = 26,
HTTP_UNSUBSCRIBE = 27,
HTTP_PATCH = 28,
HTTP_PURGE = 29,
HTTP_MKCALENDAR = 30,
HTTP_LINK = 31,
HTTP_UNLINK = 32,
HTTP_SOURCE = 33,
HTTP_PRI = 34,
HTTP_DESCRIBE = 35,
HTTP_ANNOUNCE = 36,
HTTP_SETUP = 37,
HTTP_PLAY = 38,
HTTP_PAUSE = 39,
HTTP_TEARDOWN = 40,
HTTP_GET_PARAMETER = 41,
HTTP_SET_PARAMETER = 42,
HTTP_REDIRECT = 43,
HTTP_RECORD = 44,
HTTP_FLUSH = 45,
HTTP_QUERY = 46
};
typedef enum llhttp_method llhttp_method_t;
enum llhttp_status {
HTTP_STATUS_CONTINUE = 100,
HTTP_STATUS_SWITCHING_PROTOCOLS = 101,
HTTP_STATUS_PROCESSING = 102,
HTTP_STATUS_EARLY_HINTS = 103,
HTTP_STATUS_RESPONSE_IS_STALE = 110,
HTTP_STATUS_REVALIDATION_FAILED = 111,
HTTP_STATUS_DISCONNECTED_OPERATION = 112,
HTTP_STATUS_HEURISTIC_EXPIRATION = 113,
HTTP_STATUS_MISCELLANEOUS_WARNING = 199,
HTTP_STATUS_OK = 200,
HTTP_STATUS_CREATED = 201,
HTTP_STATUS_ACCEPTED = 202,
HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION = 203,
HTTP_STATUS_NO_CONTENT = 204,
HTTP_STATUS_RESET_CONTENT = 205,
HTTP_STATUS_PARTIAL_CONTENT = 206,
HTTP_STATUS_MULTI_STATUS = 207,
HTTP_STATUS_ALREADY_REPORTED = 208,
HTTP_STATUS_TRANSFORMATION_APPLIED = 214,
HTTP_STATUS_IM_USED = 226,
HTTP_STATUS_MISCELLANEOUS_PERSISTENT_WARNING = 299,
HTTP_STATUS_MULTIPLE_CHOICES = 300,
HTTP_STATUS_MOVED_PERMANENTLY = 301,
HTTP_STATUS_FOUND = 302,
HTTP_STATUS_SEE_OTHER = 303,
HTTP_STATUS_NOT_MODIFIED = 304,
HTTP_STATUS_USE_PROXY = 305,
HTTP_STATUS_SWITCH_PROXY = 306,
HTTP_STATUS_TEMPORARY_REDIRECT = 307,
HTTP_STATUS_PERMANENT_REDIRECT = 308,
HTTP_STATUS_BAD_REQUEST = 400,
HTTP_STATUS_UNAUTHORIZED = 401,
HTTP_STATUS_PAYMENT_REQUIRED = 402,
HTTP_STATUS_FORBIDDEN = 403,
HTTP_STATUS_NOT_FOUND = 404,
HTTP_STATUS_METHOD_NOT_ALLOWED = 405,
HTTP_STATUS_NOT_ACCEPTABLE = 406,
HTTP_STATUS_PROXY_AUTHENTICATION_REQUIRED = 407,
HTTP_STATUS_REQUEST_TIMEOUT = 408,
HTTP_STATUS_CONFLICT = 409,
HTTP_STATUS_GONE = 410,
HTTP_STATUS_LENGTH_REQUIRED = 411,
HTTP_STATUS_PRECONDITION_FAILED = 412,
HTTP_STATUS_PAYLOAD_TOO_LARGE = 413,
HTTP_STATUS_URI_TOO_LONG = 414,
HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE = 415,
HTTP_STATUS_RANGE_NOT_SATISFIABLE = 416,
HTTP_STATUS_EXPECTATION_FAILED = 417,
HTTP_STATUS_IM_A_TEAPOT = 418,
HTTP_STATUS_PAGE_EXPIRED = 419,
HTTP_STATUS_ENHANCE_YOUR_CALM = 420,
HTTP_STATUS_MISDIRECTED_REQUEST = 421,
HTTP_STATUS_UNPROCESSABLE_ENTITY = 422,
HTTP_STATUS_LOCKED = 423,
HTTP_STATUS_FAILED_DEPENDENCY = 424,
HTTP_STATUS_TOO_EARLY = 425,
HTTP_STATUS_UPGRADE_REQUIRED = 426,
HTTP_STATUS_PRECONDITION_REQUIRED = 428,
HTTP_STATUS_TOO_MANY_REQUESTS = 429,
HTTP_STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL = 430,
HTTP_STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
HTTP_STATUS_LOGIN_TIMEOUT = 440,
HTTP_STATUS_NO_RESPONSE = 444,
HTTP_STATUS_RETRY_WITH = 449,
HTTP_STATUS_BLOCKED_BY_PARENTAL_CONTROL = 450,
HTTP_STATUS_UNAVAILABLE_FOR_LEGAL_REASONS = 451,
HTTP_STATUS_CLIENT_CLOSED_LOAD_BALANCED_REQUEST = 460,
HTTP_STATUS_INVALID_X_FORWARDED_FOR = 463,
HTTP_STATUS_REQUEST_HEADER_TOO_LARGE = 494,
HTTP_STATUS_SSL_CERTIFICATE_ERROR = 495,
HTTP_STATUS_SSL_CERTIFICATE_REQUIRED = 496,
HTTP_STATUS_HTTP_REQUEST_SENT_TO_HTTPS_PORT = 497,
HTTP_STATUS_INVALID_TOKEN = 498,
HTTP_STATUS_CLIENT_CLOSED_REQUEST = 499,
HTTP_STATUS_INTERNAL_SERVER_ERROR = 500,
HTTP_STATUS_NOT_IMPLEMENTED = 501,
HTTP_STATUS_BAD_GATEWAY = 502,
HTTP_STATUS_SERVICE_UNAVAILABLE = 503,
HTTP_STATUS_GATEWAY_TIMEOUT = 504,
HTTP_STATUS_HTTP_VERSION_NOT_SUPPORTED = 505,
HTTP_STATUS_VARIANT_ALSO_NEGOTIATES = 506,
HTTP_STATUS_INSUFFICIENT_STORAGE = 507,
HTTP_STATUS_LOOP_DETECTED = 508,
HTTP_STATUS_BANDWIDTH_LIMIT_EXCEEDED = 509,
HTTP_STATUS_NOT_EXTENDED = 510,
HTTP_STATUS_NETWORK_AUTHENTICATION_REQUIRED = 511,
HTTP_STATUS_WEB_SERVER_UNKNOWN_ERROR = 520,
HTTP_STATUS_WEB_SERVER_IS_DOWN = 521,
HTTP_STATUS_CONNECTION_TIMEOUT = 522,
HTTP_STATUS_ORIGIN_IS_UNREACHABLE = 523,
HTTP_STATUS_TIMEOUT_OCCURED = 524,
HTTP_STATUS_SSL_HANDSHAKE_FAILED = 525,
HTTP_STATUS_INVALID_SSL_CERTIFICATE = 526,
HTTP_STATUS_RAILGUN_ERROR = 527,
HTTP_STATUS_SITE_IS_OVERLOADED = 529,
HTTP_STATUS_SITE_IS_FROZEN = 530,
HTTP_STATUS_IDENTITY_PROVIDER_AUTHENTICATION_ERROR = 561,
HTTP_STATUS_NETWORK_READ_TIMEOUT = 598,
HTTP_STATUS_NETWORK_CONNECT_TIMEOUT = 599
};
typedef enum llhttp_status llhttp_status_t;
#define HTTP_ERRNO_MAP(XX) \
XX(0, OK, OK) \
XX(1, INTERNAL, INTERNAL) \
XX(2, STRICT, STRICT) \
XX(25, CR_EXPECTED, CR_EXPECTED) \
XX(3, LF_EXPECTED, LF_EXPECTED) \
XX(4, UNEXPECTED_CONTENT_LENGTH, UNEXPECTED_CONTENT_LENGTH) \
XX(30, UNEXPECTED_SPACE, UNEXPECTED_SPACE) \
XX(5, CLOSED_CONNECTION, CLOSED_CONNECTION) \
XX(6, INVALID_METHOD, INVALID_METHOD) \
XX(7, INVALID_URL, INVALID_URL) \
XX(8, INVALID_CONSTANT, INVALID_CONSTANT) \
XX(9, INVALID_VERSION, INVALID_VERSION) \
XX(10, INVALID_HEADER_TOKEN, INVALID_HEADER_TOKEN) \
XX(11, INVALID_CONTENT_LENGTH, INVALID_CONTENT_LENGTH) \
XX(12, INVALID_CHUNK_SIZE, INVALID_CHUNK_SIZE) \
XX(13, INVALID_STATUS, INVALID_STATUS) \
XX(14, INVALID_EOF_STATE, INVALID_EOF_STATE) \
XX(15, INVALID_TRANSFER_ENCODING, INVALID_TRANSFER_ENCODING) \
XX(16, CB_MESSAGE_BEGIN, CB_MESSAGE_BEGIN) \
XX(17, CB_HEADERS_COMPLETE, CB_HEADERS_COMPLETE) \
XX(18, CB_MESSAGE_COMPLETE, CB_MESSAGE_COMPLETE) \
XX(19, CB_CHUNK_HEADER, CB_CHUNK_HEADER) \
XX(20, CB_CHUNK_COMPLETE, CB_CHUNK_COMPLETE) \
XX(21, PAUSED, PAUSED) \
XX(22, PAUSED_UPGRADE, PAUSED_UPGRADE) \
XX(23, PAUSED_H2_UPGRADE, PAUSED_H2_UPGRADE) \
XX(24, USER, USER) \
XX(26, CB_URL_COMPLETE, CB_URL_COMPLETE) \
XX(27, CB_STATUS_COMPLETE, CB_STATUS_COMPLETE) \
XX(32, CB_METHOD_COMPLETE, CB_METHOD_COMPLETE) \
XX(33, CB_VERSION_COMPLETE, CB_VERSION_COMPLETE) \
XX(28, CB_HEADER_FIELD_COMPLETE, CB_HEADER_FIELD_COMPLETE) \
XX(29, CB_HEADER_VALUE_COMPLETE, CB_HEADER_VALUE_COMPLETE) \
XX(34, CB_CHUNK_EXTENSION_NAME_COMPLETE, CB_CHUNK_EXTENSION_NAME_COMPLETE) \
XX(35, CB_CHUNK_EXTENSION_VALUE_COMPLETE, CB_CHUNK_EXTENSION_VALUE_COMPLETE) \
XX(31, CB_RESET, CB_RESET) \
#define HTTP_METHOD_MAP(XX) \
XX(0, DELETE, DELETE) \
XX(1, GET, GET) \
XX(2, HEAD, HEAD) \
XX(3, POST, POST) \
XX(4, PUT, PUT) \
XX(5, CONNECT, CONNECT) \
XX(6, OPTIONS, OPTIONS) \
XX(7, TRACE, TRACE) \
XX(8, COPY, COPY) \
XX(9, LOCK, LOCK) \
XX(10, MKCOL, MKCOL) \
XX(11, MOVE, MOVE) \
XX(12, PROPFIND, PROPFIND) \
XX(13, PROPPATCH, PROPPATCH) \
XX(14, SEARCH, SEARCH) \
XX(15, UNLOCK, UNLOCK) \
XX(16, BIND, BIND) \
XX(17, REBIND, REBIND) \
XX(18, UNBIND, UNBIND) \
XX(19, ACL, ACL) \
XX(20, REPORT, REPORT) \
XX(21, MKACTIVITY, MKACTIVITY) \
XX(22, CHECKOUT, CHECKOUT) \
XX(23, MERGE, MERGE) \
XX(24, MSEARCH, M-SEARCH) \
XX(25, NOTIFY, NOTIFY) \
XX(26, SUBSCRIBE, SUBSCRIBE) \
XX(27, UNSUBSCRIBE, UNSUBSCRIBE) \
XX(28, PATCH, PATCH) \
XX(29, PURGE, PURGE) \
XX(30, MKCALENDAR, MKCALENDAR) \
XX(31, LINK, LINK) \
XX(32, UNLINK, UNLINK) \
XX(33, SOURCE, SOURCE) \
XX(46, QUERY, QUERY) \
#define RTSP_METHOD_MAP(XX) \
XX(1, GET, GET) \
XX(3, POST, POST) \
XX(6, OPTIONS, OPTIONS) \
XX(35, DESCRIBE, DESCRIBE) \
XX(36, ANNOUNCE, ANNOUNCE) \
XX(37, SETUP, SETUP) \
XX(38, PLAY, PLAY) \
XX(39, PAUSE, PAUSE) \
XX(40, TEARDOWN, TEARDOWN) \
XX(41, GET_PARAMETER, GET_PARAMETER) \
XX(42, SET_PARAMETER, SET_PARAMETER) \
XX(43, REDIRECT, REDIRECT) \
XX(44, RECORD, RECORD) \
XX(45, FLUSH, FLUSH) \
#define HTTP_ALL_METHOD_MAP(XX) \
XX(0, DELETE, DELETE) \
XX(1, GET, GET) \
XX(2, HEAD, HEAD) \
XX(3, POST, POST) \
XX(4, PUT, PUT) \
XX(5, CONNECT, CONNECT) \
XX(6, OPTIONS, OPTIONS) \
XX(7, TRACE, TRACE) \
XX(8, COPY, COPY) \
XX(9, LOCK, LOCK) \
XX(10, MKCOL, MKCOL) \
XX(11, MOVE, MOVE) \
XX(12, PROPFIND, PROPFIND) \
XX(13, PROPPATCH, PROPPATCH) \
XX(14, SEARCH, SEARCH) \
XX(15, UNLOCK, UNLOCK) \
XX(16, BIND, BIND) \
XX(17, REBIND, REBIND) \
XX(18, UNBIND, UNBIND) \
XX(19, ACL, ACL) \
XX(20, REPORT, REPORT) \
XX(21, MKACTIVITY, MKACTIVITY) \
XX(22, CHECKOUT, CHECKOUT) \
XX(23, MERGE, MERGE) \
XX(24, MSEARCH, M-SEARCH) \
XX(25, NOTIFY, NOTIFY) \
XX(26, SUBSCRIBE, SUBSCRIBE) \
XX(27, UNSUBSCRIBE, UNSUBSCRIBE) \
XX(28, PATCH, PATCH) \
XX(29, PURGE, PURGE) \
XX(30, MKCALENDAR, MKCALENDAR) \
XX(31, LINK, LINK) \
XX(32, UNLINK, UNLINK) \
XX(33, SOURCE, SOURCE) \
XX(34, PRI, PRI) \
XX(35, DESCRIBE, DESCRIBE) \
XX(36, ANNOUNCE, ANNOUNCE) \
XX(37, SETUP, SETUP) \
XX(38, PLAY, PLAY) \
XX(39, PAUSE, PAUSE) \
XX(40, TEARDOWN, TEARDOWN) \
XX(41, GET_PARAMETER, GET_PARAMETER) \
XX(42, SET_PARAMETER, SET_PARAMETER) \
XX(43, REDIRECT, REDIRECT) \
XX(44, RECORD, RECORD) \
XX(45, FLUSH, FLUSH) \
XX(46, QUERY, QUERY) \
#define HTTP_STATUS_MAP(XX) \
XX(100, CONTINUE, CONTINUE) \
XX(101, SWITCHING_PROTOCOLS, SWITCHING_PROTOCOLS) \
XX(102, PROCESSING, PROCESSING) \
XX(103, EARLY_HINTS, EARLY_HINTS) \
XX(110, RESPONSE_IS_STALE, RESPONSE_IS_STALE) \
XX(111, REVALIDATION_FAILED, REVALIDATION_FAILED) \
XX(112, DISCONNECTED_OPERATION, DISCONNECTED_OPERATION) \
XX(113, HEURISTIC_EXPIRATION, HEURISTIC_EXPIRATION) \
XX(199, MISCELLANEOUS_WARNING, MISCELLANEOUS_WARNING) \
XX(200, OK, OK) \
XX(201, CREATED, CREATED) \
XX(202, ACCEPTED, ACCEPTED) \
XX(203, NON_AUTHORITATIVE_INFORMATION, NON_AUTHORITATIVE_INFORMATION) \
XX(204, NO_CONTENT, NO_CONTENT) \
XX(205, RESET_CONTENT, RESET_CONTENT) \
XX(206, PARTIAL_CONTENT, PARTIAL_CONTENT) \
XX(207, MULTI_STATUS, MULTI_STATUS) \
XX(208, ALREADY_REPORTED, ALREADY_REPORTED) \
XX(214, TRANSFORMATION_APPLIED, TRANSFORMATION_APPLIED) \
XX(226, IM_USED, IM_USED) \
XX(299, MISCELLANEOUS_PERSISTENT_WARNING, MISCELLANEOUS_PERSISTENT_WARNING) \
XX(300, MULTIPLE_CHOICES, MULTIPLE_CHOICES) \
XX(301, MOVED_PERMANENTLY, MOVED_PERMANENTLY) \
XX(302, FOUND, FOUND) \
XX(303, SEE_OTHER, SEE_OTHER) \
XX(304, NOT_MODIFIED, NOT_MODIFIED) \
XX(305, USE_PROXY, USE_PROXY) \
XX(306, SWITCH_PROXY, SWITCH_PROXY) \
XX(307, TEMPORARY_REDIRECT, TEMPORARY_REDIRECT) \
XX(308, PERMANENT_REDIRECT, PERMANENT_REDIRECT) \
XX(400, BAD_REQUEST, BAD_REQUEST) \
XX(401, UNAUTHORIZED, UNAUTHORIZED) \
XX(402, PAYMENT_REQUIRED, PAYMENT_REQUIRED) \
XX(403, FORBIDDEN, FORBIDDEN) \
XX(404, NOT_FOUND, NOT_FOUND) \
XX(405, METHOD_NOT_ALLOWED, METHOD_NOT_ALLOWED) \
XX(406, NOT_ACCEPTABLE, NOT_ACCEPTABLE) \
XX(407, PROXY_AUTHENTICATION_REQUIRED, PROXY_AUTHENTICATION_REQUIRED) \
XX(408, REQUEST_TIMEOUT, REQUEST_TIMEOUT) \
XX(409, CONFLICT, CONFLICT) \
XX(410, GONE, GONE) \
XX(411, LENGTH_REQUIRED, LENGTH_REQUIRED) \
XX(412, PRECONDITION_FAILED, PRECONDITION_FAILED) \
XX(413, PAYLOAD_TOO_LARGE, PAYLOAD_TOO_LARGE) \
XX(414, URI_TOO_LONG, URI_TOO_LONG) \
XX(415, UNSUPPORTED_MEDIA_TYPE, UNSUPPORTED_MEDIA_TYPE) \
XX(416, RANGE_NOT_SATISFIABLE, RANGE_NOT_SATISFIABLE) \
XX(417, EXPECTATION_FAILED, EXPECTATION_FAILED) \
XX(418, IM_A_TEAPOT, IM_A_TEAPOT) \
XX(419, PAGE_EXPIRED, PAGE_EXPIRED) \
XX(420, ENHANCE_YOUR_CALM, ENHANCE_YOUR_CALM) \
XX(421, MISDIRECTED_REQUEST, MISDIRECTED_REQUEST) \
XX(422, UNPROCESSABLE_ENTITY, UNPROCESSABLE_ENTITY) \
XX(423, LOCKED, LOCKED) \
XX(424, FAILED_DEPENDENCY, FAILED_DEPENDENCY) \
XX(425, TOO_EARLY, TOO_EARLY) \
XX(426, UPGRADE_REQUIRED, UPGRADE_REQUIRED) \
XX(428, PRECONDITION_REQUIRED, PRECONDITION_REQUIRED) \
XX(429, TOO_MANY_REQUESTS, TOO_MANY_REQUESTS) \
XX(430, REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL, REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL) \
XX(431, REQUEST_HEADER_FIELDS_TOO_LARGE, REQUEST_HEADER_FIELDS_TOO_LARGE) \
XX(440, LOGIN_TIMEOUT, LOGIN_TIMEOUT) \
XX(444, NO_RESPONSE, NO_RESPONSE) \
XX(449, RETRY_WITH, RETRY_WITH) \
XX(450, BLOCKED_BY_PARENTAL_CONTROL, BLOCKED_BY_PARENTAL_CONTROL) \
XX(451, UNAVAILABLE_FOR_LEGAL_REASONS, UNAVAILABLE_FOR_LEGAL_REASONS) \
XX(460, CLIENT_CLOSED_LOAD_BALANCED_REQUEST, CLIENT_CLOSED_LOAD_BALANCED_REQUEST) \
XX(463, INVALID_X_FORWARDED_FOR, INVALID_X_FORWARDED_FOR) \
XX(494, REQUEST_HEADER_TOO_LARGE, REQUEST_HEADER_TOO_LARGE) \
XX(495, SSL_CERTIFICATE_ERROR, SSL_CERTIFICATE_ERROR) \
XX(496, SSL_CERTIFICATE_REQUIRED, SSL_CERTIFICATE_REQUIRED) \
XX(497, HTTP_REQUEST_SENT_TO_HTTPS_PORT, HTTP_REQUEST_SENT_TO_HTTPS_PORT) \
XX(498, INVALID_TOKEN, INVALID_TOKEN) \
XX(499, CLIENT_CLOSED_REQUEST, CLIENT_CLOSED_REQUEST) \
XX(500, INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR) \
XX(501, NOT_IMPLEMENTED, NOT_IMPLEMENTED) \
XX(502, BAD_GATEWAY, BAD_GATEWAY) \
XX(503, SERVICE_UNAVAILABLE, SERVICE_UNAVAILABLE) \
XX(504, GATEWAY_TIMEOUT, GATEWAY_TIMEOUT) \
XX(505, HTTP_VERSION_NOT_SUPPORTED, HTTP_VERSION_NOT_SUPPORTED) \
XX(506, VARIANT_ALSO_NEGOTIATES, VARIANT_ALSO_NEGOTIATES) \
XX(507, INSUFFICIENT_STORAGE, INSUFFICIENT_STORAGE) \
XX(508, LOOP_DETECTED, LOOP_DETECTED) \
XX(509, BANDWIDTH_LIMIT_EXCEEDED, BANDWIDTH_LIMIT_EXCEEDED) \
XX(510, NOT_EXTENDED, NOT_EXTENDED) \
XX(511, NETWORK_AUTHENTICATION_REQUIRED, NETWORK_AUTHENTICATION_REQUIRED) \
XX(520, WEB_SERVER_UNKNOWN_ERROR, WEB_SERVER_UNKNOWN_ERROR) \
XX(521, WEB_SERVER_IS_DOWN, WEB_SERVER_IS_DOWN) \
XX(522, CONNECTION_TIMEOUT, CONNECTION_TIMEOUT) \
XX(523, ORIGIN_IS_UNREACHABLE, ORIGIN_IS_UNREACHABLE) \
XX(524, TIMEOUT_OCCURED, TIMEOUT_OCCURED) \
XX(525, SSL_HANDSHAKE_FAILED, SSL_HANDSHAKE_FAILED) \
XX(526, INVALID_SSL_CERTIFICATE, INVALID_SSL_CERTIFICATE) \
XX(527, RAILGUN_ERROR, RAILGUN_ERROR) \
XX(529, SITE_IS_OVERLOADED, SITE_IS_OVERLOADED) \
XX(530, SITE_IS_FROZEN, SITE_IS_FROZEN) \
XX(561, IDENTITY_PROVIDER_AUTHENTICATION_ERROR, IDENTITY_PROVIDER_AUTHENTICATION_ERROR) \
XX(598, NETWORK_READ_TIMEOUT, NETWORK_READ_TIMEOUT) \
XX(599, NETWORK_CONNECT_TIMEOUT, NETWORK_CONNECT_TIMEOUT) \
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif /* LLLLHTTP_C_HEADERS_ */
#ifndef INCLUDE_LLHTTP_API_H_
#define INCLUDE_LLHTTP_API_H_
#ifdef __cplusplus
extern "C" {
#endif
#include <stddef.h>
#if defined(__wasm__)
#define LLHTTP_EXPORT __attribute__((visibility("default")))
#elif defined(_WIN32)
#define LLHTTP_EXPORT __declspec(dllexport)
#else
#define LLHTTP_EXPORT
#endif
typedef llhttp__internal_t llhttp_t;
typedef struct llhttp_settings_s llhttp_settings_t;
typedef int (*llhttp_data_cb)(llhttp_t*, const char *at, size_t length);
typedef int (*llhttp_cb)(llhttp_t*);
struct llhttp_settings_s {
/* Possible return values 0, -1, `HPE_PAUSED` */
llhttp_cb on_message_begin;
/* Possible return values 0, -1, HPE_USER */
llhttp_data_cb on_url;
llhttp_data_cb on_status;
llhttp_data_cb on_method;
llhttp_data_cb on_version;
llhttp_data_cb on_header_field;
llhttp_data_cb on_header_value;
llhttp_data_cb on_chunk_extension_name;
llhttp_data_cb on_chunk_extension_value;
/* Possible return values:
* 0 - Proceed normally
* 1 - Assume that request/response has no body, and proceed to parsing the
* next message
* 2 - Assume absence of body (as above) and make `llhttp_execute()` return
* `HPE_PAUSED_UPGRADE`
* -1 - Error
* `HPE_PAUSED`
*/
llhttp_cb on_headers_complete;
/* Possible return values 0, -1, HPE_USER */
llhttp_data_cb on_body;
/* Possible return values 0, -1, `HPE_PAUSED` */
llhttp_cb on_message_complete;
llhttp_cb on_url_complete;
llhttp_cb on_status_complete;
llhttp_cb on_method_complete;
llhttp_cb on_version_complete;
llhttp_cb on_header_field_complete;
llhttp_cb on_header_value_complete;
llhttp_cb on_chunk_extension_name_complete;
llhttp_cb on_chunk_extension_value_complete;
/* When on_chunk_header is called, the current chunk length is stored
* in parser->content_length.
* Possible return values 0, -1, `HPE_PAUSED`
*/
llhttp_cb on_chunk_header;
llhttp_cb on_chunk_complete;
llhttp_cb on_reset;
};
/* Initialize the parser with specific type and user settings.
*
* NOTE: lifetime of `settings` has to be at least the same as the lifetime of
* the `parser` here. In practice, `settings` has to be either a static
* variable or be allocated with `malloc`, `new`, etc.
*/
LLHTTP_EXPORT
void llhttp_init(llhttp_t* parser, llhttp_type_t type,
const llhttp_settings_t* settings);
LLHTTP_EXPORT
llhttp_t* llhttp_alloc(llhttp_type_t type);
LLHTTP_EXPORT
void llhttp_free(llhttp_t* parser);
LLHTTP_EXPORT
uint8_t llhttp_get_type(llhttp_t* parser);
LLHTTP_EXPORT
uint8_t llhttp_get_http_major(llhttp_t* parser);
LLHTTP_EXPORT
uint8_t llhttp_get_http_minor(llhttp_t* parser);
LLHTTP_EXPORT
uint8_t llhttp_get_method(llhttp_t* parser);
LLHTTP_EXPORT
int llhttp_get_status_code(llhttp_t* parser);
LLHTTP_EXPORT
uint8_t llhttp_get_upgrade(llhttp_t* parser);
/* Reset an already initialized parser back to the start state, preserving the
* existing parser type, callback settings, user data, and lenient flags.
*/
LLHTTP_EXPORT
void llhttp_reset(llhttp_t* parser);
/* Initialize the settings object */
LLHTTP_EXPORT
void llhttp_settings_init(llhttp_settings_t* settings);
/* Parse full or partial request/response, invoking user callbacks along the
* way.
*
* If any of `llhttp_data_cb` returns errno not equal to `HPE_OK` - the parsing
* interrupts, and such errno is returned from `llhttp_execute()`. If
* `HPE_PAUSED` was used as a errno, the execution can be resumed with
* `llhttp_resume()` call.
*
* In a special case of CONNECT/Upgrade request/response `HPE_PAUSED_UPGRADE`
* is returned after fully parsing the request/response. If the user wishes to
* continue parsing, they need to invoke `llhttp_resume_after_upgrade()`.
*
* NOTE: if this function ever returns a non-pause type error, it will continue
* to return the same error upon each successive call up until `llhttp_init()`
* is called.
*/
LLHTTP_EXPORT
llhttp_errno_t llhttp_execute(llhttp_t* parser, const char* data, size_t len);
/* This method should be called when the other side has no further bytes to
* send (e.g. shutdown of readable side of the TCP connection.)
*
* Requests without `Content-Length` and other messages might require treating
* all incoming bytes as the part of the body, up to the last byte of the
* connection. This method will invoke `on_message_complete()` callback if the
* request was terminated safely. Otherwise a error code would be returned.
*/
LLHTTP_EXPORT
llhttp_errno_t llhttp_finish(llhttp_t* parser);
/* Returns `1` if the incoming message is parsed until the last byte, and has
* to be completed by calling `llhttp_finish()` on EOF
*/
LLHTTP_EXPORT
int llhttp_message_needs_eof(const llhttp_t* parser);
/* Returns `1` if there might be any other messages following the last that was
* successfully parsed.
*/
LLHTTP_EXPORT
int llhttp_should_keep_alive(const llhttp_t* parser);
/* Make further calls of `llhttp_execute()` return `HPE_PAUSED` and set
* appropriate error reason.
*
* Important: do not call this from user callbacks! User callbacks must return
* `HPE_PAUSED` if pausing is required.
*/
LLHTTP_EXPORT
void llhttp_pause(llhttp_t* parser);
/* Might be called to resume the execution after the pause in user's callback.
* See `llhttp_execute()` above for details.
*
* Call this only if `llhttp_execute()` returns `HPE_PAUSED`.
*/
LLHTTP_EXPORT
void llhttp_resume(llhttp_t* parser);
/* Might be called to resume the execution after the pause in user's callback.
* See `llhttp_execute()` above for details.
*
* Call this only if `llhttp_execute()` returns `HPE_PAUSED_UPGRADE`
*/
LLHTTP_EXPORT
void llhttp_resume_after_upgrade(llhttp_t* parser);
/* Returns the latest return error */
LLHTTP_EXPORT
llhttp_errno_t llhttp_get_errno(const llhttp_t* parser);
/* Returns the verbal explanation of the latest returned error.
*
* Note: User callback should set error reason when returning the error. See
* `llhttp_set_error_reason()` for details.
*/
LLHTTP_EXPORT
const char* llhttp_get_error_reason(const llhttp_t* parser);
/* Assign verbal description to the returned error. Must be called in user
* callbacks right before returning the errno.
*
* Note: `HPE_USER` error code might be useful in user callbacks.
*/
LLHTTP_EXPORT
void llhttp_set_error_reason(llhttp_t* parser, const char* reason);
/* Returns the pointer to the last parsed byte before the returned error. The
* pointer is relative to the `data` argument of `llhttp_execute()`.
*
* Note: this method might be useful for counting the number of parsed bytes.
*/
LLHTTP_EXPORT
const char* llhttp_get_error_pos(const llhttp_t* parser);
/* Returns textual name of error code */
LLHTTP_EXPORT
const char* llhttp_errno_name(llhttp_errno_t err);
/* Returns textual name of HTTP method */
LLHTTP_EXPORT
const char* llhttp_method_name(llhttp_method_t method);
/* Returns textual name of HTTP status */
LLHTTP_EXPORT
const char* llhttp_status_name(llhttp_status_t status);
/* Enables/disables lenient header value parsing (disabled by default).
*
* Lenient parsing disables header value token checks, extending llhttp's
* protocol support to highly non-compliant clients/server. No
* `HPE_INVALID_HEADER_TOKEN` will be raised for incorrect header values when
* lenient parsing is "on".
*
* **Enabling this flag can pose a security issue since you will be exposed to
* request smuggling attacks. USE WITH CAUTION!**
*/
LLHTTP_EXPORT
void llhttp_set_lenient_headers(llhttp_t* parser, int enabled);
/* Enables/disables lenient handling of conflicting `Transfer-Encoding` and
* `Content-Length` headers (disabled by default).
*
* Normally `llhttp` would error when `Transfer-Encoding` is present in
* conjunction with `Content-Length`. This error is important to prevent HTTP
* request smuggling, but may be less desirable for small number of cases
* involving legacy servers.
*
* **Enabling this flag can pose a security issue since you will be exposed to
* request smuggling attacks. USE WITH CAUTION!**
*/
LLHTTP_EXPORT
void llhttp_set_lenient_chunked_length(llhttp_t* parser, int enabled);
/* Enables/disables lenient handling of `Connection: close` and HTTP/1.0
* requests responses.
*
* Normally `llhttp` would error on (in strict mode) or discard (in loose mode)
* the HTTP request/response after the request/response with `Connection: close`
* and `Content-Length`. This is important to prevent cache poisoning attacks,
* but might interact badly with outdated and insecure clients. With this flag
* the extra request/response will be parsed normally.
*
* **Enabling this flag can pose a security issue since you will be exposed to
* poisoning attacks. USE WITH CAUTION!**
*/
LLHTTP_EXPORT
void llhttp_set_lenient_keep_alive(llhttp_t* parser, int enabled);
/* Enables/disables lenient handling of `Transfer-Encoding` header.
*
* Normally `llhttp` would error when a `Transfer-Encoding` has `chunked` value
* and another value after it (either in a single header or in multiple
* headers whose value are internally joined using `, `).
* This is mandated by the spec to reliably determine request body size and thus
* avoid request smuggling.
* With this flag the extra value will be parsed normally.
*
* **Enabling this flag can pose a security issue since you will be exposed to
* request smuggling attacks. USE WITH CAUTION!**
*/
LLHTTP_EXPORT
void llhttp_set_lenient_transfer_encoding(llhttp_t* parser, int enabled);
/* Enables/disables lenient handling of HTTP version.
*
* Normally `llhttp` would error when the HTTP version in the request or status line
* is not `0.9`, `1.0`, `1.1` or `2.0`.
* With this flag the invalid value will be parsed normally.
*
* **Enabling this flag can pose a security issue since you will allow unsupported
* HTTP versions. USE WITH CAUTION!**
*/
LLHTTP_EXPORT
void llhttp_set_lenient_version(llhttp_t* parser, int enabled);
/* Enables/disables lenient handling of additional data received after a message ends
* and keep-alive is disabled.
*
* Normally `llhttp` would error when additional unexpected data is received if the message
* contains the `Connection` header with `close` value.
* With this flag the extra data will discarded without throwing an error.
*
* **Enabling this flag can pose a security issue since you will be exposed to
* poisoning attacks. USE WITH CAUTION!**
*/
LLHTTP_EXPORT
void llhttp_set_lenient_data_after_close(llhttp_t* parser, int enabled);
/* Enables/disables lenient handling of incomplete CRLF sequences.
*
* Normally `llhttp` would error when a CR is not followed by LF when terminating the
* request line, the status line, the headers or a chunk header.
* With this flag only a CR is required to terminate such sections.
*
* **Enabling this flag can pose a security issue since you will be exposed to
* request smuggling attacks. USE WITH CAUTION!**
*/
LLHTTP_EXPORT
void llhttp_set_lenient_optional_lf_after_cr(llhttp_t* parser, int enabled);
/*
* Enables/disables lenient handling of line separators.
*
* Normally `llhttp` would error when a LF is not preceded by CR when terminating the
* request line, the status line, the headers, a chunk header or a chunk data.
* With this flag only a LF is required to terminate such sections.
*
* **Enabling this flag can pose a security issue since you will be exposed to
* request smuggling attacks. USE WITH CAUTION!**
*/
LLHTTP_EXPORT
void llhttp_set_lenient_optional_cr_before_lf(llhttp_t* parser, int enabled);
/* Enables/disables lenient handling of chunks not separated via CRLF.
*
* Normally `llhttp` would error when after a chunk data a CRLF is missing before
* starting a new chunk.
* With this flag the new chunk can start immediately after the previous one.
*
* **Enabling this flag can pose a security issue since you will be exposed to
* request smuggling attacks. USE WITH CAUTION!**
*/
LLHTTP_EXPORT
void llhttp_set_lenient_optional_crlf_after_chunk(llhttp_t* parser, int enabled);
/* Enables/disables lenient handling of spaces after chunk size.
*
* Normally `llhttp` would error when after a chunk size is followed by one or more
* spaces are present instead of a CRLF or `;`.
* With this flag this check is disabled.
*
* **Enabling this flag can pose a security issue since you will be exposed to
* request smuggling attacks. USE WITH CAUTION!**
*/
LLHTTP_EXPORT
void llhttp_set_lenient_spaces_after_chunk_size(llhttp_t* parser, int enabled);
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif /* INCLUDE_LLHTTP_API_H_ */
#endif /* INCLUDE_LLHTTP_H_ */

24765
jtlsrv-cpp/vendor/nlohmann/json.hpp vendored Normal file

File diff suppressed because it is too large Load Diff

308
package-lock.json generated
View File

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

View File

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

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')}"`;
}

101
server.js
View File

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

View File

@@ -1,6 +1,6 @@
import { sendJson } from '../http.js'; import { sendJson } from '../http.js';
import { getMaxExternalId } from '../order-log.js';
import { getCategoryCount } from '../queries/category-count.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 { getCompositeProductCount } from '../queries/composite-product-count.js';
import { getCustomerGroupCount } from '../queries/customer-groups.js'; import { getCustomerGroupCount } from '../queries/customer-groups.js';
import { getDeletedEntityCount } from '../queries/deleted-entity-count.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 compositeProductCursor = Number(url.searchParams.get('lastChangedCompositeProduct')) || 0;
const deletedEntityCursor = Number(url.searchParams.get('lastChangedDeletedEntity')) || 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 }), getProductCount({ cursor: productCursor }),
getCategoryCount({ cursor: categoryCursor }), getCategoryCount({ cursor: categoryCursor }),
getCustomerGroupCount({ cursor: customerGroupCursor }), getCustomerGroupCount({ cursor: customerGroupCursor }),
getCompositeProductCount({ cursor: compositeProductCursor }), getCompositeProductCount({ cursor: compositeProductCursor }),
getDeletedEntityCount({ cursor: deletedEntityCursor }), getDeletedEntityCount({ cursor: deletedEntityCursor }),
getMaxOrderIdCount(),
]); ]);
return sendJson(res, 200, { return sendJson(res, 200, {
@@ -34,6 +35,6 @@ export async function handle(_req, res, { url }) {
configurationGroup_count: '0', configurationGroup_count: '0',
configurationItem_count: '0', configurationItem_count: '0',
deletedEntity_count: String(deletedEntityCount), deletedEntity_count: String(deletedEntityCount),
max_orderId_count: getMaxExternalId(), max_orderId_count: String(maxOrderIdCount),
}); });
} }

View File

@@ -1,5 +1,7 @@
import { sendJson } from '../http.js'; import { sendJson } from '../http.js';
import { logger } from '../logger.js';
import { logOrder } from '../order-log.js'; import { logOrder } from '../order-log.js';
import { createOrder } from '../queries/create-order.js';
export const method = 'POST'; export const method = 'POST';
export const path = '/v1/order'; export const path = '/v1/order';
@@ -33,16 +35,41 @@ export async function handle(req, res) {
} }
const orders = getOrders(body); const orders = getOrders(body);
const results = orders.map((order) => { const results = [];
for (const order of orders) {
logOrder(order); logOrder(order);
const externalOrderId = String(order?.externalId ?? ''); const externalOrderId = String(order?.externalId ?? '');
return { try {
status: 'OK', const created = await createOrder(order);
externalOrderId, if (created.alreadyExists) {
message: '', 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({
status: 'ERROR',
externalOrderId,
message: err.message,
});
}
}
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) { export function sendJson(res, statusCode, body) {
const responseBody = JSON.stringify(body); const responseBody = JSON.stringify(body);
res.writeHead(statusCode, { res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8', 'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(responseBody), 'Content-Length': Buffer.byteLength(responseBody),
...CORS_HEADERS,
}); });
res.end(responseBody); res.end(responseBody);
} }
@@ -26,10 +34,19 @@ export function sendBinary(res, statusCode, buffer, contentType) {
res.writeHead(statusCode, { res.writeHead(statusCode, {
'Content-Type': contentType, 'Content-Type': contentType,
'Content-Length': buffer.length, 'Content-Length': buffer.length,
...CORS_HEADERS,
}); });
res.end(buffer); res.end(buffer);
} }
export function sendCorsPreflight(res) {
res.writeHead(204, {
'Content-Length': 0,
...CORS_HEADERS,
});
res.end();
}
export function normalizePath(pathname) { export function normalizePath(pathname) {
return pathname.replace(/^\/api(?=\/v1\/)/, ''); return pathname.replace(/^\/api(?=\/v1\/)/, '');
} }

View File

@@ -1,21 +1,12 @@
import { endpoints } from './endpoints/index.js'; import { endpoints } from './endpoints/index.js';
import { normalizePath, readBody, sendJson } from './http.js'; import { normalizePath, readBody, sendCorsPreflight, sendJson } from './http.js';
function buildConfig(config = {}) { function buildConfig(config = {}) {
return { return {
authToken: config.authToken || process.env.AUTH_TOKEN || 'df40ad2067954646abb0499548a52241', authToken: config.authToken || process.env.AUTH_TOKEN || 'df40ad2067954646abb0499548a52241',
certificateFingerprint: certificateFingerprint: config.certificateFingerprint || '',
config.certificateFingerprint || certificateSerialNumber: config.certificateSerialNumber || '',
process.env.CERTIFICATE_FINGERPRINT || serverFingerprint: config.serverFingerprint || '',
'BC2114CF407A42724BEEF417960F76DCBF9DE879',
certificateSerialNumber:
config.certificateSerialNumber ||
process.env.CERTIFICATE_SERIAL_NUMBER ||
'00BFC8BEACDB981B165210EF111CB9D3',
serverFingerprint:
config.serverFingerprint ||
process.env.SERVER_FINGERPRINT ||
'39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE',
mandantId: config.mandantId || process.env.MANDANT_ID || '1', mandantId: config.mandantId || process.env.MANDANT_ID || '1',
mandantName: config.mandantName || process.env.MANDANT_NAME || 'eB-Standard', mandantName: config.mandantName || process.env.MANDANT_NAME || 'eB-Standard',
mandantDatabase: config.mandantDatabase || process.env.MANDANT_DATABASE || 'eazybusiness', mandantDatabase: config.mandantDatabase || process.env.MANDANT_DATABASE || 'eazybusiness',
@@ -27,6 +18,10 @@ export function createJtlPosServer(pairingStore, config = {}) {
const routes = new Map(endpoints.map((endpoint) => [`${endpoint.method} ${endpoint.path}`, endpoint])); const routes = new Map(endpoints.map((endpoint) => [`${endpoint.method} ${endpoint.path}`, endpoint]));
async function handle(req, res) { async function handle(req, res) {
if (req.method === 'OPTIONS') {
return sendCorsPreflight(res);
}
const url = new URL(req.url, 'https://localhost'); const url = new URL(req.url, 'https://localhost');
const pathname = normalizePath(url.pathname); const pathname = normalizePath(url.pathname);
const routeKey = `${req.method} ${pathname}`; const routeKey = `${req.method} ${pathname}`;

View File

@@ -7,20 +7,13 @@ fs.mkdirSync(path.dirname(ORDER_LOG_FILE), { recursive: true });
const stream = fs.createWriteStream(ORDER_LOG_FILE, { flags: 'a' }); const stream = fs.createWriteStream(ORDER_LOG_FILE, { flags: 'a' });
let orderSequence = 0; let orderSequence = 0;
let maxExternalId = 0;
export function logOrder(order) { export function logOrder(order) {
orderSequence += 1; 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); stream.write(line);
return maxExternalId;
}
export function getMaxExternalId() {
return String(maxExternalId);
} }
export function closeOrderLog() { export function closeOrderLog() {

View File

@@ -1,4 +1,6 @@
import sql from 'mssql'; import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoCategoryCount } from '../demo/store.js';
import { getPool } from '../db.js'; import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js'; import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
@@ -7,10 +9,15 @@ ${CATEGORY_TREE_CTE}
SELECT COUNT(*) AS CategoryCount SELECT COUNT(*) AS CategoryCount
FROM dbo.tKategorie k FROM dbo.tKategorie k
WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree) WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree)
AND (@kShop = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks WHERE ks.kKategorie = k.kKategorie AND ks.kShop = @kShop))
AND CONVERT(BIGINT, k.bRowversion) > @cursor; AND CONVERT(BIGINT, k.bRowversion) > @cursor;
`; `;
export async function getCategoryCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) { export async function getCategoryCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
if (isDemoMode()) {
return getDemoCategoryCount({ cursor });
}
const result = await categoryTreeRequest(getPool(), rootCategoryId) const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor) .input('cursor', sql.BigInt, cursor)
.query(CATEGORY_COUNT_SQL); .query(CATEGORY_COUNT_SQL);

View File

@@ -1,10 +1,10 @@
import sql from 'mssql'; import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoCategoryList } from '../demo/store.js';
import { getPool } from '../db.js'; import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js'; import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1; const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1;
const IMAGE_PLATFORM_ID = Number(process.env.IMAGE_PLATFORM_ID) || 1;
const IMAGE_SHOP_ID = Number(process.env.IMAGE_SHOP_ID) || 0;
const CATEGORY_LIST_SQL = ` const CATEGORY_LIST_SQL = `
${CATEGORY_TREE_CTE} ${CATEGORY_TREE_CTE}
@@ -18,21 +18,24 @@ SELECT TOP (@limit)
FROM dbo.tKategorie k FROM dbo.tKategorie k
INNER JOIN dbo.tKategorieSprache ks ON ks.kKategorie = k.kKategorie AND ks.kSprache = @languageId INNER JOIN dbo.tKategorieSprache ks ON ks.kKategorie = k.kKategorie AND ks.kSprache = @languageId
LEFT JOIN dbo.tKategoriebildPlattform kbp LEFT JOIN dbo.tKategoriebildPlattform kbp
ON kbp.kKategorie = k.kKategorie AND kbp.kPlattform = @imagePlatformId AND kbp.kShop = @imageShopId ON kbp.kKategorie = k.kKategorie
LEFT JOIN dbo.tBild b ON b.kBild = kbp.kBild LEFT JOIN dbo.tBild b ON b.kBild = kbp.kBild
WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree WHERE kKategorie <> @rootCategoryId) WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree WHERE kKategorie <> @rootCategoryId)
AND k.cAktiv = 'Y' AND k.cAktiv = 'Y'
AND (@kShop = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks2 WHERE ks2.kKategorie = k.kKategorie AND ks2.kShop = @kShop))
AND CONVERT(BIGINT, k.bRowversion) > @cursor AND CONVERT(BIGINT, k.bRowversion) > @cursor
ORDER BY lastChanged ASC; ORDER BY lastChanged ASC;
`; `;
export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId = getRootCategoryId() } = {}) { export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId = getRootCategoryId() } = {}) {
if (isDemoMode()) {
return getDemoCategoryList({ cursor, limit });
}
const result = await categoryTreeRequest(getPool(), rootCategoryId) const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor) .input('cursor', sql.BigInt, cursor)
.input('limit', sql.Int, limit) .input('limit', sql.Int, limit)
.input('languageId', sql.Int, LANGUAGE_ID) .input('languageId', sql.Int, LANGUAGE_ID)
.input('imagePlatformId', sql.Int, IMAGE_PLATFORM_ID)
.input('imageShopId', sql.Int, IMAGE_SHOP_ID)
.query(CATEGORY_LIST_SQL); .query(CATEGORY_LIST_SQL);
return result.recordset.map((row) => ({ return result.recordset.map((row) => ({
@@ -40,7 +43,7 @@ export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId =
imghash: row.imgHash ?? null, imghash: row.imgHash ?? null,
imgsrc: row.imgHash ?? null, imgsrc: row.imgHash ?? null,
name: row.name, name: row.name,
pid: String(row.pid), pid: row.pid === rootCategoryId ? '0' : String(row.pid),
discounts: [], discounts: [],
sort: String(row.sort), sort: String(row.sort),
lastChanged: String(row.lastChanged), lastChanged: String(row.lastChanged),

View File

@@ -1,4 +1,5 @@
import sql from 'mssql'; import sql from 'mssql';
import { getActiveShopId } from '../shop.js';
export const CATEGORY_TREE_CTE = ` export const CATEGORY_TREE_CTE = `
WITH CategoryTree AS ( WITH CategoryTree AS (
@@ -14,5 +15,8 @@ export function getRootCategoryId() {
} }
export function categoryTreeRequest(pool, rootCategoryId = getRootCategoryId()) { export function categoryTreeRequest(pool, rootCategoryId = getRootCategoryId()) {
return pool.request().input('rootCategoryId', sql.Int, rootCategoryId); return pool
.request()
.input('rootCategoryId', sql.Int, rootCategoryId)
.input('kShop', sql.Int, getActiveShopId());
} }

View File

@@ -1,18 +1,31 @@
import sql from 'mssql'; import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoCompositeProductCount } from '../demo/store.js';
import { getPool } from '../db.js'; import { getPool } from '../db.js';
import { getActiveShopId } from '../shop.js';
const COMPOSITE_PRODUCT_COUNT_SQL = ` const COMPOSITE_PRODUCT_COUNT_SQL = `
SELECT COUNT(DISTINCT a.kArtikel) AS CompositeProductCount SELECT COUNT(DISTINCT a.kArtikel) AS CompositeProductCount
FROM dbo.tArtikel a FROM dbo.tArtikel a
INNER JOIN dbo.tStueckliste s ON s.kStueckliste = a.kStueckliste INNER JOIN dbo.tStueckliste s ON s.kStueckliste = a.kStueckliste
WHERE a.kStueckliste <> 0 WHERE a.kStueckliste <> 0
AND (@kShop = 0 OR EXISTS (
SELECT 1 FROM dbo.tKategorieArtikel ka
INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie AND ks.kShop = @kShop
WHERE ka.kArtikel = a.kArtikel
))
AND CONVERT(BIGINT, a.bRowversion) > @cursor; AND CONVERT(BIGINT, a.bRowversion) > @cursor;
`; `;
export async function getCompositeProductCount({ cursor = 0 } = {}) { export async function getCompositeProductCount({ cursor = 0 } = {}) {
if (isDemoMode()) {
return getDemoCompositeProductCount({ cursor });
}
const result = await getPool() const result = await getPool()
.request() .request()
.input('cursor', sql.BigInt, cursor) .input('cursor', sql.BigInt, cursor)
.input('kShop', sql.Int, getActiveShopId())
.query(COMPOSITE_PRODUCT_COUNT_SQL); .query(COMPOSITE_PRODUCT_COUNT_SQL);
return result.recordset[0]?.CompositeProductCount ?? 0; return result.recordset[0]?.CompositeProductCount ?? 0;
} }

View File

@@ -1,5 +1,8 @@
import sql from 'mssql'; import sql from 'mssql';
import { isDemoMode } from '../demo/mode.js';
import { getDemoCompositeProductList } from '../demo/store.js';
import { getPool } from '../db.js'; import { getPool } from '../db.js';
import { getActiveShopId } from '../shop.js';
const COMPOSITE_PRODUCT_LIST_SQL = ` const COMPOSITE_PRODUCT_LIST_SQL = `
SELECT TOP (@limit) SELECT TOP (@limit)
@@ -10,15 +13,25 @@ SELECT TOP (@limit)
FROM dbo.tStueckliste s FROM dbo.tStueckliste s
INNER JOIN dbo.tArtikel a ON a.kArtikel = s.kVaterArtikel INNER JOIN dbo.tArtikel a ON a.kArtikel = s.kVaterArtikel
WHERE a.kStueckliste <> 0 WHERE a.kStueckliste <> 0
AND (@kShop = 0 OR EXISTS (
SELECT 1 FROM dbo.tKategorieArtikel ka
INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie AND ks.kShop = @kShop
WHERE ka.kArtikel = a.kArtikel
))
AND CONVERT(BIGINT, a.bRowversion) > @cursor AND CONVERT(BIGINT, a.bRowversion) > @cursor
ORDER BY lastChanged ASC; ORDER BY lastChanged ASC;
`; `;
export async function getCompositeProductList({ cursor = 0, limit = 100 } = {}) { export async function getCompositeProductList({ cursor = 0, limit = 100 } = {}) {
if (isDemoMode()) {
return getDemoCompositeProductList({ cursor, limit });
}
const result = await getPool() const result = await getPool()
.request() .request()
.input('cursor', sql.BigInt, cursor) .input('cursor', sql.BigInt, cursor)
.input('limit', sql.Int, limit) .input('limit', sql.Int, limit)
.input('kShop', sql.Int, getActiveShopId())
.query(COMPOSITE_PRODUCT_LIST_SQL); .query(COMPOSITE_PRODUCT_LIST_SQL);
return result.recordset.map((row) => ({ return result.recordset.map((row) => ({

779
src/queries/create-order.js Normal file
View File

@@ -0,0 +1,779 @@
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';
const config = {
kBenutzer: Number(process.env.JTL_KBENUTZER) || 1,
kFirmaHistory: Number(process.env.JTL_KFIRMAHISTORY) || 0,
kSprache: Number(process.env.JTL_KSPRACHE) || 1,
kPlattform: Number(process.env.JTL_KPLATTFORM) || 7,
kVersandArt: Number(process.env.JTL_KVERSANDART) || 0,
kKundengruppe: Number(process.env.JTL_KKUNDENGRUPPE) || 0,
// kLaufendeNummer 3 = Auftrag, 6 = Kunde in dbo.tLaufendeNummern
orderNumberSequence: Number(process.env.JTL_ORDER_NUMBER_SEQUENCE) || 3,
customerNumberSequence: Number(process.env.JTL_CUSTOMER_NUMBER_SEQUENCE) || 6,
};
/** DB-dependent foreign keys, resolved once on first order. */
let resolvedDefaults = null;
async function getDefaults() {
if (resolvedDefaults) {
return resolvedDefaults;
}
const result = await getPool().request().input('kPlattform', sql.Int, config.kPlattform).query(`
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 = @kPlattform) THEN @kPlattform ELSE 1 END) AS kPlattform;
`);
const row = result.recordset[0] || {};
resolvedDefaults = {
kFirmaHistory: config.kFirmaHistory || row.kFirmaHistory || 1,
kVersandArt: config.kVersandArt || row.kVersandArt || 1,
kKundengruppe: config.kKundengruppe || row.kKundengruppe || 1,
kPlattform: row.kPlattform || 1,
};
return resolvedDefaults;
}
const COUNTRY_NAMES = {
DE: 'Deutschland',
AT: 'Österreich',
CH: 'Schweiz',
};
function toNumber(value, fallback = 0) {
const n = parseFloat(String(value ?? '').replace(',', '.'));
return Number.isFinite(n) ? n : fallback;
}
// order.creationDate comes in as "YYYY-MM-DD HH:mm:ss" (no timezone); treat it as
// local server time, same as `new Date('YYYY-MM-DDTHH:mm:ss')` would. Falls back to
// the server's current time if missing/unparseable.
function parseOrderDate(creationDate) {
if (creationDate) {
const parsed = new Date(String(creationDate).replace(' ', 'T'));
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
}
return new Date();
}
// JTL maps VAT rates to Steuerklassen: 19% => 1 (voller Satz), 7% => 2 (ermäßigt)
function steuerklasseForVat(vat) {
if (vat >= 15) return 1;
if (vat > 0) return 2;
return 1;
}
function isoWeek(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
}
/**
* Resolves the Admin > Nummernkreise placeholders used in tLaufendeNummern's
* cPrefix/cSuffix columns: J = Jahr, M = Monat, T = Tag, K = Kalenderwoche.
*/
function formatNumberPlaceholders(template, date) {
if (!template) return '';
const pad2 = (n) => String(n).padStart(2, '0');
return template
.replace(/<J>/g, String(date.getFullYear()))
.replace(/<M>/g, pad2(date.getMonth() + 1))
.replace(/<T>/g, pad2(date.getDate()))
.replace(/<K>/g, pad2(isoWeek(date)));
}
/**
* Atomically increments nNummer for a dbo.tLaufendeNummern row and returns
* the fully formatted number (cPrefix + nNummer + cSuffix, with placeholders
* resolved against `date`).
*/
async function nextNumberFromSequence(transaction, kLaufendeNummer, date) {
const result = await new sql.Request(transaction).input('kLaufendeNummer', sql.Int, kLaufendeNummer).query(`
DECLARE @n INT, @cPrefix NVARCHAR(50), @cSuffix NVARCHAR(50);
UPDATE dbo.tLaufendeNummern
SET @n = nNummer = nNummer + 1, @cPrefix = cPrefix, @cSuffix = cSuffix
WHERE kLaufendeNummer = @kLaufendeNummer;
SELECT @n AS nNummer, @cPrefix AS cPrefix, @cSuffix AS cSuffix;
`);
const row = result.recordset[0];
if (!row || row.nNummer == null) {
throw new Error(`dbo.tLaufendeNummern has no row ${kLaufendeNummer}`);
}
const prefix = formatNumberPlaceholders(row.cPrefix, date);
const suffix = formatNumberPlaceholders(row.cSuffix, date);
return `${prefix}${row.nNummer}${suffix}`;
}
/**
* Allocates the next primary key for a table via dbo.tpk
* (JTL tables like tZahlung/tZahlungsart have no identity column).
*/
async function allocatePk(transaction, tableName) {
const result = await new sql.Request(transaction).input('cName', sql.NVarChar, tableName).query(`
DECLARE @pk INT;
UPDATE dbo.tpk SET @pk = nummer, nummer = nummer + 1, dChanged = GETDATE() WHERE cName = @cName;
SELECT @pk AS pk;
`);
const pk = result.recordset[0]?.pk;
if (pk == null) {
throw new Error(`dbo.tpk has no row for table '${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 lookupName = String(name || 'Bar');
const cacheKey = lookupName.toLowerCase();
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
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 (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, lookupName)
.query(`
INSERT INTO dbo.tZahlungsart
(kZahlungsart, cName, cPrtString, nLastschrift, cPrtStringVor, cPaymentOption, cKonto,
nAusliefernVorZahlung, nPrioritaet, nMahnwesenAktiv, fSkontoWert, nSkontoZeitraum,
nMatchingOptionen, nIstStandard, nAktiv)
VALUES (@kZahlungsart, @cName, '', 0, '', '', '', 0, 0, 0, 0, 0, 0, 0, 1)
`);
zahlungsart = { kZahlungsart, cName: lookupName };
}
cache.set(cacheKey, zahlungsart);
return zahlungsart;
}
async function nextCustomerNumber(transaction) {
return nextNumberFromSequence(transaction, config.customerNumberSequence, new Date());
}
/**
* Creates a Kunde via Kunde.spKundeInsert (which fills tKunde, tAdresse and
* the search tables). Columns of the table type are named explicitly because
* their order differs between JTL versions.
*/
async function createCustomer(transaction, { customerNumber, address, defaults }) {
const a = address || {};
const iso = (a.countryIso || 'DE').toUpperCase();
const kKundengruppe = Number(a.customerGroupId) || defaults.kKundengruppe;
const result = await new sql.Request(transaction)
.input('cKundenNr', sql.NVarChar, customerNumber)
.input('cFirma', sql.NVarChar, a.company || '')
.input('cAnrede', sql.NVarChar, a.salutation || '')
.input('cTitel', sql.NVarChar, a.title || '')
.input('cVorname', sql.NVarChar, a.firstName || '')
.input('cName', sql.NVarChar, a.lastName || 'Laufkunde')
.input('cStrasse', sql.NVarChar, a.street || '-')
.input('cPLZ', sql.NVarChar, a.zipCode || '')
.input('cOrt', sql.NVarChar, a.city || '-')
.input('cLand', sql.NVarChar, COUNTRY_NAMES[iso] || iso)
.input('cTel', sql.NVarChar, a.phone || '')
.input('cFax', sql.NVarChar, a.fax || '')
.input('cEMail', sql.NVarChar, a.email || '')
.input('cMobil', sql.NVarChar, a.mobile || '')
.input('fRabatt', sql.Decimal(18, 13), toNumber(a.discount, 0))
.input('cAdressZusatz', sql.NVarChar, a.addressAddition || '')
.input('cGeburtstag', sql.NVarChar, a.birthday || '')
.input('kKundenGruppe', sql.Int, kKundengruppe)
.input('kSprache', sql.Int, config.kSprache)
.input('cISO', sql.NVarChar, iso)
.input('cBundesland', sql.NVarChar, a.state || '')
.input('cHerkunft', sql.NVarChar, 'Kasse')
.input('cKassenKunde', sql.Char(1), 'Y')
.input('nDebitorennr', sql.Int, Number(a.debtorNumber) || 0)
.query(`
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, @cKundenNr, @cFirma, @cAnrede, @cTitel, @cVorname, @cName,
@cStrasse, @cPLZ, @cOrt, @cLand, @cTel, @cFax, @cEMail, GETDATE(), @cMobil, @fRabatt, NULL, 'N',
N'', N'', 0, @cAdressZusatz, @cGeburtstag, N'', 'N', NULL, @kKundenGruppe,
0, @kSprache, @cISO, @cBundesland, @cHerkunft, @cKassenKunde, N'', 0,
@nDebitorennr, N'', 0, 0, 0, 0, 0,
NULL, 0, 0, 0);
EXEC @returnValue = Kunde.spKundeInsert @daten = @kunde_daten;
SELECT @returnValue AS kKunde;
`);
const kKunde = result.recordset[0]?.kKunde;
if (!kKunde || kKunde <= 0) {
throw new Error(`Kunde.spKundeInsert failed for '${customerNumber}'`);
}
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. Walk-in orders use (or create) the Kassenkunde.
*/
async function resolveCustomer(transaction, order, defaults) {
if (isWalkInOrder(order)) {
const kassenKunde = await lookupKassenkunde(transaction, defaults);
if (kassenKunde) return kassenKunde;
return createCustomer(transaction, {
customerNumber: await nextCustomerNumber(transaction),
address: order.billingAddress,
defaults,
});
}
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: result.recordset[0].kKunde,
kKundengruppe: result.recordset[0].kKundenGruppe || defaults.kKundengruppe,
};
}
return createCustomer(transaction, {
customerNumber,
address: order.billingAddress,
defaults,
});
}
async function nextOrderNumber(transaction, orderDate) {
return nextNumberFromSequence(transaction, config.orderNumberSequence, orderDate);
}
async function insertOrderAddress(transaction, kAuftrag, kKunde, address, nTyp) {
const a = address || {};
const iso = (a.countryIso || 'DE').toUpperCase();
await new sql.Request(transaction)
.input('kAuftrag', sql.Int, kAuftrag)
.input('kKunde', sql.Int, kKunde)
.input('cFirma', sql.NVarChar, a.company || '')
.input('cAnrede', sql.NVarChar, a.salutation || '')
.input('cTitel', sql.NVarChar, a.title || '')
.input('cVorname', sql.NVarChar, a.firstName || '')
.input('cName', sql.NVarChar, a.lastName || '-')
.input('cStrasse', sql.NVarChar, a.street || '-')
.input('cPLZ', sql.NVarChar, a.zipCode || '')
.input('cOrt', sql.NVarChar, a.city || '-')
.input('cLand', sql.NVarChar, COUNTRY_NAMES[iso] || iso)
.input('cTel', sql.NVarChar, a.phone || '')
.input('cZusatz', sql.NVarChar, a.extraAddressLine || '')
.input('cAdressZusatz', sql.NVarChar, a.addressAddition || '')
.input('cMobil', sql.NVarChar, a.mobile || '')
.input('cMail', sql.NVarChar, a.email || '')
.input('cFax', sql.NVarChar, a.fax || '')
.input('cBundesland', sql.NVarChar, a.state || '')
.input('cISO', sql.NVarChar, iso)
.input('nTyp', sql.Int, nTyp)
.query(`
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 (@kAuftrag, @kKunde, @cFirma, @cAnrede, @cTitel, @cVorname, @cName, @cStrasse, @cPLZ, @cOrt, @cLand,
@cTel, @cZusatz, @cAdressZusatz, @cMobil, @cMail, @cFax, @cBundesland, @cISO, @nTyp, 0)
`);
}
async function insertOrderItem(transaction, kAuftrag, item) {
const vat = toNumber(item.vat, 19);
const quantity = toNumber(item.quantity, 1);
const priceNet = toNumber(item.priceNet, toNumber(item.priceGross) / (1 + vat / 100));
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 && 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)
.input('cArtNr', sql.NVarChar, kArtikel ? sku : null)
.input('cName', sql.NVarChar, item.name || sku || 'Position')
.input('cHinweis', sql.NVarChar, item.note || '')
.input('fAnzahl', sql.Float, quantity)
.input('fVkNetto', sql.Float, priceNet)
.input('fMwSt', sql.Float, vat)
.input('kSteuerklasse', sql.Int, kSteuerklasse)
.input('nType', sql.Int, nType)
.input('nReserviert', sql.Int, nReserviert)
.input('cEinheit', sql.NVarChar, item.unit || '')
.input('fRabatt', sql.Float, discount)
.query(`
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 (@kArtikel, @kAuftrag, @cArtNr, @nReserviert, @cName, @cHinweis, @fAnzahl, @fVkNetto, @fMwSt,
@cName, @kSteuerklasse, @nType, @cEinheit, 1.0, 3, @fRabatt);
SELECT kAuftragPosition FROM @t;
`);
return result.recordset[0]?.kAuftragPosition ?? null;
}
/**
* 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.
*/
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)
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(`
INSERT INTO Pos.tAuftragMapping (kAuftrag, kPosAuftrag, kShopSubShop)
VALUES (@kAuftrag, @kPosAuftrag, @kShopSubShop)
`);
}
async function insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition) {
const kShopSubShop = getActiveShopSubshopId();
if (!Number.isInteger(kAuftragPosition) || !Number.isInteger(kPosAuftragPosition) || !kShopSubShop) {
return;
}
await new sql.Request(transaction)
.input('kAuftragPosition', sql.Int, kAuftragPosition)
.input('kPosAuftragPosition', sql.Int, kPosAuftragPosition)
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(`
INSERT INTO Pos.tAuftragPositionMapping (kAuftragPosition, kPosAuftragPosition, kShopSubShop)
VALUES (@kAuftragPosition, @kPosAuftragPosition, @kShopSubShop)
`);
}
/**
* Mirrors JTL's own PosOrderCreationService -> DeliveryStep.Run(): after a POS
* order is created, `Settings.Deliver` (default true, see decompiled
* jtlCore.Classes.Sync.PosOrderSteps.DeliveryStep:199,
* `bool flag = orderEntity.Settings?.Deliver ?? true;`) decides whether the
* order is delivered immediately. Digital-voucher partial delivery
* (IstGutscheinDigital) is intentionally not reproduced.
*
* The actual delivery mechanics (Pickliste creation, real stock decrement,
* numbered Lieferschein) live in ./delivery/ - see delivery.md for the full
* investigation behind it.
*/
function isOrderDelivered(order) {
const deliver = order.settings?.deliver;
if (deliver === undefined || deliver === null) {
return true;
}
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 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)
.input('fBetrag', sql.Float, toNumber(payment.amount, 0))
.input('kBestellung', sql.Int, kAuftrag)
.input('kBenutzer', sql.Int, config.kBenutzer)
.input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart)
.input('cExternalTransactionId', sql.NVarChar, order.externalOrderNumber || '')
.input('fOffenerWert', sql.Float, fOffenerWert)
.query(`
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);
try {
const zahlungsartCache = new Map();
// Use the client-supplied order.creationDate for stored timestamps (dErstellt,
// number-sequence date placeholders, payment date), falling back to the
// server's current time if it's missing/unparseable.
const orderDate = parseOrderDate(order.creationDate);
const { kKunde, kKundengruppe } = await resolveCustomer(transaction, order, defaults);
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)
.input('kBenutzer', sql.Int, config.kBenutzer)
.input('kKunde', sql.Int, kKunde)
.input('kFirmaHistory', sql.Int, defaults.kFirmaHistory)
.input('kSprache', sql.Int, config.kSprache)
.input('cWaehrung', sql.NVarChar, order.currencyIso || 'EUR')
.input('kPlattform', sql.Int, defaults.kPlattform)
.input('kShop', sql.Int, getActiveShopId() || null)
.input('cKundenNr', sql.NVarChar, resolveAuftragCKundenNr(order))
.input('cVersandlandISO', sql.NVarChar, (order.shippingAddress?.countryIso || 'DE').toUpperCase())
.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, 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, @nIstExterneRechnung, 'Y', @nIstReadOnly, 0, 10);
SELECT kAuftrag FROM @t;
`);
const kAuftrag = resultAuftrag.recordset[0].kAuftrag;
const kPosAuftrag = Number.parseInt(order.externalId, 10);
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 orderItems) {
const kAuftragPosition = await insertOrderItem(transaction, kAuftrag, item);
const kPosAuftragPosition = Number.parseInt(item.externalId, 10);
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, kVersandArt, deliveredItems);
}
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;
`);
await transaction.commit();
return { orderId: String(kAuftrag), orderNumber: cAuftragsNr, alreadyExists: false };
} catch (err) {
try {
await transaction.rollback();
} catch {
// transaction may already be aborted by the server
}
throw err;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
import sql from 'mssql';
import { xmlTag, xmlElement } from './xml.js';
/**
* Commits this session's reservations into "real" Picklisten (bumps
* dbo.tPicklistePos.nStatus from <10 to 10/20 for this session). Mirrors
* AuslieferungContext.PicklistenCommitten -> Auslieferung.spPicklistenUebernehmen.
*
* nTeillieferung = 0: partial delivery is forbidden, matching create-order.js
* always delivering the full ordered quantity (see delivery.md §6).
*
* XML is passed as NVarChar and CONVERT()ed to xml inside the batch, and
* @xResult is read back via SELECT rather than a true XML OUTPUT parameter
* (tedious does not reliably round-trip xml OUTPUT params for this proc).
*/
export async function commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag) {
const bestellungen = xmlElement('Bestellung', [xmlTag('kBestellung', kAuftrag)]);
await new sql.Request(transaction)
.input('Bestellungen', sql.NVarChar(sql.MAX), bestellungen)
.input('kBenutzer', sql.Int, kBenutzer)
.input('nTeillieferung', sql.Bit, false)
.input('kSessionId', sql.Int, kSessionId)
.query(`
DECLARE @xBestellungen XML = CONVERT(XML, @Bestellungen);
DECLARE @xResult XML;
EXEC Auslieferung.spPicklistenUebernehmen
@Bestellungen = @xBestellungen,
@kBenutzer = @kBenutzer,
@nTeillieferung = @nTeillieferung,
@kSessionId = @kSessionId,
@xResult = @xResult OUTPUT;
`);
}

View File

@@ -0,0 +1,56 @@
import sql from 'mssql';
import { xmlTag, xmlElement } from './xml.js';
// PicklistenAusliefernOptionen.VersandSetzen (0x0002). DeliveryStep.Run always sets
// AuslieferungAusliefernContext.Optionen.VersandSetzen = true for POS/API orders
// (decompiled jtlCore.Classes.Sync.PosOrderSteps.DeliveryStep.cs:213), so we do too
// -- see delivery.md §0.1 for the cross-check that settled this.
const AUSLIEFERN_OPTIONS = 0x002;
/**
* Actually delivers the session's committed Picklisten: creates the properly
* numbered dbo.tLieferschein/tLieferscheinPos rows, links them back onto
* tPicklistePos, decrements real warehouse stock (dbo.spWarenlagerAusgangSchreiben),
* and bumps tPicklistePos.nStatus to 40. Mirrors
* AuslieferungAusliefernContext.Commit -> Auslieferung.spPicklistenAusliefern.
*
* @Pakete matters even for local (non-fulfillment) warehouses: the nested
* Auslieferung.spPicklistenAusliefern_PaketeErzeugen only creates a
* dbo.tVersand row for a Lieferschein if #XMLPAKET (built straight from
* @Pakete) contains at least one row for its kBestellung - if @Pakete is
* NULL, #XMLPAKET stays empty and NO tVersand row is written for ANY
* Lieferschein in the session, at all. So we always pass one <Paket> per
* order carrying its kVersandArt (e.g. "Selbstabholer"), even though we
* don't have real tracking/weight info to report yet.
*
* Returns the raw @xResult XML (NewDeliveryNotes/ProcessedPicklisten/... - see
* delivery.md §3.3), read back via SELECT since tedious does not reliably
* round-trip xml OUTPUT params for this proc.
*/
export async function deliverPicklists(transaction, kBenutzer, kSessionId, kAuftrag, kVersandArt) {
const pakete = xmlElement('Paket', [
xmlTag('kBestellung', kAuftrag),
xmlTag('kVersandart', kVersandArt),
xmlTag('fGewicht', 0), // NOT NULL column; real weight is recomputed from the order's articles further below in the proc
]);
const result = await new sql.Request(transaction)
.input('Pakete', sql.NVarChar(sql.MAX), pakete)
.input('nOptions', sql.Int, AUSLIEFERN_OPTIONS)
.input('kBenutzer', sql.Int, kBenutzer)
.input('kSessionId', sql.Int, kSessionId)
.query(`
DECLARE @xHinweise XML = NULL;
DECLARE @xPakete XML = CONVERT(XML, @Pakete);
DECLARE @xResult XML;
EXEC Auslieferung.spPicklistenAusliefern
@xHinweise = @xHinweise,
@Pakete = @xPakete,
@nOptions = @nOptions,
@kBenutzer = @kBenutzer,
@kSessionId = @kSessionId,
@xResult = @xResult OUTPUT;
SELECT @xResult AS xResult;
`);
return result.recordset[0]?.xResult ?? null;
}

View File

@@ -0,0 +1,55 @@
import { openSession, discardSession, closeSession } from './session.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';
/**
* Delivers the full ordered quantity of every position of an order, exactly
* reproducing JTL-Wawi's own fulfillment engine (jtlCore's
* PosOrderCreationService -> DeliveryStep.Run -> AuslieferungAusliefernContext.Commit())
* instead of the old shortcut of calling Versand.spLieferscheinErstellen /
* spLieferscheinPosErstellen directly. See ../../../delivery.md for the full
* investigation (§0 has the literal SQL sequence this function implements).
*
* Creates real dbo.tPickliste/tPicklistePos rows, decrements real warehouse
* stock, produces a properly numbered Lieferschein ("<AuftragsNr>-NNN"), and
* a dbo.tVersand row carrying the order's Versandart (e.g. "Selbstabholer"),
* matching what the Wawi GUI / native POS sync produce.
*/
export async function deliverOrder(transaction, kBenutzer, kAuftrag, kVersandArt, deliveredItems) {
if (!deliveredItems.length) {
return;
}
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 {
// Safe to run unconditionally (success or error): spPicklistenVerwerfen only
// ever removes not-yet-delivered (nStatus < 10) Picklisten for this session.
// Swallow cleanup errors so they never mask an earlier, more relevant error
// (the outer transaction rollback in create-order.js is what actually matters
// on failure).
try {
await discardSession(transaction, kBenutzer, kSessionId);
await closeSession(transaction, kSessionId);
} catch {
// best-effort cleanup only
}
}
}

View File

@@ -0,0 +1,54 @@
import sql from 'mssql';
import { xmlTag, xmlElement } from './xml.js';
// ReserviereBestellpositionenOptionen: ChargenVorbelegen (0x0002) | Stücklistenkorrektur (0x0100).
// Matches jtlCore's AuslieferungContext ctor + the StoredProcedures.cs wrapper for
// Auslieferung_spReserviereBestellpositionen (see delivery.md §3.1).
const RESERVIERE_OPTIONS = 0x102;
/**
* Reserves every order position against the outgoing warehouse, creating
* dbo.tPickliste/tPicklistePos rows (status 0 -> 5) for this session.
* Mirrors PosBookPositionService.BookPosition -> AuslieferungContext.NeuReservieren
* -> Auslieferung.spReserviereBestellpositionen.
*
* XML parameters are passed as NVarChar and CONVERT()ed to xml in the SQL
* batch itself (rather than using sql.Xml directly), because tedious does
* not reliably round-trip the `xml` SQL type for these procedures.
*/
export async function reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, positions) {
if (!positions.length) {
return;
}
const bestellpositionen = positions
.map(({ kAuftragPosition, quantity }) =>
xmlElement('Bestellposition', [xmlTag('kBestellPos', kAuftragPosition), xmlTag('fAnzahl', quantity)])
)
.join('');
const laeger = xmlElement('Lager', [
xmlTag('kWarenlager', kWarenLager),
xmlTag('nPrio', 0),
xmlTag('kLieferant', 0),
xmlTag('kAnsprechpartner', 0),
]);
await new sql.Request(transaction)
.input('Bestellpositionen', sql.NVarChar(sql.MAX), bestellpositionen)
.input('Laeger', sql.NVarChar(sql.MAX), laeger)
.input('nOptions', sql.Int, RESERVIERE_OPTIONS)
.input('kBenutzer', sql.Int, kBenutzer)
.input('kSessionId', sql.Int, kSessionId)
.query(`
DECLARE @xBestellpositionen XML = CONVERT(XML, @Bestellpositionen);
DECLARE @xLaeger XML = CONVERT(XML, @Laeger);
EXEC Auslieferung.spReserviereBestellpositionen
@Bestellpositionen = @xBestellpositionen,
@Laeger = @xLaeger,
@Warenlagereingaenge = NULL,
@nOptions = @nOptions,
@kBenutzer = @kBenutzer,
@kSessionId = @kSessionId;
`);
}

Some files were not shown because too many files have changed in this diff Show More