Compare commits
34 Commits
917930d0fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6312eaec48 | ||
|
|
3e8ea1ef76 | ||
|
|
e9f29dc569 | ||
|
|
10c4269da0 | ||
|
|
94e44e8e61 | ||
|
|
b9c574074f | ||
|
|
ba705c7d08 | ||
|
|
fc7afd1be8 | ||
|
|
c27ad52e2a | ||
|
|
538ecf7a80 | ||
|
|
7bd8df22d1 | ||
|
|
8057933939 | ||
|
|
b922229687 | ||
|
|
886830cd83 | ||
|
|
72b74f5037 | ||
|
|
1de4144ae2 | ||
|
|
056c9e18cd | ||
|
|
7fa839da4d | ||
|
|
ca7c4af973 | ||
|
|
2c76b401b5 | ||
|
|
262fa63f1b | ||
|
|
245fb047d7 | ||
|
|
b7b76c9d39 | ||
|
|
d471f2411d | ||
|
|
955fe2c164 | ||
|
|
03dde8eff8 | ||
|
|
8d55da79b0 | ||
|
|
53a7924c7f | ||
|
|
cbc4b8315e | ||
|
|
23c5b76027 | ||
|
|
ee09434ea9 | ||
|
|
7f6095013b | ||
|
|
ca190b2832 | ||
|
|
3ca6e894ab |
14
.env.example
14
.env.example
@@ -1,3 +1,6 @@
|
||||
# Demo catalog (skips MSSQL; requires `npm run demo:generate` first)
|
||||
DEMO_MODE=false
|
||||
|
||||
# HTTPS POS server
|
||||
PORT=4443
|
||||
AUTH_TOKEN=df40ad2067954646abb0499548a52241
|
||||
@@ -5,25 +8,20 @@ PAIRING_CODE=307018
|
||||
LOG_FILE=logs/requests.log
|
||||
ORDER_LOG_FILE=logs/orders.log
|
||||
|
||||
# TLS certificate metadata returned during pairing
|
||||
CERTIFICATE_FINGERPRINT=BC2114CF407A42724BEEF417960F76DCBF9DE879
|
||||
CERTIFICATE_SERIAL_NUMBER=00BFC8BEACDB981B165210EF111CB9D3
|
||||
SERVER_FINGERPRINT=39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE
|
||||
|
||||
# Mandant
|
||||
MANDANT_ID=1
|
||||
MANDANT_NAME=eB-Standard
|
||||
MANDANT_DATABASE=eazybusiness
|
||||
ROOT_CATEGORY_ID=1
|
||||
|
||||
# Category sync (tKategorieSprache / tKategoriebildPlattform lookups)
|
||||
# Category sync (tKategorieSprache lookups)
|
||||
LANGUAGE_ID=1
|
||||
IMAGE_PLATFORM_ID=1
|
||||
IMAGE_SHOP_ID=0
|
||||
|
||||
# Product sync (tSteuerzone.cName used to look up tax rates per tSteuerklasse)
|
||||
TAX_ZONE_NAME=Zone-EU
|
||||
|
||||
# Shop filter: active shop is queried from tShopSubshop at startup
|
||||
|
||||
# MSSQL (connection data for JTL-Wawi database)
|
||||
MSSQL_SERVER=localhost
|
||||
MSSQL_PORT=1433
|
||||
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -1,4 +1,12 @@
|
||||
node_modules/
|
||||
.env
|
||||
certs/
|
||||
logs/
|
||||
/node_modules/
|
||||
/.env
|
||||
/certs/
|
||||
/logs/
|
||||
/capturedDataReference
|
||||
/decompiledReference
|
||||
/scripts/s3-backup/data/
|
||||
/scripts/s3-backup/tmp/
|
||||
/scripts/s3-backup/certs/
|
||||
/scripts/minimal-db/data/
|
||||
# Generated demo catalog assets (keep src/demo/ source tracked)
|
||||
/demo/
|
||||
|
||||
499
API.md
Normal file
499
API.md
Normal 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, 3–4 category levels, variants, real photos).
|
||||
2. Set `DEMO_MODE=true` in `.env` (see `.env.example`).
|
||||
3. Start the server as usual (`npm start`). Pairing and all sync endpoints work; orders are logged and return synthetic `OK` results.
|
||||
|
||||
Demo mode is **opt-in only** — a failed MSSQL connection does not enable it.
|
||||
|
||||
---
|
||||
|
||||
## `GET /v1/client` — Pairing
|
||||
|
||||
Discovers the server and completes pairing with a 6-digit code.
|
||||
|
||||
### Query parameters
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `authCode` | yes | Pairing code. Short codes (1–4 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 1–4). 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 per–customer-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** | Per–customer-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
666
delivery.md
Normal 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.
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -12,13 +13,68 @@ const certPath = path.join(certsDir, 'cert.pem');
|
||||
|
||||
fs.mkdirSync(certsDir, { recursive: true });
|
||||
|
||||
const subject = '/CN=localhost/O=JTL POS Sync/C=DE';
|
||||
const san = 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0';
|
||||
function isIp(value) {
|
||||
return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(':');
|
||||
}
|
||||
|
||||
function localIpv4s() {
|
||||
const ips = [];
|
||||
for (const entries of Object.values(os.networkInterfaces())) {
|
||||
for (const entry of entries || []) {
|
||||
if (entry.family !== 'IPv4' || entry.internal) continue;
|
||||
// Skip link-local / docker / libvirt bridge noise by default — keep LAN + extras via args
|
||||
if (entry.address.startsWith('169.254.')) continue;
|
||||
if (entry.address.startsWith('172.17.')) continue;
|
||||
if (entry.address.startsWith('192.168.122.')) continue;
|
||||
ips.push(entry.address);
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
const dnsNames = new Set(['localhost']);
|
||||
const ipAddrs = new Set(['127.0.0.1', '0.0.0.0']);
|
||||
|
||||
for (const ip of localIpv4s()) {
|
||||
ipAddrs.add(ip);
|
||||
}
|
||||
|
||||
const extras = [
|
||||
...(process.env.CERT_SAN || '').split(/[,\s]+/).filter(Boolean),
|
||||
...process.argv.slice(2),
|
||||
];
|
||||
for (const value of extras) {
|
||||
if (isIp(value)) ipAddrs.add(value);
|
||||
else dnsNames.add(value);
|
||||
}
|
||||
|
||||
const sanParts = [
|
||||
...[...dnsNames].map((name) => `DNS:${name}`),
|
||||
...[...ipAddrs].map((ip) => `IP:${ip}`),
|
||||
];
|
||||
const san = `subjectAltName=${sanParts.join(',')}`;
|
||||
const cn = [...dnsNames][0] || 'localhost';
|
||||
const subject = `/CN=${cn}/O=JTL POS Sync/C=DE`;
|
||||
|
||||
// ECDSA P-256 keeps pairing QR codes much smaller than RSA-2048
|
||||
execSync(
|
||||
`openssl req -x509 -newkey rsa:2048 -nodes -keyout "${keyPath}" -out "${certPath}" -days 3650 -subj "${subject}" -addext "${san}"`,
|
||||
`openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout "${keyPath}" -out "${certPath}" -days 3650 -subj "${subject}" -addext "${san}"`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
|
||||
const sha1 = execSync(`openssl x509 -in "${certPath}" -noout -fingerprint -sha1`, {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.trim()
|
||||
.split('=')[1];
|
||||
const serial = execSync(`openssl x509 -in "${certPath}" -noout -serial`, {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.trim()
|
||||
.split('=')[1];
|
||||
|
||||
logger.success(`Wrote ${keyPath}`);
|
||||
logger.success(`Wrote ${certPath}`);
|
||||
logger.info(`SAN: ${sanParts.join(', ')}`);
|
||||
logger.info(`Fingerprint: ${sha1.replace(/:/g, '')}`);
|
||||
logger.info(`Serial: ${serial}`);
|
||||
|
||||
29
jtlsrv-cpp/.cursor/rules/no-background-servers.mdc
Normal file
29
jtlsrv-cpp/.cursor/rules/no-background-servers.mdc
Normal 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
19
jtlsrv-cpp/.gitignore
vendored
Normal 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
89
jtlsrv-cpp/Makefile
Normal 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
132
jtlsrv-cpp/README.md
Normal 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
|
||||
```
|
||||
28
jtlsrv-cpp/logs/orders.log
Normal file
28
jtlsrv-cpp/logs/orders.log
Normal file
@@ -0,0 +1,28 @@
|
||||
2026-07-14T20:11:01 #1 externalId=79 {"ShippingDate":"","amountBack":"0","amountGiven":"0.08","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-13 07:40:35","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"79","externalOrderNumber":"R00081","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"126","isReturn":"0","name":"BioBizz Lightmix 50 Liter","note":"","orderItemId":"","priceGross":"0.08","priceNet":"0.08","purchasePriceNet":"0.0","quantity":"1","sku":"4","totalPriceGross":"0.08","totalPriceNet":"0.08","type":"1","unit":"","vat":"0.00"}],"paymentMethodName":"BAR","payments":[{"amount":"0.08","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"0.08","totalNet":"0.08","type":"0","username":"admin"}
|
||||
2026-07-14T20:14:29 #1 externalId=80 {"ShippingDate":"","amountBack":"0","amountGiven":"11","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:12:47","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"80","externalOrderNumber":"R00082","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"127","isReturn":"0","name":"Peach Cotton Candy 1g","note":"","orderItemId":"","priceGross":"11","priceNet":"9.243697478991596","purchasePriceNet":"0.0","quantity":"1","sku":"42001111-1","totalPriceGross":"11","totalPriceNet":"9.24","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"11","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"11","totalNet":"9.24","type":"0","username":"admin"}
|
||||
2026-07-14T20:15:38 #1 externalId=99999 {"currencyIso":"EUR","externalId":"99999","orderItems":[],"paymentMethodName":"Bar"}
|
||||
2026-07-14T20:16:01 #1 externalId=81 {"ShippingDate":"","amountBack":"0","amountGiven":"45","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:14:12","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"81","externalOrderNumber":"R00083","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"128","isReturn":"0","name":"Vending Machine 5g","note":"","orderItemId":"","priceGross":"45","priceNet":"37.81512605042017","purchasePriceNet":"0.0","quantity":"1","sku":"42001099-9","totalPriceGross":"45","totalPriceNet":"37.82","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"45","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"45","totalNet":"37.82","type":"0","username":"admin"}
|
||||
2026-07-14T20:16:45 #1 externalId=81 {"ShippingDate":"","amountBack":"0","amountGiven":"45","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:14:12","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"81","externalOrderNumber":"R00083","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"128","isReturn":"0","name":"Vending Machine 5g","note":"","orderItemId":"","priceGross":"45","priceNet":"37.81512605042017","purchasePriceNet":"0.0","quantity":"1","sku":"42001099-9","totalPriceGross":"45","totalPriceNet":"37.82","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"45","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"45","totalNet":"37.82","type":"0","username":"admin"}
|
||||
2026-07-14T20:17:02 #1 externalId=81 {"ShippingDate":"","amountBack":"0","amountGiven":"45","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:14:12","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"81","externalOrderNumber":"R00083","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"128","isReturn":"0","name":"Vending Machine 5g","note":"","orderItemId":"","priceGross":"45","priceNet":"37.81512605042017","purchasePriceNet":"0.0","quantity":"1","sku":"42001099-9","totalPriceGross":"45","totalPriceNet":"37.82","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"45","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"45","totalNet":"37.82","type":"0","username":"admin"}
|
||||
2026-07-14T20:18:27 #1 externalId=81 {"ShippingDate":"","amountBack":"0","amountGiven":"45","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:14:12","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"81","externalOrderNumber":"R00083","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"128","isReturn":"0","name":"Vending Machine 5g","note":"","orderItemId":"","priceGross":"45","priceNet":"37.81512605042017","purchasePriceNet":"0.0","quantity":"1","sku":"42001099-9","totalPriceGross":"45","totalPriceNet":"37.82","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"45","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"45","totalNet":"37.82","type":"0","username":"admin"}
|
||||
2026-07-14T20:19:12 #1 externalId=83 {"ShippingDate":"","amountBack":"0","amountGiven":"45","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:14:12","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"83","externalOrderNumber":"R00085","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"130","isReturn":"0","name":"Vending Machine 5g","note":"","orderItemId":"","priceGross":"45","priceNet":"37.81512605042017","purchasePriceNet":"0.0","quantity":"1","sku":"42001099-9","totalPriceGross":"45","totalPriceNet":"37.82","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"45","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"45","totalNet":"37.82","type":"0","username":"admin"}
|
||||
2026-07-14T20:22:19 #1 externalId=82 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:16:01","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"82","externalOrderNumber":"R00084","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"129","isReturn":"0","name":"King Palm Mars Grinder The Gift","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001021-5","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2026-07-14T20:23:56 #1 externalId=84 {"billingAddress":{"countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","lastName":"Laufkunde"},"creationDate":"2026-07-14 18:14:12","currencyIso":"EUR","customerNumber":"420strainz","externalId":"84","externalOrderNumber":"R00086","orderItems":[{"discountPercent":"0","externalId":"131","name":"Vending Machine 5g","priceGross":"45","priceNet":"37.82","quantity":"1","sku":"42001099-9","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"45","paymentMethodName":"BAR"}],"settings":{"deliver":"1"},"shippingAddress":{"countryIso":"DE","lastName":"Laufkunde"},"totalGross":"45","totalNet":"37.82"}
|
||||
2026-07-14T20:24:45 #1 externalId=83 {"ShippingDate":"","amountBack":"0","amountGiven":"15","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:24:41","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"83","externalOrderNumber":"R00085","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"130","isReturn":"0","name":"Cannasseur Lupe","note":"","orderItemId":"","priceGross":"15","priceNet":"12.605042016806722","purchasePriceNet":"0.0","quantity":"1","sku":"42001165","totalPriceGross":"15","totalPriceNet":"12.61","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"15","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"15","totalNet":"12.61","type":"0","username":"admin"}
|
||||
2026-07-14T20:25:17 #2 externalId=84 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 18:25:13","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"84","externalOrderNumber":"R00086","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"131","isReturn":"0","name":"Cannasseur Cooling Case","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001166","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2026-07-14T20:39:28 #1 externalId=83 {"currencyIso":"EUR","customerNumber":"420strainz","externalId":"83","orderItems":[],"paymentMethodName":"BAR"}
|
||||
2026-07-14T22:54:26 #1 externalId=85 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 20:54:06","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"85","externalOrderNumber":"R00087","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"132","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2026-07-14T23:03:00 #1 externalId=86 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 21:02:50","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"86","externalOrderNumber":"R00088","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"133","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2026-07-14T23:10:48 #2 externalId=87 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 21:10:45","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"87","externalOrderNumber":"R00089","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"134","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2026-07-14T23:11:59 #3 externalId=88 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 21:11:57","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"88","externalOrderNumber":"R00090","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"135","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2026-07-14T23:14:38 #4 externalId=89 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 21:14:35","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"89","externalOrderNumber":"T00090","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"136","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2026-07-15T00:53:23 #1 externalId=90 {"ShippingDate":"","amountBack":"0","amountGiven":"14.90","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 22:31:13","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"90","externalOrderNumber":"T00091","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"137","isReturn":"0","name":"RAW Donut Tray","note":"","orderItemId":"","priceGross":"14.90","priceNet":"12.521008403361344","purchasePriceNet":"0.0","quantity":"1","sku":"42001172","totalPriceGross":"14.90","totalPriceNet":"12.52","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"14.90","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"14.90","totalNet":"12.52","type":"0","username":"admin"}
|
||||
2026-07-15T00:55:25 #2 externalId=91 {"ShippingDate":"","amountBack":"0","amountGiven":"89.40","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 22:55:16","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"91","externalOrderNumber":"T00092","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"138","isReturn":"0","name":"RAW Donut Tray","note":"","orderItemId":"","priceGross":"14.90","priceNet":"12.521008403361344","purchasePriceNet":"0.0","quantity":"6","sku":"42001172","totalPriceGross":"89.40","totalPriceNet":"75.13","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"89.40","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"89.40","totalNet":"75.13","type":"0","username":"admin"}
|
||||
2026-07-15T00:56:08 #3 externalId=92 {"ShippingDate":"","amountBack":"0","amountGiven":"89.40","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 22:55:56","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"92","externalOrderNumber":"T00093","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"139","isReturn":"0","name":"RAW Donut Tray","note":"","orderItemId":"","priceGross":"14.90","priceNet":"12.521008403361344","purchasePriceNet":"0.0","quantity":"6","sku":"42001172","totalPriceGross":"89.40","totalPriceNet":"75.13","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"89.40","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"89.40","totalNet":"75.13","type":"0","username":"admin"}
|
||||
2026-07-15T01:14:05 #1 externalId=93 {"ShippingDate":"","amountBack":"0","amountGiven":"89.40","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-14 23:13:57","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"93","externalOrderNumber":"T00094","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"140","isReturn":"0","name":"RAW Donut Tray","note":"","orderItemId":"","priceGross":"14.90","priceNet":"12.521008403361344","purchasePriceNet":"0.0","quantity":"6","sku":"42001172","totalPriceGross":"89.40","totalPriceNet":"75.13","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"89.40","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"89.40","totalNet":"75.13","type":"0","username":"admin"}
|
||||
2026-07-15T02:19:52 #1 externalId=94 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-15 00:19:47","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"94","externalOrderNumber":"T00095","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"141","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2026-07-15T02:21:57 #1 externalId=94 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-15 00:19:47","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"94","externalOrderNumber":"T00095","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"141","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2026-07-15T02:25:07 #1 externalId=95 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-15 00:25:02","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"95","externalOrderNumber":"T00096","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"142","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2026-07-15T02:27:05 #2 externalId=96 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-15 00:27:02","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"96","externalOrderNumber":"T00097","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"143","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2026-07-15T02:37:40 #1 externalId=97 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-15 00:37:31","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"97","externalOrderNumber":"T00098","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"144","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2026-07-15T02:42:47 #1 externalId=98 {"ShippingDate":"","amountBack":"0","amountGiven":"25","billingAddress":{"addressAddition":"","birthday":"","city":"","company":"","countryIso":"DE","customerGroupId":"","debtorNumber":"","discount":"0.0","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","toTheAttention":"","zipCode":""},"creationDate":"2026-07-15 00:42:41","currencyIso":"EUR","customerNumber":"420strainz","descriptionType":"","externalId":"98","externalOrderNumber":"T00099","languageIso":"de","note":"","orderId":"","orderItems":[{"discountPercent":"0","externalId":"145","isReturn":"0","name":"Milwaukee Ph600","note":"","orderItemId":"","priceGross":"25","priceNet":"21.008403361344538","purchasePriceNet":"0.0","quantity":"1","sku":"42001168","totalPriceGross":"25","totalPriceNet":"21.01","type":"1","unit":"","vat":"19.00"}],"paymentMethodName":"BAR","payments":[{"amount":"25","paymentId":"","paymentMethodName":"BAR"}],"rapRounding":"0","settings":{"deliver":"1","importSetting":"0","invoiceSetting":"0"},"shippingAddress":{"city":"","company":"","countryIso":"DE","deliveryInstruction":"","email":"","extraAddressLine":"","fax":"","firstName":"","lastName":"Laufkunde","mobile":"","phone":"","salutation":"","state":"","street":"","title":"","zipCode":""},"shippingInfo":"","shippingName":"","taxIdNumber":"","totalGross":"25","totalNet":"21.01","type":"0","username":"admin"}
|
||||
2862
jtlsrv-cpp/logs/requests.log
Normal file
2862
jtlsrv-cpp/logs/requests.log
Normal file
File diff suppressed because one or more lines are too long
64
jtlsrv-cpp/src/config.hpp
Normal file
64
jtlsrv-cpp/src/config.hpp
Normal 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
402
jtlsrv-cpp/src/db/pool.cpp
Normal 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;
|
||||
}
|
||||
98
jtlsrv-cpp/src/db/pool.hpp
Normal file
98
jtlsrv-cpp/src/db/pool.hpp
Normal 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();
|
||||
11
jtlsrv-cpp/src/endpoints/category.cpp
Normal file
11
jtlsrv-cpp/src/endpoints/category.cpp
Normal 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);
|
||||
}
|
||||
22
jtlsrv-cpp/src/endpoints/cimage.cpp
Normal file
22
jtlsrv-cpp/src/endpoints/cimage.cpp
Normal 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);
|
||||
}
|
||||
72
jtlsrv-cpp/src/endpoints/client.cpp
Normal file
72
jtlsrv-cpp/src/endpoints/client.cpp
Normal 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."}
|
||||
});
|
||||
}
|
||||
10
jtlsrv-cpp/src/endpoints/customergroup.cpp
Normal file
10
jtlsrv-cpp/src/endpoints/customergroup.cpp
Normal 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);
|
||||
}
|
||||
11
jtlsrv-cpp/src/endpoints/deleted_entity.cpp
Normal file
11
jtlsrv-cpp/src/endpoints/deleted_entity.cpp
Normal 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);
|
||||
}
|
||||
50
jtlsrv-cpp/src/endpoints/init.cpp
Normal file
50
jtlsrv-cpp/src/endpoints/init.cpp
Normal 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)}
|
||||
});
|
||||
}
|
||||
83
jtlsrv-cpp/src/endpoints/order.cpp
Normal file
83
jtlsrv-cpp/src/endpoints/order.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
22
jtlsrv-cpp/src/endpoints/pimage.cpp
Normal file
22
jtlsrv-cpp/src/endpoints/pimage.cpp
Normal 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);
|
||||
}
|
||||
11
jtlsrv-cpp/src/endpoints/product.cpp
Normal file
11
jtlsrv-cpp/src/endpoints/product.cpp
Normal 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);
|
||||
}
|
||||
11
jtlsrv-cpp/src/endpoints/productcomposite.cpp
Normal file
11
jtlsrv-cpp/src/endpoints/productcomposite.cpp
Normal 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
157
jtlsrv-cpp/src/http.cpp
Normal 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
83
jtlsrv-cpp/src/http.hpp
Normal 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
45
jtlsrv-cpp/src/log.cpp
Normal 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
35
jtlsrv-cpp/src/log.hpp
Normal 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
240
jtlsrv-cpp/src/main.cpp
Normal 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;
|
||||
}
|
||||
40
jtlsrv-cpp/src/order_log.cpp
Normal file
40
jtlsrv-cpp/src/order_log.cpp
Normal 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_);
|
||||
}
|
||||
}
|
||||
17
jtlsrv-cpp/src/order_log.hpp
Normal file
17
jtlsrv-cpp/src/order_log.hpp
Normal 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;
|
||||
27
jtlsrv-cpp/src/pairing.cpp
Normal file
27
jtlsrv-cpp/src/pairing.cpp
Normal 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_;
|
||||
}
|
||||
33
jtlsrv-cpp/src/pairing.hpp
Normal file
33
jtlsrv-cpp/src/pairing.hpp
Normal 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_;
|
||||
};
|
||||
60
jtlsrv-cpp/src/queries/category_list.hpp
Normal file
60
jtlsrv-cpp/src/queries/category_list.hpp
Normal 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;
|
||||
}
|
||||
35
jtlsrv-cpp/src/queries/commit.hpp
Normal file
35
jtlsrv-cpp/src/queries/commit.hpp
Normal 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
|
||||
40
jtlsrv-cpp/src/queries/composite_product_list.hpp
Normal file
40
jtlsrv-cpp/src/queries/composite_product_list.hpp
Normal 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;
|
||||
}
|
||||
74
jtlsrv-cpp/src/queries/counts.hpp
Normal file
74
jtlsrv-cpp/src/queries/counts.hpp
Normal 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}});
|
||||
}
|
||||
966
jtlsrv-cpp/src/queries/create_order.hpp
Normal file
966
jtlsrv-cpp/src/queries/create_order.hpp
Normal 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
|
||||
57
jtlsrv-cpp/src/queries/customer_groups.hpp
Normal file
57
jtlsrv-cpp/src/queries/customer_groups.hpp
Normal 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});
|
||||
}
|
||||
28
jtlsrv-cpp/src/queries/deleted_entity_list.hpp
Normal file
28
jtlsrv-cpp/src/queries/deleted_entity_list.hpp
Normal 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;
|
||||
}
|
||||
42
jtlsrv-cpp/src/queries/deliver.hpp
Normal file
42
jtlsrv-cpp/src/queries/deliver.hpp
Normal 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
|
||||
37
jtlsrv-cpp/src/queries/delivery.hpp
Normal file
37
jtlsrv-cpp/src/queries/delivery.hpp
Normal 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
|
||||
133
jtlsrv-cpp/src/queries/image.hpp
Normal file
133
jtlsrv-cpp/src/queries/image.hpp
Normal 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 {};
|
||||
}
|
||||
239
jtlsrv-cpp/src/queries/product_attributes.hpp
Normal file
239
jtlsrv-cpp/src/queries/product_attributes.hpp
Normal 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;
|
||||
}
|
||||
190
jtlsrv-cpp/src/queries/product_list.hpp
Normal file
190
jtlsrv-cpp/src/queries/product_list.hpp
Normal 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;
|
||||
}
|
||||
60
jtlsrv-cpp/src/queries/reserve.hpp
Normal file
60
jtlsrv-cpp/src/queries/reserve.hpp
Normal 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
|
||||
42
jtlsrv-cpp/src/queries/session.hpp
Normal file
42
jtlsrv-cpp/src/queries/session.hpp
Normal 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
|
||||
28
jtlsrv-cpp/src/queries/shop.cpp
Normal file
28
jtlsrv-cpp/src/queries/shop.cpp
Normal 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;
|
||||
}
|
||||
10
jtlsrv-cpp/src/queries/shop.hpp
Normal file
10
jtlsrv-cpp/src/queries/shop.hpp
Normal 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();
|
||||
137
jtlsrv-cpp/src/queries/stock_shortage.hpp
Normal file
137
jtlsrv-cpp/src/queries/stock_shortage.hpp
Normal 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
|
||||
37
jtlsrv-cpp/src/queries/warehouse.hpp
Normal file
37
jtlsrv-cpp/src/queries/warehouse.hpp
Normal 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
|
||||
40
jtlsrv-cpp/src/queries/xml.hpp
Normal file
40
jtlsrv-cpp/src/queries/xml.hpp
Normal 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 += "<"; break;
|
||||
case '>': r += ">"; break;
|
||||
case '&': r += "&"; break;
|
||||
case '\'': r += "'"; break;
|
||||
case '"': r += """; 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
|
||||
40
jtlsrv-cpp/src/request_log.cpp
Normal file
40
jtlsrv-cpp/src/request_log.cpp
Normal 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_);
|
||||
}
|
||||
16
jtlsrv-cpp/src/request_log.hpp
Normal file
16
jtlsrv-cpp/src/request_log.hpp
Normal 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
74
jtlsrv-cpp/src/router.cpp
Normal 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
34
jtlsrv-cpp/src/router.hpp
Normal 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;
|
||||
};
|
||||
383
jtlsrv-cpp/src/tls_server.cpp
Normal file
383
jtlsrv-cpp/src/tls_server.cpp
Normal 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";
|
||||
}
|
||||
}
|
||||
67
jtlsrv-cpp/src/tls_server.hpp
Normal file
67
jtlsrv-cpp/src/tls_server.hpp
Normal 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
510
jtlsrv-cpp/vendor/api.c
vendored
Normal 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
170
jtlsrv-cpp/vendor/http.c
vendored
Normal 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
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
903
jtlsrv-cpp/vendor/llhttp.h
vendored
Normal 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
24765
jtlsrv-cpp/vendor/nlohmann/json.hpp
vendored
Normal file
File diff suppressed because it is too large
Load Diff
665
package-lock.json
generated
665
package-lock.json
generated
@@ -9,13 +9,15 @@
|
||||
"version": "1.0.0",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"chalk": "^5.6.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"mssql": "^12.7.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure-rest/core-client": {
|
||||
@@ -883,6 +885,30 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -903,6 +929,29 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.11.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
||||
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "6.1.6",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
|
||||
@@ -960,6 +1009,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
@@ -972,6 +1030,41 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
|
||||
@@ -998,6 +1091,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/default-browser": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
|
||||
@@ -1047,6 +1173,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
@@ -1068,6 +1200,21 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
@@ -1086,6 +1233,55 @@
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@@ -1154,6 +1350,12 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
||||
@@ -1169,6 +1371,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-inside-container": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
|
||||
@@ -1251,6 +1462,18 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
@@ -1293,6 +1516,33 @@
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1318,12 +1568,39 @@
|
||||
"node": ">=18.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/native-duplexpair": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
|
||||
"integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.94.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
|
||||
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
|
||||
@@ -1342,6 +1619,87 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
@@ -1351,6 +1709,48 @@
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
@@ -1367,6 +1767,21 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
@@ -1417,6 +1832,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
@@ -1466,6 +1887,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
@@ -1481,6 +1947,118 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
|
||||
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream/node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream/node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/tarn": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tarn/-/tarn-3.1.0.tgz",
|
||||
@@ -1517,12 +2095,56 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||
@@ -1537,6 +2159,47 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
package.json
14
package.json
@@ -8,16 +8,26 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"chalk": "^5.6.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"mssql": "^12.7.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.35.3"
|
||||
}
|
||||
}
|
||||
|
||||
63
productSync.md
Normal file
63
productSync.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Product Sync Protocol
|
||||
|
||||
## The Sync Flow
|
||||
|
||||
1. **The POS client polls `/api/v1/init`** with its current `lastChangedProduct` cursor (e.g. `24599`). The server counts how many products have a row version greater than that cursor and returns the count.
|
||||
|
||||
2. **If `product_count > 0`**, the client fetches `/api/v1/product` with the same cursor and a `limit` (e.g. 20). The server returns the changed products ordered by row version ascending.
|
||||
|
||||
3. **The client updates its local cursor** to the highest `lastChanged` value it received in that batch (e.g. `24609`), so the next poll only fetches newer changes.
|
||||
|
||||
### Example from the log
|
||||
|
||||
```
|
||||
init with lastChangedProduct=24599 -> product_count=0 (no changes)
|
||||
init with lastChangedProduct=24599 -> product_count=1 (a new product appeared)
|
||||
product fetch lastChangedProduct=24599 -> returns product with lastChanged=24609
|
||||
init with lastChangedProduct=24609 -> product_count=0 (caught up)
|
||||
```
|
||||
|
||||
## What is `lastChangedProduct`?
|
||||
|
||||
It's the **MSSQL `bRowversion`** value -- an auto-incrementing binary counter that SQL Server bumps whenever a row is modified. The queries in `product-count.js` and `product-list.js` filter with:
|
||||
|
||||
```sql
|
||||
WHERE CONVERT(BIGINT, a.bRowversion) > @cursor
|
||||
```
|
||||
|
||||
The client simply persists the highest `lastChanged` value it received so it only gets new/changed rows on subsequent polls.
|
||||
|
||||
## How it's kept track
|
||||
|
||||
**Server side**: The cursor is stateless -- the client sends its last-known value every time. The server just runs the SQL query filtering rows above that value.
|
||||
|
||||
**Client side**: The POS client stores its `lastChangedProduct` (and `lastChangedCategory`, `lastChangedCustomerGroup`, etc.) persistently so it can resume syncing after a restart without re-downloading everything.
|
||||
|
||||
## Other entity types
|
||||
|
||||
The same pattern applies to:
|
||||
|
||||
- **Categories** (`lastChangedCategory` / `bRowversion` on `tKategorie`)
|
||||
- **Customer groups** (`lastChangedCustomerGroup` / `bRowversion` on `tKundenGruppe`)
|
||||
- **Composite products** (`lastChangedCompositeProduct` / `bRowversion` on `tArtikelBaugruppe`)
|
||||
- **Deleted entities** (`lastChangedDeletedEntity` / `bLastChanged` on `Pos.vDeletedEntity`)
|
||||
|
||||
## Deleted entity sync
|
||||
|
||||
When entities are deleted in JTL-Wawi, they appear in the `Pos.vDeletedEntity` view with their entity type:
|
||||
|
||||
| nEntityType | Entity |
|
||||
|---|---|
|
||||
| 1 | Artikel (Product) |
|
||||
| 2 | Kategorie (Category) |
|
||||
| 7 | Stückliste (Composite Product) |
|
||||
|
||||
The client polls `/v1/init` with `lastChangedDeletedEntity` cursor, and if `deletedEntity_count > 0`, fetches `/v1/deletedentity` to get the list of deleted entities. The response format is:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "entityId": "123", "entityType": "1", "lastChanged": "208980" }
|
||||
]
|
||||
```
|
||||
|
||||
The client then removes these entities from its local database.
|
||||
1005
scripts/create-minimal-db.mjs
Normal file
1005
scripts/create-minimal-db.mjs
Normal file
File diff suppressed because it is too large
Load Diff
73
scripts/create-pairing-qr.mjs
Normal file
73
scripts/create-pairing-qr.mjs
Normal 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}`);
|
||||
781
scripts/generate-demo-catalog.mjs
Normal file
781
scripts/generate-demo-catalog.mjs
Normal file
@@ -0,0 +1,781 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates demo/catalog.json + demo/images/*.jpg for DEMO_MODE.
|
||||
* Downloads real photos from LoremFlickr matched to category / product keywords
|
||||
* (Picsum fallback). Variant siblings share a base photo and get a light tint.
|
||||
*
|
||||
* Usage: node scripts/generate-demo-catalog.mjs
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const DEMO_DIR = path.join(ROOT, 'demo');
|
||||
const IMAGES_DIR = path.join(DEMO_DIR, 'images');
|
||||
|
||||
const IMAGE_SIZE = 480;
|
||||
const JPEG_QUALITY = 65;
|
||||
const DOWNLOAD_CONCURRENCY = 8;
|
||||
const TARGET_PRODUCTS = 1100;
|
||||
|
||||
const COLORS = ['Red', 'Blue', 'Green', 'Black', 'White', 'Grey', 'Navy', 'Beige'];
|
||||
const MATERIALS = ['Oak', 'Pine', 'Steel', 'Aluminium', 'Cotton', 'Leather', 'Plastic', 'Bamboo'];
|
||||
const PACKAGE_SIZES = ['250g', '500g', '1kg', '2kg', '5kg', '10pcs', '20pcs', '50pcs'];
|
||||
|
||||
const TINTS = {
|
||||
Red: { r: 220, g: 60, b: 60 },
|
||||
Blue: { r: 50, g: 90, b: 200 },
|
||||
Green: { r: 40, g: 150, b: 70 },
|
||||
Black: { r: 30, g: 30, b: 30 },
|
||||
White: { r: 230, g: 230, b: 230 },
|
||||
Grey: { r: 120, g: 120, b: 120 },
|
||||
Navy: { r: 20, g: 40, b: 90 },
|
||||
Beige: { r: 210, g: 190, b: 150 },
|
||||
};
|
||||
|
||||
/** Flickr-friendly tags for catalog subjects (category / product type names). */
|
||||
const KEYWORD_ALIASES = {
|
||||
'Home & Living': 'livingroom,interior,home',
|
||||
Furniture: 'furniture,home',
|
||||
Seating: 'seating,chair',
|
||||
Armchairs: 'armchair,chair',
|
||||
Sofas: 'sofa,couch',
|
||||
Stools: 'stool,chair',
|
||||
Tables: 'table,furniture',
|
||||
'Coffee Tables': 'coffee,table',
|
||||
'Side Tables': 'side,table',
|
||||
'Dining Tables': 'dining,table',
|
||||
Storage: 'storage,cabinet',
|
||||
Shelves: 'shelf,bookshelf',
|
||||
Cabinets: 'cabinet,cupboard',
|
||||
Decor: 'decor,interior',
|
||||
Lighting: 'lamp,lighting',
|
||||
'Floor Lamps': 'floor,lamp',
|
||||
'Table Lamps': 'table,lamp',
|
||||
Pendants: 'pendant,lamp',
|
||||
Textiles: 'textile,fabric',
|
||||
Cushions: 'cushion,pillow',
|
||||
Throws: 'blanket,throw',
|
||||
Rugs: 'rug,carpet',
|
||||
WallArt: 'wallart,painting',
|
||||
Prints: 'poster,print',
|
||||
Mirrors: 'mirror,reflection',
|
||||
Office: 'office,workspace',
|
||||
Desks: 'desk,office',
|
||||
Standing: 'standing,desk',
|
||||
'Electric Desks': 'standing,desk',
|
||||
'Manual Desks': 'desk,office',
|
||||
Sitting: 'desk,office',
|
||||
'Compact Desks': 'desk,workspace',
|
||||
'Executive Desks': 'desk,office',
|
||||
Chairs: 'office,chair',
|
||||
Ergonomic: 'ergonomic,chair',
|
||||
'Mesh Chairs': 'office,chair',
|
||||
'Leather Chairs': 'leather,chair',
|
||||
Guest: 'guest,chair',
|
||||
'Stacking Chairs': 'stacking,chair',
|
||||
'Visitor Chairs': 'office,chair',
|
||||
Supplies: 'office,supplies',
|
||||
Paper: 'paper,stationery',
|
||||
'A4 Paper': 'paper,stack',
|
||||
Notebooks: 'notebook,journal',
|
||||
Writing: 'pen,writing',
|
||||
Pens: 'pen,fountain',
|
||||
Markers: 'marker,pen',
|
||||
Organizers: 'desk,organizer',
|
||||
Trays: 'tray,desk',
|
||||
'File Boxes': 'archive,box',
|
||||
Outdoor: 'outdoor,garden',
|
||||
Garden: 'garden,outdoors',
|
||||
Tools: 'garden,tools',
|
||||
'Hand Tools': 'hand,tools',
|
||||
'Power Tools': 'power,tools',
|
||||
Planters: 'planter,pot',
|
||||
'Ceramic Pots': 'ceramic,pot',
|
||||
'Hanging Baskets': 'hanging,basket',
|
||||
Benches: 'bench,park',
|
||||
Loungers: 'lounger,sunbed',
|
||||
Sports: 'sports,fitness',
|
||||
Fitness: 'fitness,gym',
|
||||
Weights: 'dumbbell,weights',
|
||||
Mats: 'yoga,mat',
|
||||
Bands: 'resistance,band',
|
||||
Recreation: 'recreation,sport',
|
||||
Balls: 'ball,sport',
|
||||
Rackets: 'tennis,racket',
|
||||
Kitchen: 'kitchen,cooking',
|
||||
Cookware: 'cookware,kitchen',
|
||||
Pots: 'cooking,pot',
|
||||
'Sauce Pans': 'saucepan,pot',
|
||||
'Stock Pots': 'stockpot,pot',
|
||||
Pans: 'frying,pan',
|
||||
'Frying Pans': 'frying,pan',
|
||||
Woks: 'wok,pan',
|
||||
Bakeware: 'bakeware,baking',
|
||||
'Baking Trays': 'baking,tray',
|
||||
'Cake Tins': 'cake,tin',
|
||||
Tableware: 'tableware,dishes',
|
||||
Plates: 'plate,dish',
|
||||
'Dinner Plates': 'dinner,plate',
|
||||
'Side Plates': 'plate,dish',
|
||||
Drinkware: 'drinkware,cup',
|
||||
Mugs: 'mug,coffee',
|
||||
Glasses: 'glass,drink',
|
||||
Cutlery: 'cutlery,silverware',
|
||||
'Fork Sets': 'fork,cutlery',
|
||||
'Knife Sets': 'knife,cutlery',
|
||||
Food: 'food,grocery',
|
||||
DryGoods: 'grocery,food',
|
||||
Pasta: 'pasta,noodles',
|
||||
Rice: 'rice,grain',
|
||||
Beans: 'beans,legume',
|
||||
Beverages: 'beverage,drink',
|
||||
Coffee: 'coffee,beans',
|
||||
Tea: 'tea,cup',
|
||||
Juice: 'juice,orange',
|
||||
Snacks: 'snack,food',
|
||||
Nuts: 'nuts,almond',
|
||||
Bars: 'granola,bar',
|
||||
};
|
||||
|
||||
const PRODUCT_STEMS = [
|
||||
'Classic',
|
||||
'Premium',
|
||||
'Essential',
|
||||
'Urban',
|
||||
'Nordic',
|
||||
'Studio',
|
||||
'Heritage',
|
||||
'Compact',
|
||||
'Pro',
|
||||
'Lite',
|
||||
'Max',
|
||||
'Basic',
|
||||
];
|
||||
|
||||
const STEM_RE = new RegExp(`^(${PRODUCT_STEMS.join('|')})\\s+`, 'i');
|
||||
|
||||
|
||||
/** Department → groups → subgroups → leaves (3–4 levels). */
|
||||
const TREE = {
|
||||
'Home & Living': {
|
||||
Furniture: {
|
||||
Seating: ['Armchairs', 'Sofas', 'Stools'],
|
||||
Tables: ['Coffee Tables', 'Side Tables', 'Dining Tables'],
|
||||
Storage: ['Shelves', 'Cabinets'],
|
||||
},
|
||||
Decor: {
|
||||
Lighting: ['Floor Lamps', 'Table Lamps', 'Pendants'],
|
||||
Textiles: ['Cushions', 'Throws', 'Rugs'],
|
||||
WallArt: ['Prints', 'Mirrors'],
|
||||
},
|
||||
},
|
||||
Office: {
|
||||
Desks: {
|
||||
Standing: ['Electric Desks', 'Manual Desks'],
|
||||
Sitting: ['Compact Desks', 'Executive Desks'],
|
||||
},
|
||||
Chairs: {
|
||||
Ergonomic: ['Mesh Chairs', 'Leather Chairs'],
|
||||
Guest: ['Stacking Chairs', 'Visitor Chairs'],
|
||||
},
|
||||
Supplies: {
|
||||
Paper: ['A4 Paper', 'Notebooks'],
|
||||
Writing: ['Pens', 'Markers'],
|
||||
Organizers: ['Trays', 'File Boxes'],
|
||||
},
|
||||
},
|
||||
Outdoor: {
|
||||
Garden: {
|
||||
Tools: ['Hand Tools', 'Power Tools'],
|
||||
Planters: ['Ceramic Pots', 'Hanging Baskets'],
|
||||
Furniture: ['Benches', 'Loungers'],
|
||||
},
|
||||
Sports: {
|
||||
Fitness: ['Weights', 'Mats', 'Bands'],
|
||||
Recreation: ['Balls', 'Rackets'],
|
||||
},
|
||||
},
|
||||
Kitchen: {
|
||||
Cookware: {
|
||||
Pots: ['Sauce Pans', 'Stock Pots'],
|
||||
Pans: ['Frying Pans', 'Woks'],
|
||||
Bakeware: ['Baking Trays', 'Cake Tins'],
|
||||
},
|
||||
Tableware: {
|
||||
Plates: ['Dinner Plates', 'Side Plates'],
|
||||
Drinkware: ['Mugs', 'Glasses'],
|
||||
Cutlery: ['Fork Sets', 'Knife Sets'],
|
||||
},
|
||||
Food: {
|
||||
DryGoods: ['Pasta', 'Rice', 'Beans'],
|
||||
Beverages: ['Coffee', 'Tea', 'Juice'],
|
||||
Snacks: ['Nuts', 'Bars'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function hashBuffer(buf) {
|
||||
return crypto.createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
function padSku(n) {
|
||||
return `DEMO-${String(n).padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
function barcodeFor(n) {
|
||||
return `200${String(n).padStart(10, '0')}`;
|
||||
}
|
||||
|
||||
function formatDateTime(date) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function grossPrice(net, taxRate) {
|
||||
return (Number(net) * (1 + Number(taxRate) / 100)).toFixed(2);
|
||||
}
|
||||
|
||||
function pick(arr, i) {
|
||||
return arr[i % arr.length];
|
||||
}
|
||||
|
||||
function variantCombos(axes) {
|
||||
const keys = Object.keys(axes);
|
||||
if (keys.length === 0) {
|
||||
return [{}];
|
||||
}
|
||||
let combos = [{}];
|
||||
for (const key of keys) {
|
||||
const next = [];
|
||||
for (const base of combos) {
|
||||
for (const value of axes[key]) {
|
||||
next.push({ ...base, [key]: value });
|
||||
}
|
||||
}
|
||||
combos = next;
|
||||
}
|
||||
return combos;
|
||||
}
|
||||
|
||||
function formatVariants(combo) {
|
||||
return Object.entries(combo)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
async function mapPool(items, concurrency, fn) {
|
||||
const results = new Array(items.length);
|
||||
let index = 0;
|
||||
|
||||
async function worker() {
|
||||
while (index < items.length) {
|
||||
const i = index++;
|
||||
results[i] = await fn(items[i], i);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
function subjectFromProductName(name) {
|
||||
return String(name || '')
|
||||
.replace(/\s*\([^)]*\)\s*$/, '')
|
||||
.replace(STEM_RE, '')
|
||||
.replace(/\s+Featured\s+\d+$/i, '')
|
||||
.replace(/\s+Item$/i, '')
|
||||
.replace(/\s+Starter Kit\s+\d+$/i, '')
|
||||
.replace(/\s+Kit$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function keywordsForSubject(subject) {
|
||||
if (!subject) {
|
||||
return 'product';
|
||||
}
|
||||
if (KEYWORD_ALIASES[subject]) {
|
||||
return KEYWORD_ALIASES[subject];
|
||||
}
|
||||
const cleaned = subjectFromProductName(subject);
|
||||
if (KEYWORD_ALIASES[cleaned]) {
|
||||
return KEYWORD_ALIASES[cleaned];
|
||||
}
|
||||
const tags = cleaned
|
||||
.replace(/&/g, ' ')
|
||||
.split(/[\s/_-]+/)
|
||||
.map((w) => w.toLowerCase().replace(/[^a-z0-9]/g, ''))
|
||||
.filter((w) => w.length > 2 && !['the', 'and', 'set', 'sets'].includes(w))
|
||||
.slice(0, 3);
|
||||
return tags.length > 0 ? tags.join(',') : 'product';
|
||||
}
|
||||
|
||||
function lockFromSeed(seed) {
|
||||
const hex = crypto.createHash('sha1').update(String(seed)).digest('hex').slice(0, 8);
|
||||
return Number.parseInt(hex, 16) % 1_000_000_000 || 1;
|
||||
}
|
||||
|
||||
async function fetchBuffer(url, retries = 4) {
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
headers: { 'User-Agent': 'jtlsrv-demo-catalog/1.0' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (buf.length < 1000) {
|
||||
throw new Error('image too small');
|
||||
}
|
||||
await sharp(buf).metadata();
|
||||
return buf;
|
||||
} catch (err) {
|
||||
if (attempt === retries - 1) {
|
||||
throw err;
|
||||
}
|
||||
await sleep(250 * (attempt + 1));
|
||||
}
|
||||
}
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
|
||||
const rawImageCache = new Map();
|
||||
|
||||
async function fetchMatchingImage(keywords, lock) {
|
||||
const cacheKey = `${keywords}|${lock}`;
|
||||
if (rawImageCache.has(cacheKey)) {
|
||||
return rawImageCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const pathTags = String(keywords)
|
||||
.split(',')
|
||||
.map((tag) => encodeURIComponent(tag.trim()))
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
|
||||
try {
|
||||
const flickrUrl = `https://loremflickr.com/${IMAGE_SIZE}/${IMAGE_SIZE}/${pathTags}?lock=${lock}`;
|
||||
const buf = await fetchBuffer(flickrUrl);
|
||||
rawImageCache.set(cacheKey, buf);
|
||||
return buf;
|
||||
} catch (err) {
|
||||
console.warn(` loremflickr miss (${keywords}): ${err.message}; falling back to picsum`);
|
||||
}
|
||||
|
||||
const picsumUrl = `https://picsum.photos/seed/${encodeURIComponent(`${keywords}-${lock}`)}/${IMAGE_SIZE}/${IMAGE_SIZE}.jpg`;
|
||||
const buf = await fetchBuffer(picsumUrl);
|
||||
rawImageCache.set(cacheKey, buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
async function processImage(raw, { tintKey = null, label = null } = {}) {
|
||||
let pipeline = sharp(raw).resize(IMAGE_SIZE, IMAGE_SIZE, { fit: 'cover' });
|
||||
|
||||
if (tintKey && TINTS[tintKey]) {
|
||||
const { r, g, b } = TINTS[tintKey];
|
||||
const overlay = await sharp({
|
||||
create: {
|
||||
width: IMAGE_SIZE,
|
||||
height: IMAGE_SIZE,
|
||||
channels: 4,
|
||||
background: { r, g, b, alpha: 0.28 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
pipeline = sharp(await pipeline.toBuffer()).composite([{ input: overlay, blend: 'over' }]);
|
||||
}
|
||||
|
||||
if (label) {
|
||||
const safe = String(label)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.slice(0, 40);
|
||||
const svg = Buffer.from(`
|
||||
<svg width="${IMAGE_SIZE}" height="${IMAGE_SIZE}">
|
||||
<rect x="0" y="${IMAGE_SIZE - 48}" width="${IMAGE_SIZE}" height="48" fill="rgba(0,0,0,0.45)"/>
|
||||
<text x="16" y="${IMAGE_SIZE - 18}" font-family="sans-serif" font-size="22" fill="white">${safe}</text>
|
||||
</svg>
|
||||
`);
|
||||
pipeline = sharp(await pipeline.toBuffer()).composite([{ input: svg, blend: 'over' }]);
|
||||
}
|
||||
|
||||
return pipeline.jpeg({ quality: JPEG_QUALITY, mozjpeg: true }).toBuffer();
|
||||
}
|
||||
|
||||
async function saveImage(jpegBuffer) {
|
||||
const hash = hashBuffer(jpegBuffer);
|
||||
const filePath = path.join(IMAGES_DIR, `${hash}.jpg`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, jpegBuffer);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function buildCategoryTree() {
|
||||
const categories = [];
|
||||
let nextId = 1;
|
||||
let sort = 0;
|
||||
let lastChanged = 1;
|
||||
|
||||
function add(name, pid, depth) {
|
||||
const id = nextId++;
|
||||
categories.push({
|
||||
_id: String(id),
|
||||
name,
|
||||
pid: pid === null ? '0' : String(pid),
|
||||
sort: String(++sort),
|
||||
lastChanged: String(lastChanged++),
|
||||
imghash: null,
|
||||
imgsrc: null,
|
||||
discounts: [],
|
||||
depth,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
for (const [dept, groups] of Object.entries(TREE)) {
|
||||
const deptId = add(dept, null, 1);
|
||||
for (const [group, subgroups] of Object.entries(groups)) {
|
||||
const groupId = add(group, deptId, 2);
|
||||
for (const [subgroup, leaves] of Object.entries(subgroups)) {
|
||||
const subgroupId = add(subgroup, groupId, 3);
|
||||
for (const leaf of leaves) {
|
||||
add(leaf, subgroupId, 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
function chooseAxes(leafName, familyIndex) {
|
||||
const foodish = /Pasta|Rice|Beans|Coffee|Tea|Juice|Nuts|Bars|Paper|Notebooks/.test(leafName);
|
||||
const furnitureish = /Chair|Sofa|Table|Desk|Shelf|Cabinet|Bench|Lounger|Lamp|Armchair|Stool/.test(leafName);
|
||||
|
||||
const mode = familyIndex % 5;
|
||||
if (foodish || mode === 0) {
|
||||
return { Size: PACKAGE_SIZES.slice(0, 4 + (familyIndex % 3)) };
|
||||
}
|
||||
if (furnitureish || mode === 1) {
|
||||
return {
|
||||
Color: COLORS.slice(0, 3 + (familyIndex % 3)),
|
||||
Material: MATERIALS.slice(0, 2 + (familyIndex % 2)),
|
||||
};
|
||||
}
|
||||
if (mode === 2) {
|
||||
return { Color: COLORS.slice(0, 4 + (familyIndex % 3)) };
|
||||
}
|
||||
if (mode === 3) {
|
||||
return { Material: MATERIALS.slice(0, 3 + (familyIndex % 3)) };
|
||||
}
|
||||
return {
|
||||
Color: COLORS.slice(0, 2 + (familyIndex % 2)),
|
||||
Size: PACKAGE_SIZES.slice(0, 2 + (familyIndex % 2)),
|
||||
};
|
||||
}
|
||||
|
||||
const MIN_PRODUCTS_PER_CATEGORY = 5;
|
||||
|
||||
function buildProducts(categories, customerGroupIds) {
|
||||
const products = [];
|
||||
const composites = [];
|
||||
let nextId = 1;
|
||||
let lastChanged = 1;
|
||||
let familyIndex = 0;
|
||||
const createdAt = formatDateTime(new Date('2024-01-15T10:00:00Z'));
|
||||
const taxRate = 19;
|
||||
const leafCategories = categories.filter((c) => c.depth === 4);
|
||||
|
||||
function pricesFor(net) {
|
||||
const base = grossPrice(net, taxRate);
|
||||
const wholesaleNet = Number(net) * 0.85;
|
||||
return customerGroupIds.map((customerGroupId, i) => ({
|
||||
customerGroupId: String(customerGroupId),
|
||||
customerId: '0',
|
||||
price: i === 0 ? base : grossPrice(wholesaleNet, taxRate),
|
||||
quantity: '0',
|
||||
}));
|
||||
}
|
||||
|
||||
function pushProduct(partial) {
|
||||
const id = nextId++;
|
||||
const net = partial.netPrice ?? 9.99 + (id % 80) * 1.25;
|
||||
const product = {
|
||||
_id: String(id),
|
||||
sku: padSku(id),
|
||||
barcode: barcodeFor(id),
|
||||
name: partial.name,
|
||||
tax_rate: String(taxRate),
|
||||
price: grossPrice(net, taxRate),
|
||||
created_at: createdAt,
|
||||
lastChanged: String(lastChanged++),
|
||||
categories_id: partial.categoryId,
|
||||
categories: [{ categoryId: partial.categoryId }],
|
||||
prices: pricesFor(net),
|
||||
is_parent: partial.is_parent ?? '0',
|
||||
parent: partial.parent ?? '0',
|
||||
variants: partial.variants ?? '',
|
||||
isCompositeProduct: partial.isCompositeProduct ?? '0',
|
||||
attributes: [],
|
||||
imghash: null,
|
||||
imgsrc: null,
|
||||
_tintKey: partial.tintKey ?? null,
|
||||
_label: partial.label ?? null,
|
||||
_seed: partial.seed,
|
||||
};
|
||||
products.push(product);
|
||||
return product;
|
||||
}
|
||||
|
||||
function countByCategory() {
|
||||
const counts = new Map();
|
||||
for (const product of products) {
|
||||
const id = product.categories_id;
|
||||
counts.set(id, (counts.get(id) || 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// Variant families on leaf categories (majority of catalog)
|
||||
for (const leaf of leafCategories) {
|
||||
const familiesPerLeaf = 2 + (Number(leaf._id) % 3); // 2–4 families
|
||||
for (let f = 0; f < familiesPerLeaf; f++) {
|
||||
familyIndex++;
|
||||
const stem = pick(PRODUCT_STEMS, familyIndex);
|
||||
const baseName = `${stem} ${leaf.name}`;
|
||||
const axes = chooseAxes(leaf.name, familyIndex);
|
||||
const combos = variantCombos(axes).slice(0, 8);
|
||||
|
||||
const parent = pushProduct({
|
||||
name: baseName,
|
||||
categoryId: leaf._id,
|
||||
is_parent: '1',
|
||||
parent: '0',
|
||||
variants: '',
|
||||
seed: `parent-${leaf._id}-${f}`,
|
||||
netPrice: 15 + (familyIndex % 40),
|
||||
});
|
||||
|
||||
for (let c = 0; c < combos.length; c++) {
|
||||
const combo = combos[c];
|
||||
const tintKey = combo.Color || null;
|
||||
pushProduct({
|
||||
name: `${baseName} (${formatVariants(combo)})`,
|
||||
categoryId: leaf._id,
|
||||
is_parent: '0',
|
||||
parent: parent._id,
|
||||
variants: formatVariants(combo),
|
||||
tintKey,
|
||||
label: formatVariants(combo),
|
||||
seed: `var-${leaf._id}-${f}-${c}`,
|
||||
netPrice: 15 + (familyIndex % 40) + c * 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
if (products.length >= TARGET_PRODUCTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (products.length >= TARGET_PRODUCTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated simple kit products for BOM (not variant parents/children)
|
||||
const kitLeaf = leafCategories[0];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const kit = pushProduct({
|
||||
name: `${pick(PRODUCT_STEMS, i + 7)} Starter Kit ${i + 1}`,
|
||||
categoryId: kitLeaf._id,
|
||||
isCompositeProduct: '1',
|
||||
seed: `kit-${i}`,
|
||||
netPrice: 49 + i * 5,
|
||||
});
|
||||
const components = products
|
||||
.filter((p) => p.is_parent === '0' && p.parent !== '0' && p._id !== kit._id)
|
||||
.slice(i * 3, i * 3 + 3);
|
||||
for (const comp of components) {
|
||||
composites.push({
|
||||
productId: kit._id,
|
||||
productIdComponent: comp._id,
|
||||
quantity: (1 + (i % 3)).toFixed(2),
|
||||
lastChanged: kit.lastChanged,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fill remaining with simple products across leaves
|
||||
let simpleIndex = 0;
|
||||
while (products.length < TARGET_PRODUCTS) {
|
||||
const leaf = leafCategories[simpleIndex % leafCategories.length];
|
||||
const stem = pick(PRODUCT_STEMS, simpleIndex + 3);
|
||||
pushProduct({
|
||||
name: `${stem} ${leaf.name} Item`,
|
||||
categoryId: leaf._id,
|
||||
seed: `simple-${leaf._id}-${simpleIndex}`,
|
||||
netPrice: 4.5 + (simpleIndex % 50),
|
||||
});
|
||||
simpleIndex++;
|
||||
}
|
||||
|
||||
// Every category (including top-level / intermediate) gets ≥5 products
|
||||
let ensureIndex = 0;
|
||||
for (const category of categories) {
|
||||
const counts = countByCategory();
|
||||
const have = counts.get(category._id) || 0;
|
||||
for (let i = have; i < MIN_PRODUCTS_PER_CATEGORY; i++) {
|
||||
const stem = pick(PRODUCT_STEMS, ensureIndex + i);
|
||||
pushProduct({
|
||||
name: `${stem} ${category.name} Featured ${i + 1}`,
|
||||
categoryId: category._id,
|
||||
seed: `ensure-${category._id}-${i}`,
|
||||
netPrice: 8 + ((ensureIndex + i) % 40),
|
||||
});
|
||||
}
|
||||
ensureIndex++;
|
||||
}
|
||||
|
||||
return { products, composites };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Generating demo catalog…');
|
||||
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
|
||||
// Clear previous images
|
||||
for (const file of fs.readdirSync(IMAGES_DIR)) {
|
||||
fs.unlinkSync(path.join(IMAGES_DIR, file));
|
||||
}
|
||||
|
||||
const categories = buildCategoryTree();
|
||||
const leafCategories = categories.filter((c) => c.depth === 4);
|
||||
console.log(`Categories: ${categories.length} (leaves: ${leafCategories.length})`);
|
||||
|
||||
const customerGroups = [
|
||||
{
|
||||
customerGroupId: '1',
|
||||
name: 'Standard',
|
||||
standard: '1',
|
||||
discountPercent: '0.00',
|
||||
lastChanged: '1',
|
||||
},
|
||||
{
|
||||
customerGroupId: '2',
|
||||
name: 'Wholesale',
|
||||
standard: '0',
|
||||
discountPercent: '10.00',
|
||||
lastChanged: '2',
|
||||
},
|
||||
];
|
||||
|
||||
const { products, composites } = buildProducts(
|
||||
categories,
|
||||
customerGroups.map((g) => Number(g.customerGroupId))
|
||||
);
|
||||
const perCategory = new Map();
|
||||
for (const product of products) {
|
||||
perCategory.set(product.categories_id, (perCategory.get(product.categories_id) || 0) + 1);
|
||||
}
|
||||
const minPerCategory = Math.min(...categories.map((c) => perCategory.get(c._id) || 0));
|
||||
console.log(
|
||||
`Products: ${products.length} (composites links: ${composites.length}, min per category: ${minPerCategory})`
|
||||
);
|
||||
|
||||
const categoryById = new Map(categories.map((c) => [c._id, c]));
|
||||
|
||||
const imageJobs = [
|
||||
...categories.map((c) => ({
|
||||
kind: 'category',
|
||||
ref: c,
|
||||
keywords: keywordsForSubject(c.name),
|
||||
lock: lockFromSeed(`cat-${c._id}`),
|
||||
tintKey: null,
|
||||
label: c.name,
|
||||
})),
|
||||
...products.map((p) => {
|
||||
const category = categoryById.get(p.categories_id);
|
||||
const subject =
|
||||
p.isCompositeProduct === '1'
|
||||
? 'gift basket'
|
||||
: category?.name || subjectFromProductName(p.name);
|
||||
const lockSeed = p.parent !== '0' ? `parent-${p.parent}` : `product-${p._id}`;
|
||||
return {
|
||||
kind: 'product',
|
||||
ref: p,
|
||||
keywords: keywordsForSubject(subject),
|
||||
lock: lockFromSeed(lockSeed),
|
||||
tintKey: p._tintKey,
|
||||
label: p.is_parent === '1' ? p.name : p._label,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
console.log(`Downloading / processing ${imageJobs.length} images (keyword-matched via LoremFlickr)…`);
|
||||
let done = 0;
|
||||
await mapPool(imageJobs, DOWNLOAD_CONCURRENCY, async (job) => {
|
||||
const raw = await fetchMatchingImage(job.keywords, job.lock);
|
||||
const jpeg = await processImage(raw, { tintKey: job.tintKey, label: job.label });
|
||||
const hash = await saveImage(jpeg);
|
||||
job.ref.imghash = hash;
|
||||
job.ref.imgsrc = hash;
|
||||
done++;
|
||||
if (done % 50 === 0 || done === imageJobs.length) {
|
||||
console.log(` images ${done}/${imageJobs.length} (unique fetches: ${rawImageCache.size})`);
|
||||
}
|
||||
});
|
||||
|
||||
// Strip generator-only fields
|
||||
for (const p of products) {
|
||||
delete p._tintKey;
|
||||
delete p._label;
|
||||
delete p._seed;
|
||||
}
|
||||
for (const c of categories) {
|
||||
delete c.depth;
|
||||
}
|
||||
|
||||
const deletedEntities = [
|
||||
{
|
||||
entityId: '999001',
|
||||
entityType: '1',
|
||||
lastChanged: '1',
|
||||
},
|
||||
];
|
||||
|
||||
const catalog = {
|
||||
version: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
customerGroups,
|
||||
categories,
|
||||
products,
|
||||
composites,
|
||||
deletedEntities,
|
||||
maxOrderId: 0,
|
||||
};
|
||||
|
||||
const catalogPath = path.join(DEMO_DIR, 'catalog.json');
|
||||
fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2));
|
||||
|
||||
const imageCount = fs.readdirSync(IMAGES_DIR).length;
|
||||
console.log(`Wrote ${catalogPath}`);
|
||||
console.log(`Images on disk: ${imageCount}`);
|
||||
console.log('Done.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
15068
scripts/minimal-db/definitions.json
Normal file
15068
scripts/minimal-db/definitions.json
Normal file
File diff suppressed because one or more lines are too long
184
scripts/minimal-db/manifest.json
Normal file
184
scripts/minimal-db/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
55
scripts/s3-backup/README.md
Normal file
55
scripts/s3-backup/README.md
Normal 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.
|
||||
267
scripts/s3-backup/backup.mjs
Normal file
267
scripts/s3-backup/backup.mjs
Normal 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);
|
||||
});
|
||||
40
scripts/s3-backup/config.mjs
Normal file
40
scripts/s3-backup/config.mjs
Normal 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}`;
|
||||
}
|
||||
197
scripts/s3-backup/ensure-certs.mjs
Normal file
197
scripts/s3-backup/ensure-certs.mjs
Normal 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 };
|
||||
}
|
||||
335
scripts/s3-backup/restore.mjs
Normal file
335
scripts/s3-backup/restore.mjs
Normal file
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
import { fork, spawn, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import {
|
||||
ACCESS_KEY,
|
||||
BUCKET,
|
||||
DATA_DIR,
|
||||
HOST,
|
||||
PORT,
|
||||
SECRET_KEY,
|
||||
SERVER_CERT,
|
||||
s3BaseUrl,
|
||||
} from './config.mjs';
|
||||
import { caTrustStatus, ensureCerts, installCaTrust } from './ensure-certs.mjs';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
dotenv.config({ path: path.join(root, '.env') });
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const args = new Set(argv.filter((a) => a.startsWith('--')));
|
||||
const positional = argv.filter((a) => !a.startsWith('--'));
|
||||
|
||||
const serverOnly = args.has('--server-only');
|
||||
const skipTrust = args.has('--skip-trust');
|
||||
const useLatest = args.has('--latest');
|
||||
const replace = !args.has('--no-replace');
|
||||
|
||||
const databaseArg = argv.find((a, i) => argv[i - 1] === '--database');
|
||||
const defaultDb = process.env.MSSQL_DATABASE || 'eazybusiness';
|
||||
const database = databaseArg || defaultDb;
|
||||
|
||||
function sqlcmd(query) {
|
||||
const server = process.env.MSSQL_SERVER || 'localhost';
|
||||
const port = process.env.MSSQL_PORT || '1433';
|
||||
const user = process.env.MSSQL_USER || 'sa';
|
||||
const password = process.env.MSSQL_PASSWORD || '';
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
'sqlcmd',
|
||||
['-W', '-C', '-S', `${server},${port}`, '-U', user, '-P', password, '-Q', query],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
process.stdout.write(chunk);
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
const output = `${stdout}${stderr}`;
|
||||
if (code !== 0 || /^\s*Msg \d+,/m.test(output)) {
|
||||
reject(new Error(output.trim() || 'sqlcmd failed'));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function listBackups(prefix) {
|
||||
if (!fs.existsSync(DATA_DIR)) return [];
|
||||
return fs
|
||||
.readdirSync(DATA_DIR)
|
||||
.filter((name) => name.endsWith('.bak') && name.startsWith(`${prefix}-`))
|
||||
.map((name) => ({
|
||||
name,
|
||||
path: path.join(DATA_DIR, name),
|
||||
mtime: fs.statSync(path.join(DATA_DIR, name)).mtimeMs,
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
}
|
||||
|
||||
function resolveBackupFile() {
|
||||
if (positional.length > 1) {
|
||||
throw new Error(`Expected at most one backup file, got: ${positional.join(', ')}`);
|
||||
}
|
||||
|
||||
if (positional.length === 1) {
|
||||
const input = positional[0];
|
||||
if (path.isAbsolute(input) || input.includes('/')) {
|
||||
const resolved = path.resolve(input);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
throw new Error(`Backup file not found: ${resolved}`);
|
||||
}
|
||||
const base = path.basename(resolved);
|
||||
const target = path.join(DATA_DIR, base);
|
||||
if (resolved !== target) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.copyFileSync(resolved, target);
|
||||
console.log(`Copied ${resolved} -> ${target}`);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
const onDisk = path.join(DATA_DIR, input);
|
||||
if (!fs.existsSync(onDisk)) {
|
||||
throw new Error(`Backup file not found: ${onDisk}`);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
if (useLatest || positional.length === 0) {
|
||||
const matches = listBackups(database);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`No backups found for ${database} in ${DATA_DIR}`);
|
||||
}
|
||||
console.log(`Using latest backup: ${matches[0].name}`);
|
||||
return matches[0].name;
|
||||
}
|
||||
|
||||
throw new Error('Specify a backup file or pass --latest');
|
||||
}
|
||||
|
||||
function databaseFromBackup(file) {
|
||||
const match = path.basename(file).match(/^(.+)-\d{4}-\d{2}-\d{2}T/);
|
||||
return match ? match[1] : database;
|
||||
}
|
||||
|
||||
async function isServerUp() {
|
||||
const probeHost = /^\d+\./.test(HOST) ? '127.0.0.1' : HOST;
|
||||
return new Promise((resolve) => {
|
||||
const opts = {
|
||||
host: probeHost,
|
||||
port: PORT,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
rejectUnauthorized: false,
|
||||
};
|
||||
if (!/^\d+\./.test(HOST)) {
|
||||
opts.servername = HOST;
|
||||
}
|
||||
const req = https.request(opts, (res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode === 403 || res.statusCode === 200);
|
||||
});
|
||||
req.on('error', () => resolve(false));
|
||||
req.setTimeout(1000, () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (await isServerUp()) return;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
throw new Error(`S3 endpoint did not start on https://${HOST}:${PORT}`);
|
||||
}
|
||||
|
||||
function pidOnPort(port) {
|
||||
const result = spawnSync('ss', ['-tlnp'], { encoding: 'utf8' });
|
||||
const match = result.stdout?.match(new RegExp(`:${port}\\s+.*?pid=(\\d+)`));
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
async function stopPortListener(port) {
|
||||
const stale = pidOnPort(port);
|
||||
if (!stale) return;
|
||||
try {
|
||||
process.kill(stale);
|
||||
} catch {
|
||||
spawnSync('fuser', ['-k', `${port}/tcp`], { stdio: 'pipe' });
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
|
||||
async function ensureServerProcess() {
|
||||
await stopPortListener(PORT);
|
||||
return startServerProcess();
|
||||
}
|
||||
|
||||
function ensureCredential() {
|
||||
const cred = s3BaseUrl();
|
||||
return sqlcmd(`
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.credentials WHERE name = N'${cred}')
|
||||
BEGIN
|
||||
CREATE CREDENTIAL [${cred}]
|
||||
WITH IDENTITY = 'S3 Access Key',
|
||||
SECRET = '${ACCESS_KEY}:${SECRET_KEY}';
|
||||
END
|
||||
`);
|
||||
}
|
||||
|
||||
async function restoreDatabase(db, file) {
|
||||
const url = `${s3BaseUrl()}/${file}`;
|
||||
const onDisk = path.join(DATA_DIR, file);
|
||||
if (!fs.existsSync(onDisk)) {
|
||||
throw new Error(`Backup file missing on disk: ${onDisk}`);
|
||||
}
|
||||
const mb = (fs.statSync(onDisk).size / (1024 * 1024)).toFixed(1);
|
||||
console.log(`Restoring ${db} <- ${url} (${mb} MB)`);
|
||||
|
||||
const replaceClause = replace ? ', REPLACE' : '';
|
||||
await sqlcmd(`
|
||||
IF DB_ID(N'${db}') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER DATABASE [${db}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||
END
|
||||
RESTORE DATABASE [${db}]
|
||||
FROM URL = '${url}'
|
||||
WITH STATS = 10, MAXTRANSFERSIZE = 20971520${replaceClause};
|
||||
ALTER DATABASE [${db}] SET MULTI_USER;
|
||||
`);
|
||||
}
|
||||
|
||||
function waitForSql() {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const result = spawnSync(
|
||||
'sqlcmd',
|
||||
[
|
||||
'-W',
|
||||
'-C',
|
||||
'-S',
|
||||
`${process.env.MSSQL_SERVER || 'localhost'},${process.env.MSSQL_PORT || '1433'}`,
|
||||
'-U',
|
||||
process.env.MSSQL_USER || 'sa',
|
||||
'-P',
|
||||
process.env.MSSQL_PASSWORD || '',
|
||||
'-Q',
|
||||
'SELECT 1',
|
||||
],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
if (result.status === 0 && !/Msg \d+,/.test(result.stdout || '')) {
|
||||
return;
|
||||
}
|
||||
spawnSync('sleep', ['2']);
|
||||
}
|
||||
throw new Error('MSSQL did not become ready');
|
||||
}
|
||||
|
||||
function startServerProcess() {
|
||||
const child = fork(new URL('./server.mjs', import.meta.url), {
|
||||
env: { ...process.env, S3_BACKUP_CHILD: '1' },
|
||||
stdio: 'inherit',
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (skipTrust) {
|
||||
const status = caTrustStatus();
|
||||
if (!status.inSync) {
|
||||
throw new Error(
|
||||
'PAL CA is out of sync with scripts/s3-backup/certs/ca.pem. Run: npm run backup:s3'
|
||||
);
|
||||
}
|
||||
console.log('Skipping PAL CA install (--skip-trust)');
|
||||
if (!fs.existsSync(SERVER_CERT)) {
|
||||
throw new Error('No TLS certs found. Run: npm run backup:s3');
|
||||
}
|
||||
} else {
|
||||
ensureCerts(HOST);
|
||||
const trust = installCaTrust();
|
||||
if (!trust.ok) {
|
||||
console.warn('Could not install CA into MSSQL container. Run:');
|
||||
console.warn(' docker exec -u root mssql mkdir -p /var/opt/mssql/security/ca-certificates');
|
||||
console.warn(' docker cp scripts/s3-backup/certs/ca.pem mssql:/var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem');
|
||||
console.warn(' docker exec -u root mssql chown mssql:mssql /var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem');
|
||||
console.warn(' docker restart mssql');
|
||||
} else {
|
||||
console.log('Installed S3 CA into MSSQL PAL trust store');
|
||||
if (trust.restarted) {
|
||||
console.log('Waiting for MSSQL to restart...');
|
||||
waitForSql();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serverChild = await ensureServerProcess();
|
||||
try {
|
||||
await waitForServer();
|
||||
|
||||
if (serverOnly) {
|
||||
console.log('Server running (--server-only). Ctrl+C to stop.');
|
||||
await new Promise((resolve) => serverChild.on('exit', resolve));
|
||||
return;
|
||||
}
|
||||
|
||||
const file = resolveBackupFile();
|
||||
const db = databaseArg || databaseFromBackup(file);
|
||||
await ensureCredential();
|
||||
await restoreDatabase(db, file);
|
||||
console.log(`\nRestored ${db} from ${file}`);
|
||||
} finally {
|
||||
if (!serverOnly) {
|
||||
serverChild.kill();
|
||||
await stopPortListener(PORT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.has('--help')) {
|
||||
console.log(`Usage: node scripts/s3-backup/restore.mjs [file.bak] [options]
|
||||
|
||||
Starts the local S3-compatible HTTPS endpoint and restores MSSQL from a .bak on disk.
|
||||
|
||||
Arguments:
|
||||
file.bak Backup filename in data/${BUCKET}/, or a path to copy from
|
||||
|
||||
Options:
|
||||
--latest Use newest backup matching --database (default if no file given)
|
||||
--database <name> Target database (default: MSSQL_DATABASE or name parsed from file)
|
||||
--no-replace Do not pass REPLACE to RESTORE
|
||||
--server-only Start endpoint only, no restore
|
||||
--skip-trust Skip installing CA cert into system trust store
|
||||
|
||||
Examples:
|
||||
npm run restore:s3:quick
|
||||
npm run restore:s3:quick -- eazybusiness-2026-07-23T19-02-44-903Z.bak
|
||||
npm run restore:s3:quick -- --database eazybusiness --latest
|
||||
|
||||
Reads MSSQL_* from .env in repo root.
|
||||
Backups are read from scripts/s3-backup/data/${BUCKET}/
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
335
scripts/s3-backup/server.mjs
Normal file
335
scripts/s3-backup/server.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
90
scripts/s3-backup/sigv4.mjs
Normal file
90
scripts/s3-backup/sigv4.mjs
Normal 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')}"`;
|
||||
}
|
||||
63
server.js
63
server.js
@@ -5,11 +5,15 @@ import { fileURLToPath } from 'node:url';
|
||||
import 'dotenv/config';
|
||||
|
||||
import { connectDb, closeDb } from './src/db.js';
|
||||
import { isDemoMode } from './src/demo/mode.js';
|
||||
import { loadDemoCatalog } from './src/demo/store.js';
|
||||
import { readCertMetadata } from './src/cert-meta.js';
|
||||
import { createJtlPosServer } from './src/jtl-server.js';
|
||||
import { createPairingStore } from './src/pairing.js';
|
||||
import { closeOrderLog } from './src/order-log.js';
|
||||
import { closeRequestLog, logRequest } from './src/request-log.js';
|
||||
import { logger } from './src/logger.js';
|
||||
import { fetchActiveShop } from './src/shop.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -35,6 +39,16 @@ function truncateUrl(url) {
|
||||
return `${url.slice(0, CONSOLE_URL_MAX_LENGTH)}...`;
|
||||
}
|
||||
|
||||
let lastLoggedInitUrl = null;
|
||||
let suppressedInitCount = 0;
|
||||
|
||||
function flushSuppressedInitLogs() {
|
||||
if (suppressedInitCount > 0) {
|
||||
logger.info(`Suppressed ${suppressedInitCount} duplicate init log(s)`);
|
||||
suppressedInitCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBody(buffer) {
|
||||
if (!buffer.length) {
|
||||
return '(empty)';
|
||||
@@ -46,11 +60,18 @@ function formatBody(buffer) {
|
||||
return `[binary ${buffer.length} bytes]`;
|
||||
}
|
||||
|
||||
const certPem = fs.readFileSync(certPath);
|
||||
const keyPem = fs.readFileSync(keyPath);
|
||||
const certMeta = readCertMetadata(certPem);
|
||||
|
||||
const pairingStore = createPairingStore();
|
||||
pairingStore.setPairingCode(PAIRING_CODE, 'JTL-POS');
|
||||
pairingStore.registerDevice(AUTH_TOKEN, 'JTL-POS');
|
||||
|
||||
const jtlHandler = createJtlPosServer(pairingStore, { authToken: AUTH_TOKEN });
|
||||
const jtlHandler = createJtlPosServer(pairingStore, {
|
||||
authToken: AUTH_TOKEN,
|
||||
...certMeta,
|
||||
});
|
||||
|
||||
const loggedJtlHandler = async (req, res) => {
|
||||
const started = Date.now();
|
||||
@@ -68,6 +89,19 @@ const loggedJtlHandler = async (req, res) => {
|
||||
|
||||
const responseBody = formatBody(responseBuffer);
|
||||
const durationMs = Date.now() - started;
|
||||
const isInit = req.url.startsWith('/api/v1/init');
|
||||
|
||||
if (isInit) {
|
||||
if (lastLoggedInitUrl === req.url) {
|
||||
suppressedInitCount++;
|
||||
return;
|
||||
}
|
||||
lastLoggedInitUrl = req.url;
|
||||
} else {
|
||||
flushSuppressedInitLogs();
|
||||
lastLoggedInitUrl = null;
|
||||
}
|
||||
|
||||
logger.info(`${req.socket.remoteAddress} ${req.method} ${truncateUrl(req.url)} ${res.statusCode} ${durationMs}ms`);
|
||||
|
||||
logRequest({
|
||||
@@ -82,19 +116,29 @@ const loggedJtlHandler = async (req, res) => {
|
||||
|
||||
const httpsServer = https.createServer(
|
||||
{
|
||||
key: fs.readFileSync(keyPath),
|
||||
cert: fs.readFileSync(certPath),
|
||||
key: keyPem,
|
||||
cert: certPem,
|
||||
},
|
||||
loggedJtlHandler
|
||||
);
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
await connectDb();
|
||||
logger.success(`MSSQL connected: ${process.env.MSSQL_SERVER}/${process.env.MSSQL_DATABASE}`);
|
||||
} catch (err) {
|
||||
logger.warn(`MSSQL connection skipped: ${err.message}`);
|
||||
logger.warn('POS handshake will still work; sync from database is not available yet.');
|
||||
if (isDemoMode()) {
|
||||
const stats = await loadDemoCatalog();
|
||||
logger.success(
|
||||
`DEMO_MODE: loaded catalog (${stats.products} products, ${stats.categories} categories, ${stats.customerGroups} customer groups, ${stats.composites} composite links)`
|
||||
);
|
||||
logger.info('MSSQL is skipped while DEMO_MODE=true');
|
||||
} else {
|
||||
try {
|
||||
const pool = await connectDb();
|
||||
logger.success(`MSSQL connected: ${process.env.MSSQL_SERVER}/${process.env.MSSQL_DATABASE}`);
|
||||
const activeShop = await fetchActiveShop(pool);
|
||||
logger.info(`Active shop ID: ${activeShop}`);
|
||||
} catch (err) {
|
||||
logger.warn(`MSSQL connection skipped: ${err.message}`);
|
||||
logger.warn('POS handshake will still work; sync from database is not available yet.');
|
||||
}
|
||||
}
|
||||
|
||||
httpsServer.listen(PORT, '0.0.0.0', () => {
|
||||
@@ -102,7 +146,6 @@ async function start() {
|
||||
logger.info(`Certificate: ${certPath}`);
|
||||
logger.info(`Pairing code: ${PAIRING_CODE}`);
|
||||
logger.info(`Auth token: ${AUTH_TOKEN}`);
|
||||
logger.info('Import cert.pem into JTL POS trust store if required.');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
12
src/cert-meta.js
Normal file
12
src/cert-meta.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
|
||||
/** Derive pairing metadata from a PEM-encoded TLS certificate. */
|
||||
export function readCertMetadata(certPem) {
|
||||
const x509 = new X509Certificate(certPem);
|
||||
const sha1 = x509.fingerprint; // colon-separated uppercase hex
|
||||
return {
|
||||
certificateFingerprint: sha1.replace(/:/g, ''),
|
||||
certificateSerialNumber: x509.serialNumber,
|
||||
serverFingerprint: sha1.replace(/:/g, '-'),
|
||||
};
|
||||
}
|
||||
3
src/demo/mode.js
Normal file
3
src/demo/mode.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isDemoMode() {
|
||||
return String(process.env.DEMO_MODE || '').toLowerCase() === 'true';
|
||||
}
|
||||
139
src/demo/store.js
Normal file
139
src/demo/store.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { setActiveShop, setActiveShopSubshop } from '../shop.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DEMO_ROOT = path.join(__dirname, '..', '..', 'demo');
|
||||
const CATALOG_PATH = path.join(DEMO_ROOT, 'catalog.json');
|
||||
const IMAGES_DIR = path.join(DEMO_ROOT, 'images');
|
||||
|
||||
let catalog = null;
|
||||
let nextDemoOrderId = 1;
|
||||
|
||||
function ensureLoaded() {
|
||||
if (!catalog) {
|
||||
throw new Error('Demo catalog is not loaded. Call loadDemoCatalog() at startup.');
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
function afterCursor(rows, cursor, lastChangedKey = 'lastChanged') {
|
||||
const c = Number(cursor) || 0;
|
||||
return rows
|
||||
.filter((row) => Number(row[lastChangedKey]) > c)
|
||||
.sort((a, b) => Number(a[lastChangedKey]) - Number(b[lastChangedKey]));
|
||||
}
|
||||
|
||||
export async function loadDemoCatalog() {
|
||||
if (!fs.existsSync(CATALOG_PATH)) {
|
||||
throw new Error(
|
||||
`Demo catalog missing at ${CATALOG_PATH}. Run: npm run demo:generate`
|
||||
);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(CATALOG_PATH, 'utf8');
|
||||
catalog = JSON.parse(raw);
|
||||
|
||||
if (!Array.isArray(catalog.products) || catalog.products.length === 0) {
|
||||
throw new Error('Demo catalog has no products.');
|
||||
}
|
||||
|
||||
setActiveShop(1);
|
||||
setActiveShopSubshop(1);
|
||||
nextDemoOrderId = Number(catalog.maxOrderId || 0) + 1;
|
||||
|
||||
return {
|
||||
categories: catalog.categories.length,
|
||||
products: catalog.products.length,
|
||||
customerGroups: catalog.customerGroups.length,
|
||||
composites: catalog.composites.length,
|
||||
imagesDir: IMAGES_DIR,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDemoProductList({ cursor = 0, limit = 20 } = {}) {
|
||||
return afterCursor(ensureLoaded().products, cursor).slice(0, limit);
|
||||
}
|
||||
|
||||
export function getDemoProductCount({ cursor = 0 } = {}) {
|
||||
return afterCursor(ensureLoaded().products, cursor).length;
|
||||
}
|
||||
|
||||
export function getDemoCategoryList({ cursor = 0, limit = 20 } = {}) {
|
||||
const data = ensureLoaded();
|
||||
return afterCursor(data.categories, cursor).slice(0, limit).map((category) => ({
|
||||
_id: category._id,
|
||||
imghash: category.imghash,
|
||||
imgsrc: category.imgsrc,
|
||||
name: category.name,
|
||||
pid: category.pid,
|
||||
discounts: category.discounts ?? [],
|
||||
sort: category.sort,
|
||||
lastChanged: category.lastChanged,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getDemoCategoryCount({ cursor = 0 } = {}) {
|
||||
return afterCursor(ensureLoaded().categories, cursor).length;
|
||||
}
|
||||
|
||||
export function getDemoCustomerGroupIds() {
|
||||
return ensureLoaded().customerGroups.map((g) => Number(g.customerGroupId));
|
||||
}
|
||||
|
||||
export function getDemoCustomerGroupList({ cursor = 0 } = {}) {
|
||||
return afterCursor(ensureLoaded().customerGroups, cursor);
|
||||
}
|
||||
|
||||
export function getDemoCustomerGroupCount({ cursor = 0 } = {}) {
|
||||
return afterCursor(ensureLoaded().customerGroups, cursor).length;
|
||||
}
|
||||
|
||||
export function getDemoCompositeProductList({ cursor = 0, limit = 100 } = {}) {
|
||||
return afterCursor(ensureLoaded().composites, cursor).slice(0, limit);
|
||||
}
|
||||
|
||||
export function getDemoCompositeProductCount({ cursor = 0 } = {}) {
|
||||
// Match MSSQL semantics: count distinct composite parent products after cursor
|
||||
const rows = afterCursor(ensureLoaded().composites, cursor);
|
||||
return new Set(rows.map((r) => r.productId)).size;
|
||||
}
|
||||
|
||||
export function getDemoDeletedEntityList({ cursor = 0, limit = 600 } = {}) {
|
||||
return afterCursor(ensureLoaded().deletedEntities, cursor).slice(0, limit);
|
||||
}
|
||||
|
||||
export function getDemoDeletedEntityCount({ cursor = 0 } = {}) {
|
||||
return afterCursor(ensureLoaded().deletedEntities, cursor).length;
|
||||
}
|
||||
|
||||
export function getDemoMaxOrderIdCount() {
|
||||
return Number(ensureLoaded().maxOrderId || 0);
|
||||
}
|
||||
|
||||
export async function getDemoImageByHash(hash) {
|
||||
ensureLoaded();
|
||||
if (!hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = path.join(IMAGES_DIR, `${hash}.jpg`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
return { buffer, contentType: 'image/jpeg' };
|
||||
}
|
||||
|
||||
export async function createDemoOrder(order) {
|
||||
ensureLoaded();
|
||||
const orderId = nextDemoOrderId++;
|
||||
const externalId = String(order?.externalId ?? orderId);
|
||||
return {
|
||||
orderId: String(orderId),
|
||||
orderNumber: `DEMO-${externalId}`,
|
||||
alreadyExists: false,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createImageHandler } from './image-handler.js';
|
||||
import { createImageHandler } from '../image-handler.js';
|
||||
|
||||
export const method = 'GET';
|
||||
export const path = '/v1/cimage';
|
||||
|
||||
@@ -60,7 +60,6 @@ export function handle(req, res, { url, pairingStore, config }) {
|
||||
|
||||
if (authCode.length === 6) {
|
||||
if (pairingStore.hasPairingCode(authCode)) {
|
||||
pairingStore.revokePairingCode(authCode);
|
||||
pairingStore.registerDevice(config.authToken, name);
|
||||
return sendJson(res, 200, buildClientStep2(authCode, config));
|
||||
}
|
||||
|
||||
14
src/endpoints/deleted-entity.js
Normal file
14
src/endpoints/deleted-entity.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { sendJson } from '../http.js';
|
||||
import { getDeletedEntityList } from '../queries/deleted-entity-list.js';
|
||||
|
||||
export const method = 'GET';
|
||||
export const path = '/v1/deletedentity';
|
||||
|
||||
export async function handle(_req, res, { url }) {
|
||||
const cursor = Number(url.searchParams.get('lastChangedDeletedEntity')) || 0;
|
||||
const limit = Number(url.searchParams.get('limit')) || 600;
|
||||
|
||||
const deletedEntities = await getDeletedEntityList({ cursor, limit });
|
||||
|
||||
return sendJson(res, 200, deletedEntities);
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import * as category from './category.js';
|
||||
import * as cimage from './cimage.js';
|
||||
import * as client from './client.js';
|
||||
import * as customergroup from './customergroup.js';
|
||||
import * as deletedEntity from './deleted-entity.js';
|
||||
import * as init from './init.js';
|
||||
import * as order from './order.js';
|
||||
import * as pimage from './pimage.js';
|
||||
import * as product from './product.js';
|
||||
import * as productcomposite from './productcomposite.js';
|
||||
|
||||
export const endpoints = [client, init, category, product, pimage, cimage, customergroup, order];
|
||||
export const endpoints = [client, init, category, product, productcomposite, deletedEntity, pimage, cimage, customergroup, order];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { sendJson } from '../http.js';
|
||||
import { getMaxExternalId } from '../order-log.js';
|
||||
import { getCategoryCount } from '../queries/category-count.js';
|
||||
import { getMaxOrderIdCount } from '../queries/max-order-id.js';
|
||||
import { getCompositeProductCount } from '../queries/composite-product-count.js';
|
||||
import { getCustomerGroupCount } from '../queries/customer-groups.js';
|
||||
import { getDeletedEntityCount } from '../queries/deleted-entity-count.js';
|
||||
import { getProductCount } from '../queries/product-count.js';
|
||||
|
||||
export const method = 'GET';
|
||||
@@ -11,11 +13,16 @@ export async function handle(_req, res, { url }) {
|
||||
const productCursor = Number(url.searchParams.get('lastChangedProduct')) || 0;
|
||||
const categoryCursor = Number(url.searchParams.get('lastChangedCategory')) || 0;
|
||||
const customerGroupCursor = Number(url.searchParams.get('lastChangedCustomerGroup')) || 0;
|
||||
const compositeProductCursor = Number(url.searchParams.get('lastChangedCompositeProduct')) || 0;
|
||||
const deletedEntityCursor = Number(url.searchParams.get('lastChangedDeletedEntity')) || 0;
|
||||
|
||||
const [productCount, categoryCount, customerGroupCount] = await Promise.all([
|
||||
const [productCount, categoryCount, customerGroupCount, compositeProductCount, deletedEntityCount, maxOrderIdCount] = await Promise.all([
|
||||
getProductCount({ cursor: productCursor }),
|
||||
getCategoryCount({ cursor: categoryCursor }),
|
||||
getCustomerGroupCount({ cursor: customerGroupCursor }),
|
||||
getCompositeProductCount({ cursor: compositeProductCursor }),
|
||||
getDeletedEntityCount({ cursor: deletedEntityCursor }),
|
||||
getMaxOrderIdCount(),
|
||||
]);
|
||||
|
||||
return sendJson(res, 200, {
|
||||
@@ -24,10 +31,10 @@ export async function handle(_req, res, { url }) {
|
||||
category_count: String(categoryCount),
|
||||
customer_count: '0',
|
||||
customerGroup_count: String(customerGroupCount),
|
||||
compositeProduct_count: '0',
|
||||
compositeProduct_count: String(compositeProductCount),
|
||||
configurationGroup_count: '0',
|
||||
configurationItem_count: '0',
|
||||
deletedEntity_count: '0',
|
||||
max_orderId_count: getMaxExternalId(),
|
||||
deletedEntity_count: String(deletedEntityCount),
|
||||
max_orderId_count: String(maxOrderIdCount),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { sendJson } from '../http.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { logOrder } from '../order-log.js';
|
||||
import { createOrder } from '../queries/create-order.js';
|
||||
|
||||
export const method = 'POST';
|
||||
export const path = '/v1/order';
|
||||
@@ -33,16 +35,41 @@ export async function handle(req, res) {
|
||||
}
|
||||
|
||||
const orders = getOrders(body);
|
||||
const results = orders.map((order) => {
|
||||
const results = [];
|
||||
|
||||
for (const order of orders) {
|
||||
logOrder(order);
|
||||
const externalOrderId = String(order?.externalId ?? '');
|
||||
|
||||
return {
|
||||
status: 'OK',
|
||||
externalOrderId,
|
||||
message: '',
|
||||
};
|
||||
});
|
||||
try {
|
||||
const created = await createOrder(order);
|
||||
if (created.alreadyExists) {
|
||||
const msg = `order already mapped, skipped save (kAuftrag=${created.orderId}, ${created.orderNumber})`;
|
||||
logger.error(`order externalId=${externalOrderId} skipped: ${msg}`);
|
||||
results.push({
|
||||
status: 'ERROR',
|
||||
externalOrderId,
|
||||
message: msg,
|
||||
});
|
||||
} else {
|
||||
logger.success(`order ${created.orderNumber} (kAuftrag=${created.orderId}) created for externalId=${externalOrderId}`);
|
||||
results.push({
|
||||
status: 'OK',
|
||||
externalOrderId,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`order externalId=${externalOrderId} failed: ${err.message}`);
|
||||
results.push({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createImageHandler } from './image-handler.js';
|
||||
import { createImageHandler } from '../image-handler.js';
|
||||
|
||||
export const method = 'GET';
|
||||
export const path = '/v1/pimage';
|
||||
|
||||
14
src/endpoints/productcomposite.js
Normal file
14
src/endpoints/productcomposite.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { sendJson } from '../http.js';
|
||||
import { getCompositeProductList } from '../queries/composite-product-list.js';
|
||||
|
||||
export const method = 'GET';
|
||||
export const path = '/v1/productcomposite';
|
||||
|
||||
export async function handle(_req, res, { url }) {
|
||||
const cursor = Number(url.searchParams.get('lastChangedCompositeProduct')) || 0;
|
||||
const limit = Number(url.searchParams.get('limit')) || 100;
|
||||
|
||||
const composites = await getCompositeProductList({ cursor, limit });
|
||||
|
||||
return sendJson(res, 200, composites);
|
||||
}
|
||||
17
src/http.js
17
src/http.js
@@ -13,11 +13,19 @@ export function readBody(req) {
|
||||
});
|
||||
}
|
||||
|
||||
const CORS_HEADERS = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
};
|
||||
|
||||
export function sendJson(res, statusCode, body) {
|
||||
const responseBody = JSON.stringify(body);
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(responseBody),
|
||||
...CORS_HEADERS,
|
||||
});
|
||||
res.end(responseBody);
|
||||
}
|
||||
@@ -26,10 +34,19 @@ export function sendBinary(res, statusCode, buffer, contentType) {
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': buffer.length,
|
||||
...CORS_HEADERS,
|
||||
});
|
||||
res.end(buffer);
|
||||
}
|
||||
|
||||
export function sendCorsPreflight(res) {
|
||||
res.writeHead(204, {
|
||||
'Content-Length': 0,
|
||||
...CORS_HEADERS,
|
||||
});
|
||||
res.end();
|
||||
}
|
||||
|
||||
export function normalizePath(pathname) {
|
||||
return pathname.replace(/^\/api(?=\/v1\/)/, '');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sendBinary, sendJson } from '../http.js';
|
||||
import { resizeImage } from '../image-resize.js';
|
||||
import { getImageByHash } from '../queries/image.js';
|
||||
import { sendBinary, sendJson } from './http.js';
|
||||
import { resizeImage } from './image-resize.js';
|
||||
import { getImageByHash } from './queries/image.js';
|
||||
|
||||
export function createImageHandler() {
|
||||
return async function handle(_req, res, { url }) {
|
||||
@@ -1,21 +1,12 @@
|
||||
import { endpoints } from './endpoints/index.js';
|
||||
import { normalizePath, readBody, sendJson } from './http.js';
|
||||
import { normalizePath, readBody, sendCorsPreflight, sendJson } from './http.js';
|
||||
|
||||
function buildConfig(config = {}) {
|
||||
return {
|
||||
authToken: config.authToken || process.env.AUTH_TOKEN || 'df40ad2067954646abb0499548a52241',
|
||||
certificateFingerprint:
|
||||
config.certificateFingerprint ||
|
||||
process.env.CERTIFICATE_FINGERPRINT ||
|
||||
'BC2114CF407A42724BEEF417960F76DCBF9DE879',
|
||||
certificateSerialNumber:
|
||||
config.certificateSerialNumber ||
|
||||
process.env.CERTIFICATE_SERIAL_NUMBER ||
|
||||
'00BFC8BEACDB981B165210EF111CB9D3',
|
||||
serverFingerprint:
|
||||
config.serverFingerprint ||
|
||||
process.env.SERVER_FINGERPRINT ||
|
||||
'39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE',
|
||||
certificateFingerprint: config.certificateFingerprint || '',
|
||||
certificateSerialNumber: config.certificateSerialNumber || '',
|
||||
serverFingerprint: config.serverFingerprint || '',
|
||||
mandantId: config.mandantId || process.env.MANDANT_ID || '1',
|
||||
mandantName: config.mandantName || process.env.MANDANT_NAME || 'eB-Standard',
|
||||
mandantDatabase: config.mandantDatabase || process.env.MANDANT_DATABASE || 'eazybusiness',
|
||||
@@ -27,6 +18,10 @@ export function createJtlPosServer(pairingStore, config = {}) {
|
||||
const routes = new Map(endpoints.map((endpoint) => [`${endpoint.method} ${endpoint.path}`, endpoint]));
|
||||
|
||||
async function handle(req, res) {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return sendCorsPreflight(res);
|
||||
}
|
||||
|
||||
const url = new URL(req.url, 'https://localhost');
|
||||
const pathname = normalizePath(url.pathname);
|
||||
const routeKey = `${req.method} ${pathname}`;
|
||||
|
||||
@@ -7,20 +7,13 @@ fs.mkdirSync(path.dirname(ORDER_LOG_FILE), { recursive: true });
|
||||
const stream = fs.createWriteStream(ORDER_LOG_FILE, { flags: 'a' });
|
||||
|
||||
let orderSequence = 0;
|
||||
let maxExternalId = 0;
|
||||
|
||||
export function logOrder(order) {
|
||||
orderSequence += 1;
|
||||
maxExternalId += 1;
|
||||
const externalId = order?.externalId ?? '';
|
||||
|
||||
const line = `${new Date().toISOString()} #${orderSequence} externalId=${maxExternalId} ${JSON.stringify(order)}\n`;
|
||||
const line = `${new Date().toISOString()} #${orderSequence} externalId=${externalId} ${JSON.stringify(order)}\n`;
|
||||
stream.write(line);
|
||||
|
||||
return maxExternalId;
|
||||
}
|
||||
|
||||
export function getMaxExternalId() {
|
||||
return String(maxExternalId);
|
||||
}
|
||||
|
||||
export function closeOrderLog() {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import sql from 'mssql';
|
||||
import { isDemoMode } from '../demo/mode.js';
|
||||
import { getDemoCategoryCount } from '../demo/store.js';
|
||||
import { getPool } from '../db.js';
|
||||
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
|
||||
|
||||
@@ -7,10 +9,15 @@ ${CATEGORY_TREE_CTE}
|
||||
SELECT COUNT(*) AS CategoryCount
|
||||
FROM dbo.tKategorie k
|
||||
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;
|
||||
`;
|
||||
|
||||
export async function getCategoryCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
|
||||
if (isDemoMode()) {
|
||||
return getDemoCategoryCount({ cursor });
|
||||
}
|
||||
|
||||
const result = await categoryTreeRequest(getPool(), rootCategoryId)
|
||||
.input('cursor', sql.BigInt, cursor)
|
||||
.query(CATEGORY_COUNT_SQL);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import sql from 'mssql';
|
||||
import { isDemoMode } from '../demo/mode.js';
|
||||
import { getDemoCategoryList } from '../demo/store.js';
|
||||
import { getPool } from '../db.js';
|
||||
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
|
||||
|
||||
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 = `
|
||||
${CATEGORY_TREE_CTE}
|
||||
@@ -18,21 +18,24 @@ SELECT TOP (@limit)
|
||||
FROM dbo.tKategorie k
|
||||
INNER JOIN dbo.tKategorieSprache ks ON ks.kKategorie = k.kKategorie AND ks.kSprache = @languageId
|
||||
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
|
||||
WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree WHERE kKategorie <> @rootCategoryId)
|
||||
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
|
||||
ORDER BY lastChanged ASC;
|
||||
`;
|
||||
|
||||
export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId = getRootCategoryId() } = {}) {
|
||||
if (isDemoMode()) {
|
||||
return getDemoCategoryList({ cursor, limit });
|
||||
}
|
||||
|
||||
const result = await categoryTreeRequest(getPool(), rootCategoryId)
|
||||
.input('cursor', sql.BigInt, cursor)
|
||||
.input('limit', sql.Int, limit)
|
||||
.input('languageId', sql.Int, LANGUAGE_ID)
|
||||
.input('imagePlatformId', sql.Int, IMAGE_PLATFORM_ID)
|
||||
.input('imageShopId', sql.Int, IMAGE_SHOP_ID)
|
||||
.query(CATEGORY_LIST_SQL);
|
||||
|
||||
return result.recordset.map((row) => ({
|
||||
@@ -40,7 +43,7 @@ export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId =
|
||||
imghash: row.imgHash ?? null,
|
||||
imgsrc: row.imgHash ?? null,
|
||||
name: row.name,
|
||||
pid: String(row.pid),
|
||||
pid: row.pid === rootCategoryId ? '0' : String(row.pid),
|
||||
discounts: [],
|
||||
sort: String(row.sort),
|
||||
lastChanged: String(row.lastChanged),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import sql from 'mssql';
|
||||
import { getActiveShopId } from '../shop.js';
|
||||
|
||||
export const CATEGORY_TREE_CTE = `
|
||||
WITH CategoryTree AS (
|
||||
@@ -14,5 +15,8 @@ export function 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());
|
||||
}
|
||||
|
||||
31
src/queries/composite-product-count.js
Normal file
31
src/queries/composite-product-count.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import sql from 'mssql';
|
||||
import { isDemoMode } from '../demo/mode.js';
|
||||
import { getDemoCompositeProductCount } from '../demo/store.js';
|
||||
import { getPool } from '../db.js';
|
||||
import { getActiveShopId } from '../shop.js';
|
||||
|
||||
const COMPOSITE_PRODUCT_COUNT_SQL = `
|
||||
SELECT COUNT(DISTINCT a.kArtikel) AS CompositeProductCount
|
||||
FROM dbo.tArtikel a
|
||||
INNER JOIN dbo.tStueckliste s ON s.kStueckliste = a.kStueckliste
|
||||
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;
|
||||
`;
|
||||
|
||||
export async function getCompositeProductCount({ cursor = 0 } = {}) {
|
||||
if (isDemoMode()) {
|
||||
return getDemoCompositeProductCount({ cursor });
|
||||
}
|
||||
|
||||
const result = await getPool()
|
||||
.request()
|
||||
.input('cursor', sql.BigInt, cursor)
|
||||
.input('kShop', sql.Int, getActiveShopId())
|
||||
.query(COMPOSITE_PRODUCT_COUNT_SQL);
|
||||
return result.recordset[0]?.CompositeProductCount ?? 0;
|
||||
}
|
||||
43
src/queries/composite-product-list.js
Normal file
43
src/queries/composite-product-list.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import sql from 'mssql';
|
||||
import { isDemoMode } from '../demo/mode.js';
|
||||
import { getDemoCompositeProductList } from '../demo/store.js';
|
||||
import { getPool } from '../db.js';
|
||||
import { getActiveShopId } from '../shop.js';
|
||||
|
||||
const COMPOSITE_PRODUCT_LIST_SQL = `
|
||||
SELECT TOP (@limit)
|
||||
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 (@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
|
||||
ORDER BY lastChanged ASC;
|
||||
`;
|
||||
|
||||
export async function getCompositeProductList({ cursor = 0, limit = 100 } = {}) {
|
||||
if (isDemoMode()) {
|
||||
return getDemoCompositeProductList({ cursor, limit });
|
||||
}
|
||||
|
||||
const result = await getPool()
|
||||
.request()
|
||||
.input('cursor', sql.BigInt, cursor)
|
||||
.input('limit', sql.Int, limit)
|
||||
.input('kShop', sql.Int, getActiveShopId())
|
||||
.query(COMPOSITE_PRODUCT_LIST_SQL);
|
||||
|
||||
return result.recordset.map((row) => ({
|
||||
productId: String(row.productId),
|
||||
productIdComponent: String(row.productIdComponent),
|
||||
quantity: row.quantity,
|
||||
lastChanged: String(row.lastChanged),
|
||||
}));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user