Compare commits
2 Commits
b922229687
...
7bd8df22d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bd8df22d1 | ||
|
|
8057933939 |
486
API.md
Normal file
486
API.md
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `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”.
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "nlohmann/json.hpp"
|
#include "nlohmann/json.hpp"
|
||||||
#include "shop.hpp"
|
#include "shop.hpp"
|
||||||
#include "customer_groups.hpp"
|
#include "customer_groups.hpp"
|
||||||
|
#include "product_attributes.hpp"
|
||||||
#include "../config.hpp"
|
#include "../config.hpp"
|
||||||
#include "../http.hpp"
|
#include "../http.hpp"
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
@@ -74,6 +75,14 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto cg_ids = get_customer_group_ids();
|
auto cg_ids = get_customer_group_ids();
|
||||||
|
|
||||||
|
std::vector<int64_t> article_ids;
|
||||||
|
article_ids.reserve(rs.size());
|
||||||
|
for (auto& row : rs) {
|
||||||
|
article_ids.push_back(parse_int64(row[0].str, 0));
|
||||||
|
}
|
||||||
|
auto attrs_by_article = get_product_attributes(article_ids);
|
||||||
|
|
||||||
nlohmann::json result = nlohmann::json::array();
|
nlohmann::json result = nlohmann::json::array();
|
||||||
|
|
||||||
for (auto& row : rs) {
|
for (auto& row : rs) {
|
||||||
@@ -102,6 +111,21 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int64_t article_id = parse_int64(row[0].str, 0);
|
||||||
|
nlohmann::json attributes = nlohmann::json::array();
|
||||||
|
std::string deposit = "0";
|
||||||
|
std::string deposit_name = "";
|
||||||
|
std::string d_price = "0.0";
|
||||||
|
auto attrs_it = attrs_by_article.find(article_id);
|
||||||
|
if (attrs_it != attrs_by_article.end()) {
|
||||||
|
attributes = attrs_it->second.attributes;
|
||||||
|
if (!attrs_it->second.deposit.is_null()) {
|
||||||
|
deposit = attrs_it->second.deposit.value("deposit", "0");
|
||||||
|
deposit_name = attrs_it->second.deposit.value("deposit_name", "");
|
||||||
|
d_price = attrs_it->second.deposit.value("d_price", "0.0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nlohmann::json product = {
|
nlohmann::json product = {
|
||||||
{"_id", row[0].str},
|
{"_id", row[0].str},
|
||||||
{"imghash", row[7].str.empty() ? nullptr : nlohmann::json(row[7].str)},
|
{"imghash", row[7].str.empty() ? nullptr : nlohmann::json(row[7].str)},
|
||||||
@@ -119,13 +143,13 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
|
|||||||
{"parent", parse_int64(row[10].str, 0) > 0 ? row[10].str : "0"},
|
{"parent", parse_int64(row[10].str, 0) > 0 ? row[10].str : "0"},
|
||||||
{"variants", row[12].str},
|
{"variants", row[12].str},
|
||||||
{"isCompositeProduct", row[11].str},
|
{"isCompositeProduct", row[11].str},
|
||||||
{"attributes", nlohmann::json::array()},
|
{"attributes", std::move(attributes)},
|
||||||
{"sort", "0"},
|
{"sort", "0"},
|
||||||
{"p_price", "0.00"},
|
{"p_price", "0.00"},
|
||||||
{"discountable", "0"},
|
{"discountable", "0"},
|
||||||
{"deposit", "0"},
|
{"deposit", deposit},
|
||||||
{"discount", ""},
|
{"discount", ""},
|
||||||
{"d_price", "0.0"},
|
{"d_price", d_price},
|
||||||
{"tax_rate2", ""},
|
{"tax_rate2", ""},
|
||||||
{"use_in_out_tax", "0"},
|
{"use_in_out_tax", "0"},
|
||||||
{"barcode", row[13].str.empty() ? nullptr : nlohmann::json(row[13].str)},
|
{"barcode", row[13].str.empty() ? nullptr : nlohmann::json(row[13].str)},
|
||||||
@@ -139,7 +163,7 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
|
|||||||
{"tags", ""},
|
{"tags", ""},
|
||||||
{"variants", row[12].str},
|
{"variants", row[12].str},
|
||||||
{"print_kitchen_receipt", "0"},
|
{"print_kitchen_receipt", "0"},
|
||||||
{"deposit_name", ""},
|
{"deposit_name", deposit_name},
|
||||||
{"updated_at", "0001-01-01 00:00:00"},
|
{"updated_at", "0001-01-01 00:00:00"},
|
||||||
{"configurationGroups", ""},
|
{"configurationGroups", ""},
|
||||||
{"options", nullptr},
|
{"options", nullptr},
|
||||||
|
|||||||
Reference in New Issue
Block a user