Compare commits
7 Commits
6abe2032e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0d2e43f4 | ||
|
|
5caedbe345 | ||
|
|
73d256e8a6 | ||
|
|
ccf7854e6c | ||
|
|
390d66890b | ||
|
|
c42a14a437 | ||
|
|
e1a009ba5c |
@@ -26,6 +26,8 @@ LANGUAGE_ID=1
|
|||||||
|
|
||||||
# Product sync (tSteuerzone.cName used to look up tax rates per tSteuerklasse)
|
# Product sync (tSteuerzone.cName used to look up tax rates per tSteuerklasse)
|
||||||
TAX_ZONE_NAME=Zone-EU
|
TAX_ZONE_NAME=Zone-EU
|
||||||
|
# Warehouse ID for inventory stock (vLagerbestandProLager)
|
||||||
|
WARENLAGER_ID=1
|
||||||
|
|
||||||
# Shop filter: active shop is queried from tShopSubshop at startup
|
# Shop filter: active shop is queried from tShopSubshop at startup
|
||||||
|
|
||||||
|
|||||||
53
API.md
53
API.md
@@ -134,6 +134,7 @@ Returns how many entities changed since each cursor. Poll this; only fetch list
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `lastChangedProduct` | `0` | Products |
|
| `lastChangedProduct` | `0` | Products |
|
||||||
| `lastChangedCategory` | `0` | Categories |
|
| `lastChangedCategory` | `0` | Categories |
|
||||||
|
| `lastChangedCustomer` | `0` | Customers |
|
||||||
| `lastChangedCustomerGroup` | `0` | Customer groups |
|
| `lastChangedCustomerGroup` | `0` | Customer groups |
|
||||||
| `lastChangedCompositeProduct` | `0` | Composite (Stückliste) products |
|
| `lastChangedCompositeProduct` | `0` | Composite (Stückliste) products |
|
||||||
| `lastChangedDeletedEntity` | `0` | Deleted entities |
|
| `lastChangedDeletedEntity` | `0` | Deleted entities |
|
||||||
@@ -246,10 +247,12 @@ Notable fields:
|
|||||||
| `prices` | One entry per customer group |
|
| `prices` | One entry per customer group |
|
||||||
| `isCompositeProduct` | `"1"` if the article is a Stückliste parent |
|
| `isCompositeProduct` | `"1"` if the article is a Stückliste parent |
|
||||||
| `imghash` | Pass to `/v1/pimage?path=...` |
|
| `imghash` | Pass to `/v1/pimage?path=...` |
|
||||||
|
| `use_stock` | `"1"` if `cLagerAktiv = 'Y'`, otherwise `"0"` |
|
||||||
|
| `quantity` | Stock quantity from `dbo.vLagerbestandProLager` for configured `WARENLAGER_ID` |
|
||||||
| Deposit fields | Present when JTL-POS Pfand attributes are set |
|
| Deposit fields | Present when JTL-POS Pfand attributes are set |
|
||||||
| `attributes` | Article attributes from `tArtikelAttribut` (incl. Pfand) |
|
| `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.
|
Many other product fields are filled with static defaults (`sort`, `unit`, `PLU`, 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.
|
**Node only:** `prices[]` applies per–customer-group net overrides from `tPreis` / `tPreisDetail`. C++ currently fills every group with the base gross price.
|
||||||
|
|
||||||
@@ -293,6 +296,53 @@ No `limit` — returns all groups newer than the cursor.
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `GET /v1/customer`
|
||||||
|
|
||||||
|
Customer deltas. Customers are scoped to the active shop (`vCustomer.kShop = active kShop`) and its subshop (`tInetKundeShop.kSubShop = active kShopSubshop`). Rows are ordered by `bLastChanged` ascending, and only rows with `bLastChanged > lastChangedCustomer` are returned (matching the count semantics of `/v1/init`).
|
||||||
|
|
||||||
|
| Param | Default |
|
||||||
|
|---|---|
|
||||||
|
| `lastChangedCustomer` | `0` |
|
||||||
|
| `limit` | `20` |
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "5",
|
||||||
|
"customerNumber": "K-1005",
|
||||||
|
"firstname": "Max",
|
||||||
|
"lastname": "Mustermann",
|
||||||
|
"title": null,
|
||||||
|
"company": null,
|
||||||
|
"address": "Musterstraße 1",
|
||||||
|
"addressSupplement": null,
|
||||||
|
"city": "Musterstadt",
|
||||||
|
"postalCode": "12345",
|
||||||
|
"state": null,
|
||||||
|
"country": null,
|
||||||
|
"phone": null,
|
||||||
|
"email": "max@example.com",
|
||||||
|
"customerGroupId": "1",
|
||||||
|
"salutation": null,
|
||||||
|
"birthday": null,
|
||||||
|
"discount": "0.00",
|
||||||
|
"taxIdNumber": null,
|
||||||
|
"lastChanged": "2822123",
|
||||||
|
"debtorNumber": "0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Source |
|
||||||
|
|---|---|
|
||||||
|
| `id` | `vCustomer.kId` |
|
||||||
|
| `customerNumber` | `tInetKundeShop.cShopKundenNr` ?? `vCustomer.cCustomerNumber` |
|
||||||
|
| `birthday` | Parsed from `cDateOfBirth` (`dd.MM.yyyy` → `yyyy-MM-dd HH:mm:ss`) |
|
||||||
|
| `customerGroupId` | `tInetKundeShop.kKundenGruppe` ?? `vCustomer.kCustomerGroupId` |
|
||||||
|
| `discount` | `vCustomer.fDiscount`, 2 decimals |
|
||||||
|
| `lastChanged` | `vCustomer.bLastChanged` rowversion |
|
||||||
|
| `debtorNumber` | `vCustomer.nDebtorNumber` |
|
||||||
|
|
||||||
### `GET /v1/deletedentity`
|
### `GET /v1/deletedentity`
|
||||||
|
|
||||||
| Param | Default |
|
| Param | Default |
|
||||||
@@ -488,6 +538,7 @@ Invalid JSON body → `500` with `[]`.
|
|||||||
| `GET` | `/v1/product` | Product deltas |
|
| `GET` | `/v1/product` | Product deltas |
|
||||||
| `GET` | `/v1/productcomposite` | Composite BOM deltas |
|
| `GET` | `/v1/productcomposite` | Composite BOM deltas |
|
||||||
| `GET` | `/v1/customergroup` | Customer group deltas |
|
| `GET` | `/v1/customergroup` | Customer group deltas |
|
||||||
|
| `GET` | `/v1/customer` | Customer deltas |
|
||||||
| `GET` | `/v1/deletedentity` | Deletion deltas |
|
| `GET` | `/v1/deletedentity` | Deletion deltas |
|
||||||
| `GET` | `/v1/pimage` | Product image by hash |
|
| `GET` | `/v1/pimage` | Product image by hash |
|
||||||
| `GET` | `/v1/cimage` | Category image by hash |
|
| `GET` | `/v1/cimage` | Category image by hash |
|
||||||
|
|||||||
184
crashreport.md
Normal file
184
crashreport.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Crash Report Endpoint
|
||||||
|
|
||||||
|
Clients (POS apps running in a browser or Node.js) can submit crash / uncaught-error details to the sync server for later inspection. The server writes each report to a plain-text file under `logs/crash/`.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/crashreport
|
||||||
|
```
|
||||||
|
|
||||||
|
Paths may also be called as `/api/v1/crashreport` — both resolve to the same handler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
- **Method:** `POST`
|
||||||
|
- **Content-Type:** `application/json`
|
||||||
|
- **Body:** any JSON object you want to record. Keep it free-form; common fields are shown below.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": "my-pos-app",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"platform": "win32",
|
||||||
|
"userAgent": "Mozilla/5.0 ...",
|
||||||
|
"message": "Cannot read properties of undefined (reading 'price')",
|
||||||
|
"stack": "TypeError: Cannot read properties of undefined (reading 'price')\\n at ...",
|
||||||
|
"context": {
|
||||||
|
"currentView": "checkout",
|
||||||
|
"orderId": "abc-123"
|
||||||
|
},
|
||||||
|
"device": {
|
||||||
|
"id": "clerk-01",
|
||||||
|
"display": 1920
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing is validated server-side: the body is logged as-is (`JSON.stringify(body, null, 2)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responses
|
||||||
|
|
||||||
|
| Status | Body | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `200` | `{ "Message": "OK", "file": "<timestamp>-<ip>.txt" }` | Report saved |
|
||||||
|
| `400` | `{ "Message": "Invalid JSON body." }` | Body is not valid JSON |
|
||||||
|
|
||||||
|
Non-JSON or empty bodies are treated as follows:
|
||||||
|
|
||||||
|
- **Empty body** → saved as an empty object `{}`.
|
||||||
|
- **Invalid JSON** (e.g. a raw text dump) → rejected with `400`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Log file format
|
||||||
|
|
||||||
|
Each accepted report is written to:
|
||||||
|
|
||||||
|
```
|
||||||
|
logs/crash/<timestamp>-<ip>.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<timestamp>` — local server time, `YYYYMMDD-HHMMSS`.
|
||||||
|
- `<ip>` — the client's remote address (`::ffff:` IPv4 prefix is stripped; `:` is replaced with `_`).
|
||||||
|
|
||||||
|
Two reports from the same client within the same second overwrite each other. Include a unique id inside the body if you need to guarantee every report is preserved.
|
||||||
|
|
||||||
|
Example file:
|
||||||
|
|
||||||
|
```
|
||||||
|
timestamp: 2026-08-17T09:41:22.731Z
|
||||||
|
ip: ::ffff:127.0.0.1
|
||||||
|
user-agent: node-fetch/1.0 (+https://github.com/bitinn/node-fetch)
|
||||||
|
|
||||||
|
{
|
||||||
|
"app": "my-pos-app",
|
||||||
|
"message": "Cannot read properties of undefined",
|
||||||
|
"stack": "TypeError: ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The log directory `logs/crash/` is created automatically on first use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage from Node.js
|
||||||
|
|
||||||
|
```js
|
||||||
|
// plain fetch (Node 18+)
|
||||||
|
const baseUrl = 'https://192.168.1.10:4443';
|
||||||
|
const AUTH_TOKEN = '<your pairing token>'; // optional if your server does not enforce it
|
||||||
|
|
||||||
|
fetch(`${baseUrl}/v1/crashreport`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
app: 'my-pos-app',
|
||||||
|
version: '1.2.3',
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(async (res) => console.log(res.status, await res.json()))
|
||||||
|
.catch((err) => console.error('could not send crash report', err));
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the server uses a self-signed certificate, set `rejectUnauthorized: false` when using `node-fetch` or Node's `https` module:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import https from 'node:https';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
|
||||||
|
fetch('https://<host>:4443/v1/crashreport', {
|
||||||
|
method: 'POST',
|
||||||
|
agent,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ app: 'my-pos-app', message: error.message, stack: error.stack }),
|
||||||
|
})
|
||||||
|
.then(async (res) => console.log(res.status, await res.json()));
|
||||||
|
```
|
||||||
|
|
||||||
|
Hook it into your global error handlers so nothing is lost:
|
||||||
|
|
||||||
|
```js
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
void sendCrashReport(err);
|
||||||
|
// ...your own logging / shutdown
|
||||||
|
});
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
void sendCrashReport(reason instanceof Error ? reason : new Error(String(reason)));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage from the browser
|
||||||
|
|
||||||
|
Use `fetch` with `mode: 'no-cors'` **or** a normal `cors` request — the server sends `Access-Control-Allow-Origin: *`, so a plain `fetch` works from any origin.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function sendCrashReport(payload) {
|
||||||
|
return fetch('https://<host>:4443/v1/crashreport', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
const json = await res.json();
|
||||||
|
console.log(`crash report ${res.ok ? 'saved' : 'failed'}`, json);
|
||||||
|
return json;
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('could not send crash report', err));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire it into `window.onerror` and `unhandledrejection`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
sendCrashReport({
|
||||||
|
app: 'my-pos-app',
|
||||||
|
version: appVersion,
|
||||||
|
message: event.message,
|
||||||
|
stack: event.error?.stack,
|
||||||
|
context: { url: location.href },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
sendCrashReport({
|
||||||
|
app: 'my-pos-app',
|
||||||
|
version: appVersion,
|
||||||
|
message: event.reason?.message || String(event.reason),
|
||||||
|
stack: event.reason?.stack,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** if you use `mode: 'no-cors'` the browser will send the request but you will not be able to read the JSON response body — the report is still saved server-side.
|
||||||
@@ -32,6 +32,7 @@ CPP_SRCS := \
|
|||||||
src/endpoints/productcomposite.cpp \
|
src/endpoints/productcomposite.cpp \
|
||||||
src/endpoints/deleted_entity.cpp \
|
src/endpoints/deleted_entity.cpp \
|
||||||
src/endpoints/customergroup.cpp \
|
src/endpoints/customergroup.cpp \
|
||||||
|
src/endpoints/customer.cpp \
|
||||||
src/endpoints/order.cpp \
|
src/endpoints/order.cpp \
|
||||||
src/endpoints/pimage.cpp \
|
src/endpoints/pimage.cpp \
|
||||||
src/endpoints/cimage.cpp \
|
src/endpoints/cimage.cpp \
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ Place `certs/cert.pem` and `certs/key.pem` relative to the working directory whe
|
|||||||
| GET | `/v1/productcomposite` | Composite product sync |
|
| GET | `/v1/productcomposite` | Composite product sync |
|
||||||
| GET | `/v1/deletedentity` | Deleted entity sync |
|
| GET | `/v1/deletedentity` | Deleted entity sync |
|
||||||
| GET | `/v1/customergroup` | Customer group sync |
|
| GET | `/v1/customergroup` | Customer group sync |
|
||||||
|
| GET | `/v1/customer` | Customer sync |
|
||||||
| POST | `/v1/order` | Submit orders |
|
| POST | `/v1/order` | Submit orders |
|
||||||
| GET | `/v1/pimage` | Product image (resized) |
|
| GET | `/v1/pimage` | Product image (resized) |
|
||||||
| GET | `/v1/cimage` | Category image (resized) |
|
| GET | `/v1/cimage` | Category image (resized) |
|
||||||
|
|||||||
15
jtlsrv-cpp/src/endpoints/customer.cpp
Normal file
15
jtlsrv-cpp/src/endpoints/customer.cpp
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#include "../http.hpp"
|
||||||
|
#include "../tls_server.hpp"
|
||||||
|
#include "../router.hpp"
|
||||||
|
#include "../queries/customer_list.hpp"
|
||||||
|
#include "../queries/shop.hpp"
|
||||||
|
|
||||||
|
void handle_customer(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
|
||||||
|
int64_t cursor = req.get_query_int64("lastChangedCustomer");
|
||||||
|
int limit = req.get_query_int("limit", 20);
|
||||||
|
int shop = get_active_shop_id();
|
||||||
|
int subshop = get_active_shop_subshop_id();
|
||||||
|
|
||||||
|
auto customers = get_customer_list(cursor, limit, shop, subshop);
|
||||||
|
resp.send_json(200, customers);
|
||||||
|
}
|
||||||
@@ -10,23 +10,26 @@
|
|||||||
void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
|
void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
|
||||||
int64_t product_cursor = req.get_query_int64("lastChangedProduct");
|
int64_t product_cursor = req.get_query_int64("lastChangedProduct");
|
||||||
int64_t category_cursor = req.get_query_int64("lastChangedCategory");
|
int64_t category_cursor = req.get_query_int64("lastChangedCategory");
|
||||||
|
int64_t customer_cursor = req.get_query_int64("lastChangedCustomer");
|
||||||
int64_t cg_cursor = req.get_query_int64("lastChangedCustomerGroup");
|
int64_t cg_cursor = req.get_query_int64("lastChangedCustomerGroup");
|
||||||
int64_t composite_cursor = req.get_query_int64("lastChangedCompositeProduct");
|
int64_t composite_cursor = req.get_query_int64("lastChangedCompositeProduct");
|
||||||
int64_t deleted_cursor = req.get_query_int64("lastChangedDeletedEntity");
|
int64_t deleted_cursor = req.get_query_int64("lastChangedDeletedEntity");
|
||||||
|
|
||||||
int root = config::get_int("ROOT_CATEGORY_ID", 1);
|
int root = config::get_int("ROOT_CATEGORY_ID", 1);
|
||||||
int shop = get_active_shop_id();
|
int shop = get_active_shop_id();
|
||||||
|
int subshop = get_active_shop_subshop_id();
|
||||||
|
|
||||||
int64_t product_count = 0, category_count = 0, cg_count = 0, composite_count = 0, deleted_count = 0;
|
int64_t product_count = 0, category_count = 0, customer_count = 0, cg_count = 0, composite_count = 0, deleted_count = 0;
|
||||||
int64_t max_order_id_count = 0;
|
int64_t max_order_id_count = 0;
|
||||||
|
|
||||||
if (get_pool().execute_scalar("SELECT 1") != 0) {
|
if (get_pool().execute_scalar("SELECT 1") != 0) {
|
||||||
product_count = get_product_count(root, shop, product_cursor);
|
product_count = get_product_count(root, shop, product_cursor);
|
||||||
category_count = get_category_count(root, shop, category_cursor);
|
category_count = get_category_count(root, shop, category_cursor);
|
||||||
|
customer_count = get_customer_count(shop, subshop, customer_cursor);
|
||||||
cg_count = get_customer_group_count(cg_cursor);
|
cg_count = get_customer_group_count(cg_cursor);
|
||||||
composite_count = get_composite_count(shop, composite_cursor);
|
composite_count = get_composite_count(shop, composite_cursor);
|
||||||
deleted_count = get_deleted_count(deleted_cursor);
|
deleted_count = get_deleted_count(deleted_cursor);
|
||||||
max_order_id_count = get_max_order_id_count(get_active_shop_subshop_id());
|
max_order_id_count = get_max_order_id_count(subshop);
|
||||||
if (product_cursor == 0 && category_cursor == 0 &&
|
if (product_cursor == 0 && category_cursor == 0 &&
|
||||||
product_count == 0 && category_count == 0 && deleted_count == 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");
|
logc::warn("init: all counts zero with cursors at 0 — check DB connectivity and shop/category config");
|
||||||
@@ -39,7 +42,7 @@ void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
|
|||||||
{"version", "1.10.12.0"},
|
{"version", "1.10.12.0"},
|
||||||
{"product_count", std::to_string(product_count)},
|
{"product_count", std::to_string(product_count)},
|
||||||
{"category_count", std::to_string(category_count)},
|
{"category_count", std::to_string(category_count)},
|
||||||
{"customer_count", "0"},
|
{"customer_count", std::to_string(customer_count)},
|
||||||
{"customerGroup_count", std::to_string(cg_count)},
|
{"customerGroup_count", std::to_string(cg_count)},
|
||||||
{"compositeProduct_count", std::to_string(composite_count)},
|
{"compositeProduct_count", std::to_string(composite_count)},
|
||||||
{"configurationGroup_count", "0"},
|
{"configurationGroup_count", "0"},
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ extern void handle_product(HttpRequest&, HttpResponse&, RouteContext&);
|
|||||||
extern void handle_productcomposite(HttpRequest&, HttpResponse&, RouteContext&);
|
extern void handle_productcomposite(HttpRequest&, HttpResponse&, RouteContext&);
|
||||||
extern void handle_deleted_entity(HttpRequest&, HttpResponse&, RouteContext&);
|
extern void handle_deleted_entity(HttpRequest&, HttpResponse&, RouteContext&);
|
||||||
extern void handle_customergroup(HttpRequest&, HttpResponse&, RouteContext&);
|
extern void handle_customergroup(HttpRequest&, HttpResponse&, RouteContext&);
|
||||||
|
extern void handle_customer(HttpRequest&, HttpResponse&, RouteContext&);
|
||||||
extern void handle_order(HttpRequest&, HttpResponse&, RouteContext&);
|
extern void handle_order(HttpRequest&, HttpResponse&, RouteContext&);
|
||||||
extern void handle_pimage(HttpRequest&, HttpResponse&, RouteContext&);
|
extern void handle_pimage(HttpRequest&, HttpResponse&, RouteContext&);
|
||||||
extern void handle_cimage(HttpRequest&, HttpResponse&, RouteContext&);
|
extern void handle_cimage(HttpRequest&, HttpResponse&, RouteContext&);
|
||||||
@@ -188,6 +189,7 @@ int main(int /*argc*/, char* argv[]) {
|
|||||||
router.add_route("GET", "/v1/productcomposite", handle_productcomposite);
|
router.add_route("GET", "/v1/productcomposite", handle_productcomposite);
|
||||||
router.add_route("GET", "/v1/deletedentity", handle_deleted_entity);
|
router.add_route("GET", "/v1/deletedentity", handle_deleted_entity);
|
||||||
router.add_route("GET", "/v1/customergroup", handle_customergroup);
|
router.add_route("GET", "/v1/customergroup", handle_customergroup);
|
||||||
|
router.add_route("GET", "/v1/customer", handle_customer);
|
||||||
router.add_route("POST", "/v1/order", handle_order);
|
router.add_route("POST", "/v1/order", handle_order);
|
||||||
router.add_route("GET", "/v1/pimage", handle_pimage);
|
router.add_route("GET", "/v1/pimage", handle_pimage);
|
||||||
router.add_route("GET", "/v1/cimage", handle_cimage);
|
router.add_route("GET", "/v1/cimage", handle_cimage);
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ static const char* COMPOSITE_PRODUCT_COUNT_SQL =
|
|||||||
"AND ks.kShop = ? WHERE ka.kArtikel = a.kArtikel)) "
|
"AND ks.kShop = ? WHERE ka.kArtikel = a.kArtikel)) "
|
||||||
"AND CONVERT(BIGINT, a.bRowversion) > ?";
|
"AND CONVERT(BIGINT, a.bRowversion) > ?";
|
||||||
|
|
||||||
|
static const char* CUSTOMER_COUNT_SQL =
|
||||||
|
"SELECT COUNT(DISTINCT k.kKunde) AS cnt FROM dbo.tkunde k "
|
||||||
|
"LEFT JOIN dbo.tInetKundeShop iks ON iks.kKunde = k.kKunde "
|
||||||
|
"AND iks.kShop = ? AND iks.kSubShop = ? "
|
||||||
|
"WHERE (? = 0 OR EXISTS (SELECT 1 FROM dbo.tInetKundeShop x "
|
||||||
|
"WHERE x.kKunde = k.kKunde AND x.kShop = ?)) "
|
||||||
|
"AND CONVERT(BIGINT, k.bRowversion) > ?";
|
||||||
|
|
||||||
static const char* DELETED_ENTITY_COUNT_SQL =
|
static const char* DELETED_ENTITY_COUNT_SQL =
|
||||||
"SELECT COUNT(*) AS cnt FROM Pos.vDeletedEntity "
|
"SELECT COUNT(*) AS cnt FROM Pos.vDeletedEntity "
|
||||||
"WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > ?";
|
"WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > ?";
|
||||||
@@ -63,6 +71,12 @@ inline int64_t get_composite_count(int k_shop, int64_t cursor) {
|
|||||||
{{ParamType::Int,"",k_shop},{ParamType::Int,"",k_shop},
|
{{ParamType::Int,"",k_shop},{ParamType::Int,"",k_shop},
|
||||||
{ParamType::BigInt,"",cursor}});
|
{ParamType::BigInt,"",cursor}});
|
||||||
}
|
}
|
||||||
|
inline int64_t get_customer_count(int k_shop, int k_subshop, int64_t cursor) {
|
||||||
|
return get_pool().execute_scalar(CUSTOMER_COUNT_SQL,
|
||||||
|
{{ParamType::Int,"",k_shop},{ParamType::Int,"",k_subshop},
|
||||||
|
{ParamType::Int,"",k_shop},{ParamType::Int,"",k_shop},
|
||||||
|
{ParamType::BigInt,"",cursor}});
|
||||||
|
}
|
||||||
inline int64_t get_deleted_count(int64_t cursor) {
|
inline int64_t get_deleted_count(int64_t cursor) {
|
||||||
return get_pool().execute_scalar(DELETED_ENTITY_COUNT_SQL,
|
return get_pool().execute_scalar(DELETED_ENTITY_COUNT_SQL,
|
||||||
{{ParamType::BigInt,"",cursor}});
|
{{ParamType::BigInt,"",cursor}});
|
||||||
|
|||||||
100
jtlsrv-cpp/src/queries/customer_list.hpp
Normal file
100
jtlsrv-cpp/src/queries/customer_list.hpp
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "../db/pool.hpp"
|
||||||
|
#include "../log.hpp"
|
||||||
|
#include "nlohmann/json.hpp"
|
||||||
|
#include "shop.hpp"
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
static const char* CUSTOMER_LIST_SQL =
|
||||||
|
"SELECT TOP (?) "
|
||||||
|
"vCustomer.kId, "
|
||||||
|
"ISNULL(tInetKundeShop.cShopKundenNr, vCustomer.cCustomerNumber) AS cCustomerNumber, "
|
||||||
|
"vCustomer.cFirstName, "
|
||||||
|
"vCustomer.cLastName, "
|
||||||
|
"vCustomer.cTitle, "
|
||||||
|
"vCustomer.cCompany, "
|
||||||
|
"vCustomer.cAddress, "
|
||||||
|
"vCustomer.cAddressSupplement, "
|
||||||
|
"vCustomer.cCity, "
|
||||||
|
"vCustomer.cPostalCode, "
|
||||||
|
"vCustomer.cState, "
|
||||||
|
"vCustomer.cCountry, "
|
||||||
|
"vCustomer.cPhone, "
|
||||||
|
"vCustomer.cEmailAddress, "
|
||||||
|
"ISNULL(tInetKundeShop.kKundenGruppe, vCustomer.kCustomerGroupId) AS kCustomerGroupId, "
|
||||||
|
"vCustomer.cSalutation, "
|
||||||
|
"vCustomer.cDateOfBirth, "
|
||||||
|
"vCustomer.fDiscount, "
|
||||||
|
"vCustomer.cFederalTaxId, "
|
||||||
|
"CONVERT(BIGINT, vCustomer.bLastChanged) AS lastChanged, "
|
||||||
|
"vCustomer.kShop, "
|
||||||
|
"vCustomer.dLastModified, "
|
||||||
|
"vCustomer.dActive, "
|
||||||
|
"vCustomer.dInactive, "
|
||||||
|
"vCustomer.nDebtorNumber "
|
||||||
|
"FROM Pos.vCustomer "
|
||||||
|
"LEFT JOIN dbo.tInetKundeShop "
|
||||||
|
" ON vCustomer.kId = tInetKundeShop.kKunde "
|
||||||
|
" AND tInetKundeShop.kShop = vCustomer.kShop "
|
||||||
|
" AND tInetKundeShop.kSubShop = ? "
|
||||||
|
"WHERE vCustomer.kShop = ? "
|
||||||
|
" AND CONVERT(BIGINT, vCustomer.bLastChanged) > ? "
|
||||||
|
"ORDER BY vCustomer.bLastChanged ASC";
|
||||||
|
|
||||||
|
inline std::string format_birthday(const std::string& c_date_of_birth) {
|
||||||
|
if (c_date_of_birth.empty()) return "";
|
||||||
|
// cDateOfBirth is expected as "dd.MM.yyyy"
|
||||||
|
int d = 0, m = 0, y = 0;
|
||||||
|
if (std::sscanf(c_date_of_birth.c_str(), "%d.%d.%d", &d, &m, &y) != 3) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (y < 1900 || y > 2100 || m < 1 || m > 12 || d < 1 || d > 31) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
char buf[32];
|
||||||
|
std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d 00:00:00", y, m, d);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline nlohmann::json get_customer_list(int64_t cursor, int limit, int shop, int subshop) {
|
||||||
|
std::vector<Param> ps = {
|
||||||
|
{ParamType::Int, "", limit},
|
||||||
|
{ParamType::Int, "", subshop},
|
||||||
|
{ParamType::Int, "", shop},
|
||||||
|
{ParamType::BigInt, "", cursor},
|
||||||
|
};
|
||||||
|
ResultSet rs;
|
||||||
|
if (!get_pool().execute(CUSTOMER_LIST_SQL, ps, rs)) {
|
||||||
|
logc::warn("customer list query failed (cursor=%lld limit=%d shop=%d subshop=%d)",
|
||||||
|
(long long)cursor, limit, shop, subshop);
|
||||||
|
return nlohmann::json::array();
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json result = nlohmann::json::array();
|
||||||
|
for (auto& row : rs) {
|
||||||
|
result.push_back({
|
||||||
|
{"id", row[0].str},
|
||||||
|
{"customerNumber", row[1].type == CellType::Null ? "" : row[1].str},
|
||||||
|
{"firstname", row[2].type == CellType::Null ? "" : row[2].str},
|
||||||
|
{"lastname", row[3].type == CellType::Null ? "" : row[3].str},
|
||||||
|
{"title", row[4].type == CellType::Null ? "" : row[4].str},
|
||||||
|
{"company", row[5].type == CellType::Null ? "" : row[5].str},
|
||||||
|
{"address", row[6].type == CellType::Null ? "" : row[6].str},
|
||||||
|
{"addressSupplement", row[7].type == CellType::Null ? "" : row[7].str},
|
||||||
|
{"city", row[8].type == CellType::Null ? "" : row[8].str},
|
||||||
|
{"postalCode", row[9].type == CellType::Null ? "" : row[9].str},
|
||||||
|
{"state", row[10].type == CellType::Null ? "" : row[10].str},
|
||||||
|
{"country", row[11].type == CellType::Null ? "" : row[11].str},
|
||||||
|
{"phone", row[12].type == CellType::Null ? "" : row[12].str},
|
||||||
|
{"email", row[13].type == CellType::Null ? "" : row[13].str},
|
||||||
|
{"customerGroupId", row[14].type == CellType::Null ? "1" : row[14].str},
|
||||||
|
{"salutation", row[15].type == CellType::Null ? "" : row[15].str},
|
||||||
|
{"birthday", format_birthday(row[16].str)},
|
||||||
|
{"discount", row[17].type == CellType::Null ? "0.00" : row[17].str},
|
||||||
|
{"taxIdNumber", row[18].type == CellType::Null ? "" : row[18].str},
|
||||||
|
{"lastChanged", row[19].str},
|
||||||
|
{"debtorNumber", row[24].type == CellType::Null ? "0" : row[24].str},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
#include "shop.hpp"
|
#include "shop.hpp"
|
||||||
#include "../log.hpp"
|
#include "../log.hpp"
|
||||||
|
#include "../http.hpp"
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
||||||
int g_active_shop_id = 0;
|
int g_active_shop_id = 0;
|
||||||
int g_active_shop_subshop_id = 0;
|
int g_active_shop_subshop_id = 0;
|
||||||
|
int64_t g_active_max_last_changed = 0;
|
||||||
|
|
||||||
static int cell_to_int(const Cell& cell) {
|
static int cell_to_int(const Cell& cell) {
|
||||||
if (cell.type == CellType::Int64) return static_cast<int>(cell.i64);
|
if (cell.type == CellType::Int64) return static_cast<int>(cell.i64);
|
||||||
@@ -11,10 +13,16 @@ static int cell_to_int(const Cell& cell) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int64_t cell_to_int64(const Cell& cell) {
|
||||||
|
if (cell.type == CellType::Int64) return cell.i64;
|
||||||
|
if (cell.type == CellType::String && !cell.str.empty()) return parse_int64(cell.str, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
bool fetch_active_shop() {
|
bool fetch_active_shop() {
|
||||||
ResultSet rs;
|
ResultSet rs;
|
||||||
if (!get_pool().execute(
|
if (!get_pool().execute(
|
||||||
"SELECT TOP 1 kShop, kShopSubshop FROM dbo.tShopSubshop "
|
"SELECT TOP 1 kShop, kShopSubshop, nMaxLastChanged FROM dbo.tShopSubshop "
|
||||||
"WHERE nGesperrt = 0 ORDER BY kShop",
|
"WHERE nGesperrt = 0 ORDER BY kShop",
|
||||||
rs) || rs.empty()) {
|
rs) || rs.empty()) {
|
||||||
logc::warn("failed to load active shop from dbo.tShopSubshop");
|
logc::warn("failed to load active shop from dbo.tShopSubshop");
|
||||||
@@ -23,6 +31,9 @@ bool fetch_active_shop() {
|
|||||||
|
|
||||||
g_active_shop_id = cell_to_int(rs[0][0]);
|
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;
|
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);
|
g_active_max_last_changed = rs[0].size() > 2 ? cell_to_int64(rs[0][2]) : 0;
|
||||||
|
logc::info("Active shop: kShop=%d kShopSubshop=%d nMaxLastChanged=%lld",
|
||||||
|
g_active_shop_id, g_active_shop_subshop_id,
|
||||||
|
(long long)g_active_max_last_changed);
|
||||||
return g_active_shop_id > 0;
|
return g_active_shop_id > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
|
|
||||||
extern int g_active_shop_id;
|
extern int g_active_shop_id;
|
||||||
extern int g_active_shop_subshop_id;
|
extern int g_active_shop_subshop_id;
|
||||||
|
extern int64_t g_active_max_last_changed;
|
||||||
|
|
||||||
inline int get_active_shop_id() { return g_active_shop_id; }
|
inline int get_active_shop_id() { return g_active_shop_id; }
|
||||||
inline int get_active_shop_subshop_id() { return g_active_shop_subshop_id; }
|
inline int get_active_shop_subshop_id() { return g_active_shop_subshop_id; }
|
||||||
|
inline int64_t get_active_max_last_changed() { return g_active_max_last_changed; }
|
||||||
|
|
||||||
bool fetch_active_shop();
|
bool fetch_active_shop();
|
||||||
|
|||||||
@@ -651,6 +651,93 @@ function buildProducts(categories, customerGroupIds) {
|
|||||||
return { products, composites };
|
return { products, composites };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CUSTOMER_FIRST_NAMES = [
|
||||||
|
'Max',
|
||||||
|
'Anna',
|
||||||
|
'Julia',
|
||||||
|
'Peter',
|
||||||
|
'Maria',
|
||||||
|
'Lukas',
|
||||||
|
'Sofia',
|
||||||
|
'Jonas',
|
||||||
|
'Laura',
|
||||||
|
'Felix',
|
||||||
|
'Elena',
|
||||||
|
'David',
|
||||||
|
];
|
||||||
|
|
||||||
|
const CUSTOMER_LAST_NAMES = [
|
||||||
|
'Mustermann',
|
||||||
|
'Schmidt',
|
||||||
|
'Weber',
|
||||||
|
'Müller',
|
||||||
|
'Fischer',
|
||||||
|
'Schneider',
|
||||||
|
'Wagner',
|
||||||
|
'Becker',
|
||||||
|
'Hoffmann',
|
||||||
|
'Koch',
|
||||||
|
'Richter',
|
||||||
|
'Klein',
|
||||||
|
];
|
||||||
|
|
||||||
|
const CUSTOMER_CITIES = [
|
||||||
|
['Musterstadt', '12345'],
|
||||||
|
['Berlin', '10115'],
|
||||||
|
['Hamburg', '20095'],
|
||||||
|
['München', '80331'],
|
||||||
|
['Köln', '50667'],
|
||||||
|
['Frankfurt', '60311'],
|
||||||
|
['Stuttgart', '70173'],
|
||||||
|
['Düsseldorf', '40213'],
|
||||||
|
['Leipzig', '04109'],
|
||||||
|
['Dresden', '01067'],
|
||||||
|
['Nürnberg', '90402'],
|
||||||
|
['Bremen', '28195'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildCustomers(customerGroupIds) {
|
||||||
|
const customers = [];
|
||||||
|
const count = 24;
|
||||||
|
let lastChanged = 1;
|
||||||
|
const createdAt = formatDateTime(new Date('2024-01-20T09:00:00Z'));
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const id = i + 1;
|
||||||
|
const first = pick(CUSTOMER_FIRST_NAMES, i);
|
||||||
|
const last = pick(CUSTOMER_LAST_NAMES, i + 3);
|
||||||
|
const [city, postalCode] = CUSTOMER_CITIES[i % CUSTOMER_CITIES.length];
|
||||||
|
const streetNumber = 1 + (i % 60);
|
||||||
|
|
||||||
|
customers.push({
|
||||||
|
id: String(id),
|
||||||
|
customerNumber: `K-${String(1000 + i + 1)}`,
|
||||||
|
firstname: first,
|
||||||
|
lastname: last,
|
||||||
|
title: null,
|
||||||
|
company: null,
|
||||||
|
address: `Musterstraße ${streetNumber}`,
|
||||||
|
addressSupplement: null,
|
||||||
|
city,
|
||||||
|
postalCode,
|
||||||
|
state: null,
|
||||||
|
country: 'DE',
|
||||||
|
phone: i % 3 === 0 ? `+49 30 ${String(1000000 + i * 11111)}` : null,
|
||||||
|
email: `${first.toLowerCase()}.${last.toLowerCase()}@example.com`,
|
||||||
|
customerGroupId: String(pick(customerGroupIds, i)),
|
||||||
|
salutation: i % 2 === 0 ? 'Herr' : 'Frau',
|
||||||
|
birthday: i % 4 === 0 ? `${String((i % 28) + 1).padStart(2, '0')}.${String((i % 12) + 1).padStart(2, '0')}.${1970 + (i % 40)}` : null,
|
||||||
|
discount: '0.00',
|
||||||
|
taxIdNumber: i % 5 === 0 ? `DE${100000000 + i * 999}` : null,
|
||||||
|
lastChanged: String(lastChanged++),
|
||||||
|
debtorNumber: '0',
|
||||||
|
created: createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return customers;
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('Generating demo catalog…');
|
console.log('Generating demo catalog…');
|
||||||
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||||
@@ -685,6 +772,7 @@ async function main() {
|
|||||||
categories,
|
categories,
|
||||||
customerGroups.map((g) => Number(g.customerGroupId))
|
customerGroups.map((g) => Number(g.customerGroupId))
|
||||||
);
|
);
|
||||||
|
const customers = buildCustomers(customerGroups.map((g) => Number(g.customerGroupId)));
|
||||||
const perCategory = new Map();
|
const perCategory = new Map();
|
||||||
for (const product of products) {
|
for (const product of products) {
|
||||||
perCategory.set(product.categories_id, (perCategory.get(product.categories_id) || 0) + 1);
|
perCategory.set(product.categories_id, (perCategory.get(product.categories_id) || 0) + 1);
|
||||||
@@ -759,6 +847,7 @@ async function main() {
|
|||||||
version: 1,
|
version: 1,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
customerGroups,
|
customerGroups,
|
||||||
|
customers,
|
||||||
categories,
|
categories,
|
||||||
products,
|
products,
|
||||||
composites,
|
composites,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export async function loadDemoCatalog() {
|
|||||||
categories: catalog.categories.length,
|
categories: catalog.categories.length,
|
||||||
products: catalog.products.length,
|
products: catalog.products.length,
|
||||||
customerGroups: catalog.customerGroups.length,
|
customerGroups: catalog.customerGroups.length,
|
||||||
|
customers: (catalog.customers ?? []).length,
|
||||||
composites: catalog.composites.length,
|
composites: catalog.composites.length,
|
||||||
imagesDir: IMAGES_DIR,
|
imagesDir: IMAGES_DIR,
|
||||||
};
|
};
|
||||||
@@ -90,6 +91,15 @@ export function getDemoCustomerGroupCount({ cursor = 0 } = {}) {
|
|||||||
return afterCursor(ensureLoaded().customerGroups, cursor).length;
|
return afterCursor(ensureLoaded().customerGroups, cursor).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDemoCustomerCount({ cursor = 0 } = {}) {
|
||||||
|
const data = ensureLoaded();
|
||||||
|
return afterCursor(data.customers ?? [], cursor).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoCustomerList({ cursor = 0, limit = 20 } = {}) {
|
||||||
|
return afterCursor(ensureLoaded().customers ?? [], cursor).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
export function getDemoCompositeProductList({ cursor = 0, limit = 100 } = {}) {
|
export function getDemoCompositeProductList({ cursor = 0, limit = 100 } = {}) {
|
||||||
return afterCursor(ensureLoaded().composites, cursor).slice(0, limit);
|
return afterCursor(ensureLoaded().composites, cursor).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/endpoints/crashreport.js
Normal file
65
src/endpoints/crashreport.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import nodePath from 'node:path';
|
||||||
|
import { sendJson } from '../http.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
export const method = 'POST';
|
||||||
|
export const path = '/v1/crashreport';
|
||||||
|
|
||||||
|
const CRASH_LOG_DIR = process.env.CRASH_LOG_DIR || nodePath.join('logs', 'crash');
|
||||||
|
|
||||||
|
function sanitizeIp(ip) {
|
||||||
|
const value = String(ip || '0.0.0.0');
|
||||||
|
const withoutPrefix = value.replace(/^::ffff:/, '');
|
||||||
|
return withoutPrefix.replace(/[:]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampPart() {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return (
|
||||||
|
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
||||||
|
`-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(buffer) {
|
||||||
|
if (!buffer || !buffer.length) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(buffer.toString('utf8'));
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handle(req, res) {
|
||||||
|
const remoteAddress = req.socket?.remoteAddress ?? 'unknown';
|
||||||
|
const ip = sanitizeIp(remoteAddress);
|
||||||
|
const body = parseBody(req.rawBody);
|
||||||
|
|
||||||
|
if (body === null) {
|
||||||
|
return sendJson(res, 400, { Message: 'Invalid JSON body.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${timestampPart()}-${ip}.txt`;
|
||||||
|
const filePath = nodePath.join(CRASH_LOG_DIR, fileName);
|
||||||
|
|
||||||
|
fs.mkdirSync(CRASH_LOG_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`timestamp: ${new Date().toISOString()}`,
|
||||||
|
`ip: ${remoteAddress}`,
|
||||||
|
`user-agent: ${req.headers['user-agent'] ?? ''}`,
|
||||||
|
'',
|
||||||
|
JSON.stringify(body, null, 2),
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, lines.join('\n'));
|
||||||
|
logger.warn(`crash report saved to ${filePath}`);
|
||||||
|
|
||||||
|
return sendJson(res, 200, { Message: 'OK', file: fileName });
|
||||||
|
}
|
||||||
14
src/endpoints/customer.js
Normal file
14
src/endpoints/customer.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { sendJson } from '../http.js';
|
||||||
|
import { getCustomerList } from '../queries/customer-list.js';
|
||||||
|
|
||||||
|
export const method = 'GET';
|
||||||
|
export const path = '/v1/customer';
|
||||||
|
|
||||||
|
export async function handle(_req, res, { url }) {
|
||||||
|
const cursor = Number(url.searchParams.get('lastChangedCustomer')) || 0;
|
||||||
|
const limit = Number(url.searchParams.get('limit')) || 20;
|
||||||
|
|
||||||
|
const customers = await getCustomerList({ cursor, limit });
|
||||||
|
|
||||||
|
return sendJson(res, 200, customers);
|
||||||
|
}
|
||||||
@@ -1,13 +1,31 @@
|
|||||||
import * as category from './category.js';
|
import * as category from './category.js';
|
||||||
import * as cimage from './cimage.js';
|
import * as cimage from './cimage.js';
|
||||||
import * as client from './client.js';
|
import * as client from './client.js';
|
||||||
|
import * as crashreport from './crashreport.js';
|
||||||
|
import * as customer from './customer.js';
|
||||||
import * as customergroup from './customergroup.js';
|
import * as customergroup from './customergroup.js';
|
||||||
import * as deletedEntity from './deleted-entity.js';
|
import * as deletedEntity from './deleted-entity.js';
|
||||||
import * as init from './init.js';
|
import * as init from './init.js';
|
||||||
import * as newpin from './newpin.js';
|
import * as newpin from './newpin.js';
|
||||||
import * as order from './order.js';
|
import * as order from './order.js';
|
||||||
|
import * as orderSearch from './order-search.js';
|
||||||
import * as pimage from './pimage.js';
|
import * as pimage from './pimage.js';
|
||||||
import * as product from './product.js';
|
import * as product from './product.js';
|
||||||
import * as productcomposite from './productcomposite.js';
|
import * as productcomposite from './productcomposite.js';
|
||||||
|
|
||||||
export const endpoints = [client, newpin, init, category, product, productcomposite, deletedEntity, pimage, cimage, customergroup, order];
|
export const endpoints = [
|
||||||
|
client,
|
||||||
|
newpin,
|
||||||
|
init,
|
||||||
|
category,
|
||||||
|
product,
|
||||||
|
productcomposite,
|
||||||
|
deletedEntity,
|
||||||
|
pimage,
|
||||||
|
cimage,
|
||||||
|
customergroup,
|
||||||
|
customer,
|
||||||
|
order,
|
||||||
|
orderSearch,
|
||||||
|
crashreport,
|
||||||
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { sendJson } from '../http.js';
|
|||||||
import { getCategoryCount } from '../queries/category-count.js';
|
import { getCategoryCount } from '../queries/category-count.js';
|
||||||
import { getMaxOrderIdCount } from '../queries/max-order-id.js';
|
import { getMaxOrderIdCount } from '../queries/max-order-id.js';
|
||||||
import { getCompositeProductCount } from '../queries/composite-product-count.js';
|
import { getCompositeProductCount } from '../queries/composite-product-count.js';
|
||||||
|
import { getCustomerCount } from '../queries/customer-count.js';
|
||||||
import { getCustomerGroupCount } from '../queries/customer-groups.js';
|
import { getCustomerGroupCount } from '../queries/customer-groups.js';
|
||||||
import { getDeletedEntityCount } from '../queries/deleted-entity-count.js';
|
import { getDeletedEntityCount } from '../queries/deleted-entity-count.js';
|
||||||
import { getProductCount } from '../queries/product-count.js';
|
import { getProductCount } from '../queries/product-count.js';
|
||||||
@@ -12,13 +13,15 @@ export const path = '/v1/init';
|
|||||||
export async function handle(_req, res, { url }) {
|
export async function handle(_req, res, { url }) {
|
||||||
const productCursor = Number(url.searchParams.get('lastChangedProduct')) || 0;
|
const productCursor = Number(url.searchParams.get('lastChangedProduct')) || 0;
|
||||||
const categoryCursor = Number(url.searchParams.get('lastChangedCategory')) || 0;
|
const categoryCursor = Number(url.searchParams.get('lastChangedCategory')) || 0;
|
||||||
|
const customerCursor = Number(url.searchParams.get('lastChangedCustomer')) || 0;
|
||||||
const customerGroupCursor = Number(url.searchParams.get('lastChangedCustomerGroup')) || 0;
|
const customerGroupCursor = Number(url.searchParams.get('lastChangedCustomerGroup')) || 0;
|
||||||
const compositeProductCursor = Number(url.searchParams.get('lastChangedCompositeProduct')) || 0;
|
const compositeProductCursor = Number(url.searchParams.get('lastChangedCompositeProduct')) || 0;
|
||||||
const deletedEntityCursor = Number(url.searchParams.get('lastChangedDeletedEntity')) || 0;
|
const deletedEntityCursor = Number(url.searchParams.get('lastChangedDeletedEntity')) || 0;
|
||||||
|
|
||||||
const [productCount, categoryCount, customerGroupCount, compositeProductCount, deletedEntityCount, maxOrderIdCount] = await Promise.all([
|
const [productCount, categoryCount, customerCount, customerGroupCount, compositeProductCount, deletedEntityCount, maxOrderIdCount] = await Promise.all([
|
||||||
getProductCount({ cursor: productCursor }),
|
getProductCount({ cursor: productCursor }),
|
||||||
getCategoryCount({ cursor: categoryCursor }),
|
getCategoryCount({ cursor: categoryCursor }),
|
||||||
|
getCustomerCount({ cursor: customerCursor }),
|
||||||
getCustomerGroupCount({ cursor: customerGroupCursor }),
|
getCustomerGroupCount({ cursor: customerGroupCursor }),
|
||||||
getCompositeProductCount({ cursor: compositeProductCursor }),
|
getCompositeProductCount({ cursor: compositeProductCursor }),
|
||||||
getDeletedEntityCount({ cursor: deletedEntityCursor }),
|
getDeletedEntityCount({ cursor: deletedEntityCursor }),
|
||||||
@@ -29,7 +32,7 @@ export async function handle(_req, res, { url }) {
|
|||||||
version: '1.10.12.0',
|
version: '1.10.12.0',
|
||||||
product_count: String(productCount),
|
product_count: String(productCount),
|
||||||
category_count: String(categoryCount),
|
category_count: String(categoryCount),
|
||||||
customer_count: '0',
|
customer_count: String(customerCount),
|
||||||
customerGroup_count: String(customerGroupCount),
|
customerGroup_count: String(customerGroupCount),
|
||||||
compositeProduct_count: String(compositeProductCount),
|
compositeProduct_count: String(compositeProductCount),
|
||||||
configurationGroup_count: '0',
|
configurationGroup_count: '0',
|
||||||
|
|||||||
254
src/endpoints/order-search.js
Normal file
254
src/endpoints/order-search.js
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { sendJson } from '../http.js';
|
||||||
|
|
||||||
|
export const method = 'GET';
|
||||||
|
export const path = '/v1/order';
|
||||||
|
|
||||||
|
function shippingAddress(overrides = {}) {
|
||||||
|
return {
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
company: 'Muster GmbH',
|
||||||
|
street: 'Hauptstraße 1',
|
||||||
|
zipCode: '12345',
|
||||||
|
city: 'Musterstadt',
|
||||||
|
phone: '030 123456',
|
||||||
|
fax: '030 123457',
|
||||||
|
email: 'max@example.com',
|
||||||
|
salutation: 'Herr',
|
||||||
|
extraAddressLine: '',
|
||||||
|
mobile: '0170 123456',
|
||||||
|
title: '',
|
||||||
|
deliveryInstruction: '',
|
||||||
|
state: '',
|
||||||
|
countryIso: 'DE',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function billingAddress(overrides = {}) {
|
||||||
|
return {
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
company: 'Muster GmbH',
|
||||||
|
street: 'Hauptstraße 1',
|
||||||
|
zipCode: '12345',
|
||||||
|
city: 'Musterstadt',
|
||||||
|
phone: '030 123456',
|
||||||
|
fax: '030 123457',
|
||||||
|
email: 'max@example.com',
|
||||||
|
salutation: 'Herr',
|
||||||
|
extraAddressLine: '',
|
||||||
|
mobile: '0170 123456',
|
||||||
|
title: '',
|
||||||
|
state: '',
|
||||||
|
countryIso: 'DE',
|
||||||
|
addressAddition: null,
|
||||||
|
toTheAttention: null,
|
||||||
|
discount: 0,
|
||||||
|
customerGroupId: '1',
|
||||||
|
birthday: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderItem(overrides = {}) {
|
||||||
|
return {
|
||||||
|
orderItemId: '55',
|
||||||
|
priceNet: '9.90',
|
||||||
|
priceGross: '11.78',
|
||||||
|
vat: '19',
|
||||||
|
quantity: '1',
|
||||||
|
name: 'Artikel A',
|
||||||
|
sku: 'SKU-1',
|
||||||
|
unit: 'Stk',
|
||||||
|
type: 0,
|
||||||
|
discountPercent: '0',
|
||||||
|
uniqueId: 'var-123',
|
||||||
|
configurationitemId: '0',
|
||||||
|
deliveryDetails: [],
|
||||||
|
purchasePriceNet: '0',
|
||||||
|
note: null,
|
||||||
|
isReturn: '0',
|
||||||
|
externalId: '0',
|
||||||
|
totalPriceNet: null,
|
||||||
|
totalPriceGross: null,
|
||||||
|
parentItemId: '0',
|
||||||
|
voucherUsageData: null,
|
||||||
|
voucherCompleteData: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function payment(overrides = {}) {
|
||||||
|
return {
|
||||||
|
paymentId: '9',
|
||||||
|
paymentMethodName: 'EC-Karte',
|
||||||
|
amount: 12.5,
|
||||||
|
voucherId: null,
|
||||||
|
voucherUsageId: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DUMMY_ORDERS = [
|
||||||
|
{
|
||||||
|
orderId: '123',
|
||||||
|
note: 'Kundennummer auf der Rechnung angeben',
|
||||||
|
creationDate: '2026-08-14 12:00:00',
|
||||||
|
shippingName: 'Bar',
|
||||||
|
shippingInfo: null,
|
||||||
|
currencyIso: 'EUR',
|
||||||
|
languageIso: 'de',
|
||||||
|
paymentMethodName: 'Bar',
|
||||||
|
orderNumber: 'WA-2026-0001',
|
||||||
|
invoiceNumber: null,
|
||||||
|
externalOrderNumber: null,
|
||||||
|
comment: null,
|
||||||
|
descriptionType: 0,
|
||||||
|
customerNumber: 'K1001',
|
||||||
|
shippingAddress: shippingAddress(),
|
||||||
|
billingAddress: billingAddress(),
|
||||||
|
orderItems: [
|
||||||
|
orderItem(),
|
||||||
|
orderItem({
|
||||||
|
orderItemId: '56',
|
||||||
|
priceNet: '5.00',
|
||||||
|
priceGross: '5.95',
|
||||||
|
name: 'Artikel B',
|
||||||
|
sku: 'SKU-2',
|
||||||
|
uniqueId: 'var-456',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
ShippingDate: null,
|
||||||
|
settings: null,
|
||||||
|
payments: [
|
||||||
|
payment({
|
||||||
|
paymentId: '9',
|
||||||
|
paymentMethodName: 'Bar',
|
||||||
|
amount: 17.73,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
externalId: '0',
|
||||||
|
changeable: 'true',
|
||||||
|
rapRounding: 'false',
|
||||||
|
taxIdNumber: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
orderId: '124',
|
||||||
|
note: 'Bitte vor 18 Uhr liefern',
|
||||||
|
creationDate: '2026-08-13 17:30:00',
|
||||||
|
shippingName: 'Versand',
|
||||||
|
shippingInfo: null,
|
||||||
|
currencyIso: 'EUR',
|
||||||
|
languageIso: 'de',
|
||||||
|
paymentMethodName: 'EC-Karte',
|
||||||
|
orderNumber: 'WA-2026-0002',
|
||||||
|
invoiceNumber: null,
|
||||||
|
externalOrderNumber: 'R00082',
|
||||||
|
comment: null,
|
||||||
|
descriptionType: 0,
|
||||||
|
customerNumber: 'K1002',
|
||||||
|
shippingAddress: shippingAddress({
|
||||||
|
firstName: 'Erika',
|
||||||
|
lastName: 'Musterfrau',
|
||||||
|
company: '',
|
||||||
|
street: 'Nebenstraße 2',
|
||||||
|
zipCode: '54321',
|
||||||
|
city: 'Beispielstadt',
|
||||||
|
}),
|
||||||
|
billingAddress: billingAddress({
|
||||||
|
firstName: 'Erika',
|
||||||
|
lastName: 'Musterfrau',
|
||||||
|
company: '',
|
||||||
|
street: 'Nebenstraße 2',
|
||||||
|
zipCode: '54321',
|
||||||
|
city: 'Beispielstadt',
|
||||||
|
}),
|
||||||
|
orderItems: [
|
||||||
|
orderItem({
|
||||||
|
orderItemId: '57',
|
||||||
|
priceNet: '12.50',
|
||||||
|
priceGross: '14.88',
|
||||||
|
name: 'Artikel C',
|
||||||
|
sku: 'SKU-3',
|
||||||
|
unit: 'kg',
|
||||||
|
uniqueId: 'var-789',
|
||||||
|
deliveryDetails: [
|
||||||
|
{
|
||||||
|
productId: 3,
|
||||||
|
lotNumber: 'LOT-2026-001',
|
||||||
|
bestBeforeDate: '2027-03-01',
|
||||||
|
serialNumber: '',
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
ShippingDate: null,
|
||||||
|
settings: null,
|
||||||
|
payments: [
|
||||||
|
payment({
|
||||||
|
paymentId: '10',
|
||||||
|
paymentMethodName: 'EC-Karte',
|
||||||
|
amount: 14.88,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
externalId: '0',
|
||||||
|
changeable: 'true',
|
||||||
|
rapRounding: 'false',
|
||||||
|
taxIdNumber: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
orderId: '125',
|
||||||
|
note: '',
|
||||||
|
creationDate: '2026-08-12 09:15:00',
|
||||||
|
shippingName: 'Selbstabholer',
|
||||||
|
shippingInfo: null,
|
||||||
|
currencyIso: 'EUR',
|
||||||
|
languageIso: 'de',
|
||||||
|
paymentMethodName: 'Rechnung',
|
||||||
|
orderNumber: 'WA-2026-0003',
|
||||||
|
invoiceNumber: null,
|
||||||
|
externalOrderNumber: null,
|
||||||
|
comment: 'Storniert laut Kundenwunsch',
|
||||||
|
descriptionType: 0,
|
||||||
|
customerNumber: 'K1003',
|
||||||
|
shippingAddress: shippingAddress({
|
||||||
|
lastName: 'Beispiel',
|
||||||
|
company: 'Beispiel & Co. KG',
|
||||||
|
email: 'office@beispiel.de',
|
||||||
|
}),
|
||||||
|
billingAddress: billingAddress({
|
||||||
|
lastName: 'Beispiel',
|
||||||
|
company: 'Beispiel & Co. KG',
|
||||||
|
email: 'office@beispiel.de',
|
||||||
|
}),
|
||||||
|
orderItems: [
|
||||||
|
orderItem({
|
||||||
|
orderItemId: '58',
|
||||||
|
priceNet: '19.90',
|
||||||
|
priceGross: '23.68',
|
||||||
|
name: 'Artikel D',
|
||||||
|
sku: 'SKU-4',
|
||||||
|
type: 0,
|
||||||
|
uniqueId: 'var-101112',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
ShippingDate: null,
|
||||||
|
settings: null,
|
||||||
|
payments: [],
|
||||||
|
externalId: '0',
|
||||||
|
changeable: 'true',
|
||||||
|
rapRounding: 'false',
|
||||||
|
taxIdNumber: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function handle(_req, res, { url }) {
|
||||||
|
const mandantId = url.searchParams.get('mandantId') ?? '';
|
||||||
|
const search = url.searchParams.get('search') ?? '';
|
||||||
|
const searchCustomer = url.searchParams.get('searchCustomer') ?? '';
|
||||||
|
|
||||||
|
// Static dummy response — query params are accepted but do not filter yet.
|
||||||
|
return sendJson(res, 200, DUMMY_ORDERS);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export function readBody(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CORS_HEADERS = {
|
const CORS_HEADERS = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
//'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
'Access-Control-Max-Age': '86400',
|
'Access-Control-Max-Age': '86400',
|
||||||
|
|||||||
@@ -4,13 +4,28 @@ import { getDemoCategoryCount } from '../demo/store.js';
|
|||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
|
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
|
||||||
|
|
||||||
|
const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1;
|
||||||
|
|
||||||
const CATEGORY_COUNT_SQL = `
|
const CATEGORY_COUNT_SQL = `
|
||||||
${CATEGORY_TREE_CTE}
|
${CATEGORY_TREE_CTE}
|
||||||
SELECT COUNT(*) AS CategoryCount
|
SELECT COUNT(*) AS CategoryCount
|
||||||
FROM dbo.tKategorie k
|
FROM dbo.tKategorie k
|
||||||
WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree)
|
LEFT JOIN dbo.tKategorieSprache ks ON ks.kKategorie = k.kKategorie AND ks.kSprache = @languageId
|
||||||
AND (@kShop = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks WHERE ks.kKategorie = k.kKategorie AND ks.kShop = @kShop))
|
LEFT JOIN dbo.tKategoriebildPlattform kbp ON kbp.kKategorie = k.kKategorie
|
||||||
AND CONVERT(BIGINT, k.bRowversion) > @cursor;
|
CROSS APPLY (
|
||||||
|
SELECT MAX(v) AS lastChanged
|
||||||
|
FROM (VALUES
|
||||||
|
(CONVERT(BIGINT, k.bRowversion)),
|
||||||
|
(CONVERT(BIGINT, ks.bRowversion)),
|
||||||
|
(CONVERT(BIGINT, kbp.bRowversion)),
|
||||||
|
((SELECT MAX(CONVERT(BIGINT, ka.bRowversion)) FROM dbo.tKategorieArtikel ka WHERE ka.kKategorie = k.kKategorie)),
|
||||||
|
((SELECT MAX(CONVERT(BIGINT, ksh.bRowversion)) FROM dbo.tKategorieShop ksh WHERE ksh.kKategorie = k.kKategorie AND (@kShop = 0 OR ksh.kShop = @kShop)))
|
||||||
|
) AS t(v)
|
||||||
|
) rv
|
||||||
|
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 rv.lastChanged > @cursor;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getCategoryCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
|
export async function getCategoryCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
|
||||||
@@ -20,6 +35,7 @@ export async function getCategoryCount({ cursor = 0, rootCategoryId = getRootCat
|
|||||||
|
|
||||||
const result = await categoryTreeRequest(getPool(), rootCategoryId)
|
const result = await categoryTreeRequest(getPool(), rootCategoryId)
|
||||||
.input('cursor', sql.BigInt, cursor)
|
.input('cursor', sql.BigInt, cursor)
|
||||||
|
.input('languageId', sql.Int, LANGUAGE_ID)
|
||||||
.query(CATEGORY_COUNT_SQL);
|
.query(CATEGORY_COUNT_SQL);
|
||||||
return result.recordset[0]?.CategoryCount ?? 0;
|
return result.recordset[0]?.CategoryCount ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,17 +14,27 @@ SELECT TOP (@limit)
|
|||||||
k.nSort AS sort,
|
k.nSort AS sort,
|
||||||
ks.cName AS name,
|
ks.cName AS name,
|
||||||
b.cHash AS imgHash,
|
b.cHash AS imgHash,
|
||||||
CONVERT(BIGINT, k.bRowversion) AS lastChanged
|
rv.lastChanged
|
||||||
FROM dbo.tKategorie k
|
FROM dbo.tKategorie k
|
||||||
INNER JOIN dbo.tKategorieSprache ks ON ks.kKategorie = k.kKategorie AND ks.kSprache = @languageId
|
INNER JOIN dbo.tKategorieSprache ks ON ks.kKategorie = k.kKategorie AND ks.kSprache = @languageId
|
||||||
LEFT JOIN dbo.tKategoriebildPlattform kbp
|
LEFT JOIN dbo.tKategoriebildPlattform kbp
|
||||||
ON kbp.kKategorie = k.kKategorie
|
ON kbp.kKategorie = k.kKategorie
|
||||||
LEFT JOIN dbo.tBild b ON b.kBild = kbp.kBild
|
LEFT JOIN dbo.tBild b ON b.kBild = kbp.kBild
|
||||||
|
CROSS APPLY (
|
||||||
|
SELECT MAX(v) AS lastChanged
|
||||||
|
FROM (VALUES
|
||||||
|
(CONVERT(BIGINT, k.bRowversion)),
|
||||||
|
(CONVERT(BIGINT, ks.bRowversion)),
|
||||||
|
(CONVERT(BIGINT, kbp.bRowversion)),
|
||||||
|
((SELECT MAX(CONVERT(BIGINT, ka.bRowversion)) FROM dbo.tKategorieArtikel ka WHERE ka.kKategorie = k.kKategorie)),
|
||||||
|
((SELECT MAX(CONVERT(BIGINT, ksh.bRowversion)) FROM dbo.tKategorieShop ksh WHERE ksh.kKategorie = k.kKategorie AND (@kShop = 0 OR ksh.kShop = @kShop)))
|
||||||
|
) AS t(v)
|
||||||
|
) rv
|
||||||
WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree WHERE kKategorie <> @rootCategoryId)
|
WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree WHERE kKategorie <> @rootCategoryId)
|
||||||
AND k.cAktiv = 'Y'
|
AND k.cAktiv = 'Y'
|
||||||
AND (@kShop = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks2 WHERE ks2.kKategorie = k.kKategorie AND ks2.kShop = @kShop))
|
AND (@kShop = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks2 WHERE ks2.kKategorie = k.kKategorie AND ks2.kShop = @kShop))
|
||||||
AND CONVERT(BIGINT, k.bRowversion) > @cursor
|
AND rv.lastChanged > @cursor
|
||||||
ORDER BY lastChanged ASC;
|
ORDER BY rv.lastChanged ASC;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId = getRootCategoryId() } = {}) {
|
export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId = getRootCategoryId() } = {}) {
|
||||||
|
|||||||
34
src/queries/customer-count.js
Normal file
34
src/queries/customer-count.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { getDemoCustomerCount } from '../demo/store.js';
|
||||||
|
import { getPool } from '../db.js';
|
||||||
|
import { getActiveShopId, getActiveShopSubshopId } from '../shop.js';
|
||||||
|
|
||||||
|
const CUSTOMER_COUNT_SQL = `
|
||||||
|
SELECT COUNT(DISTINCT k.kKunde) AS CustomerCount
|
||||||
|
FROM dbo.tkunde k
|
||||||
|
LEFT JOIN dbo.tInetKundeShop iks
|
||||||
|
ON iks.kKunde = k.kKunde
|
||||||
|
AND iks.kShop = @kShop
|
||||||
|
AND iks.kSubShop = @SubShopId
|
||||||
|
WHERE (@kShop = 0 OR EXISTS (
|
||||||
|
SELECT 1 FROM dbo.tInetKundeShop x
|
||||||
|
WHERE x.kKunde = k.kKunde AND x.kShop = @kShop
|
||||||
|
))
|
||||||
|
AND CONVERT(BIGINT, k.bRowversion) > @cursor;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function getCustomerCount({ cursor = 0 } = {}) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return getDemoCustomerCount({ cursor });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getPool()
|
||||||
|
.request()
|
||||||
|
.input('kShop', sql.Int, getActiveShopId())
|
||||||
|
.input('SubShopId', sql.Int, getActiveShopSubshopId())
|
||||||
|
.input('cursor', sql.BigInt, cursor)
|
||||||
|
.query(CUSTOMER_COUNT_SQL);
|
||||||
|
|
||||||
|
return result.recordset[0]?.CustomerCount ?? 0;
|
||||||
|
}
|
||||||
98
src/queries/customer-list.js
Normal file
98
src/queries/customer-list.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import sql from 'mssql';
|
||||||
|
import { isDemoMode } from '../demo/mode.js';
|
||||||
|
import { getDemoCustomerList } from '../demo/store.js';
|
||||||
|
import { getPool } from '../db.js';
|
||||||
|
import { getActiveShopId, getActiveShopSubshopId } from '../shop.js';
|
||||||
|
|
||||||
|
const CUSTOMER_LIST_SQL = `
|
||||||
|
SELECT TOP (@Limit)
|
||||||
|
vCustomer.kId,
|
||||||
|
ISNULL(tInetKundeShop.cShopKundenNr, vCustomer.cCustomerNumber) AS cCustomerNumber,
|
||||||
|
vCustomer.cFirstName,
|
||||||
|
vCustomer.cLastName,
|
||||||
|
vCustomer.cTitle,
|
||||||
|
vCustomer.cCompany,
|
||||||
|
vCustomer.cAddress,
|
||||||
|
vCustomer.cAddressSupplement,
|
||||||
|
vCustomer.cCity,
|
||||||
|
vCustomer.cPostalCode,
|
||||||
|
vCustomer.cState,
|
||||||
|
vCustomer.cCountry,
|
||||||
|
vCustomer.cPhone,
|
||||||
|
vCustomer.cEmailAddress,
|
||||||
|
ISNULL(tInetKundeShop.kKundenGruppe, vCustomer.kCustomerGroupId) AS kCustomerGroupId,
|
||||||
|
vCustomer.cSalutation,
|
||||||
|
vCustomer.cDateOfBirth,
|
||||||
|
vCustomer.fDiscount,
|
||||||
|
vCustomer.cFederalTaxId,
|
||||||
|
CONVERT(BIGINT, vCustomer.bLastChanged) AS lastChanged,
|
||||||
|
vCustomer.kShop,
|
||||||
|
vCustomer.dLastModified,
|
||||||
|
vCustomer.dActive,
|
||||||
|
vCustomer.dInactive,
|
||||||
|
vCustomer.nDebtorNumber
|
||||||
|
FROM Pos.vCustomer
|
||||||
|
LEFT JOIN dbo.tInetKundeShop
|
||||||
|
ON vCustomer.kId = tInetKundeShop.kKunde
|
||||||
|
AND tInetKundeShop.kShop = vCustomer.kShop
|
||||||
|
AND tInetKundeShop.kSubShop = @SubShopId
|
||||||
|
WHERE vCustomer.kShop = @ShopId
|
||||||
|
AND CONVERT(BIGINT, vCustomer.bLastChanged) > @bLastChanged
|
||||||
|
ORDER BY vCustomer.bLastChanged ASC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function formatBirthday(cDateOfBirth) {
|
||||||
|
if (!cDateOfBirth) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const text = String(cDateOfBirth).trim();
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// cDateOfBirth is expected as "dd.MM.yyyy"
|
||||||
|
const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(text);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [, day, month, year] = match;
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${year}-${pad(month)}-${pad(day)} 00:00:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCustomerList({ cursor = 0, limit = 20 } = {}) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return getDemoCustomerList({ cursor, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getPool()
|
||||||
|
.request()
|
||||||
|
.input('Limit', sql.Int, limit)
|
||||||
|
.input('ShopId', sql.Int, getActiveShopId())
|
||||||
|
.input('SubShopId', sql.Int, getActiveShopSubshopId())
|
||||||
|
.input('bLastChanged', sql.BigInt, cursor)
|
||||||
|
.query(CUSTOMER_LIST_SQL);
|
||||||
|
|
||||||
|
return result.recordset.map((row) => ({
|
||||||
|
id: String(row.kId),
|
||||||
|
customerNumber: row.cCustomerNumber ?? null,
|
||||||
|
firstname: row.cFirstName ?? null,
|
||||||
|
lastname: row.cLastName ?? null,
|
||||||
|
title: row.cTitle ?? null,
|
||||||
|
company: row.cCompany ?? null,
|
||||||
|
address: row.cAddress ?? null,
|
||||||
|
addressSupplement: row.cAddressSupplement ?? null,
|
||||||
|
city: row.cCity ?? null,
|
||||||
|
postalCode: row.cPostalCode ?? null,
|
||||||
|
state: row.cState ?? null,
|
||||||
|
country: row.cCountry ?? null,
|
||||||
|
phone: row.cPhone ?? null,
|
||||||
|
email: row.cEmailAddress ?? null,
|
||||||
|
customerGroupId: String(row.kCustomerGroupId),
|
||||||
|
salutation: row.cSalutation ?? null,
|
||||||
|
birthday: formatBirthday(row.cDateOfBirth),
|
||||||
|
discount: Number(row.fDiscount).toFixed(2),
|
||||||
|
taxIdNumber: row.cFederalTaxId ?? null,
|
||||||
|
lastChanged: String(row.lastChanged),
|
||||||
|
debtorNumber: String(row.nDebtorNumber ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { openSession, discardSession, closeSession } from './session.js';
|
import { openSession, discardSession, closeSession } from './session.js';
|
||||||
import { getOutgoingWarehouse, getWarehousePlace } from './warehouse.js';
|
import { getOutgoingWarehouse, getWarehousePlace } from './warehouse.js';
|
||||||
import { reservePositions } from './reserve.js';
|
import { reservePositions } from './reserve.js';
|
||||||
import { bookStockShortfallsAndRereserve } from './stock-shortage.js';
|
import { bookStockShortfallsAndRereserve, bookReturnPositions } from './stock-shortage.js';
|
||||||
import { commitPicklists } from './commit.js';
|
import { commitPicklists } from './commit.js';
|
||||||
import { deliverPicklists } from './deliver.js';
|
import { deliverPicklists } from './deliver.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
@@ -29,17 +29,28 @@ export async function deliverOrder(transaction, kBenutzer, kAuftrag, kVersandArt
|
|||||||
const kWarenLager = await getOutgoingWarehouse(transaction);
|
const kWarenLager = await getOutgoingWarehouse(transaction);
|
||||||
const kWarenLagerPlatz = await getWarehousePlace(transaction, kWarenLager);
|
const kWarenLagerPlatz = await getWarehousePlace(transaction, kWarenLager);
|
||||||
logger.info(`deliverOrder: kWarenLager=${kWarenLager} kWarenLagerPlatz=${kWarenLagerPlatz}`);
|
logger.info(`deliverOrder: kWarenLager=${kWarenLager} kWarenLagerPlatz=${kWarenLagerPlatz}`);
|
||||||
|
|
||||||
|
const positiveItems = deliveredItems.filter((i) => i.quantity > 0);
|
||||||
|
const returnItems = deliveredItems.filter((i) => i.quantity < 0);
|
||||||
|
|
||||||
|
// 1. Process returned items: book them back into warehouse stock via Wareneingang
|
||||||
|
if (returnItems.length) {
|
||||||
|
await bookReturnPositions(transaction, kBenutzer, kWarenLagerPlatz, returnItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Process positive items: standard JTL picklist/delivery fulfillment
|
||||||
|
if (positiveItems.length) {
|
||||||
const kSessionId = await openSession(transaction, kBenutzer);
|
const kSessionId = await openSession(transaction, kBenutzer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, deliveredItems);
|
await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, positiveItems);
|
||||||
await bookStockShortfallsAndRereserve(
|
await bookStockShortfallsAndRereserve(
|
||||||
transaction,
|
transaction,
|
||||||
kBenutzer,
|
kBenutzer,
|
||||||
kSessionId,
|
kSessionId,
|
||||||
kWarenLager,
|
kWarenLager,
|
||||||
kWarenLagerPlatz,
|
kWarenLagerPlatz,
|
||||||
deliveredItems,
|
positiveItems,
|
||||||
);
|
);
|
||||||
await commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag);
|
await commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag);
|
||||||
await deliverPicklists(transaction, kBenutzer, kSessionId, kAuftrag, kVersandArt);
|
await deliverPicklists(transaction, kBenutzer, kSessionId, kAuftrag, kVersandArt);
|
||||||
@@ -48,11 +59,6 @@ export async function deliverOrder(transaction, kBenutzer, kAuftrag, kVersandArt
|
|||||||
logger.error(`deliverOrder: FAILED kAuftrag=${kAuftrag} kSessionId=${kSessionId}: ${err.message}`);
|
logger.error(`deliverOrder: FAILED kAuftrag=${kAuftrag} kSessionId=${kSessionId}: ${err.message}`);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
// Safe to run unconditionally (success or error): spPicklistenVerwerfen only
|
|
||||||
// ever removes not-yet-delivered (nStatus < 10) Picklisten for this session.
|
|
||||||
// Swallow cleanup errors so they never mask an earlier, more relevant error
|
|
||||||
// (the outer transaction rollback in create-order.js is what actually matters
|
|
||||||
// on failure).
|
|
||||||
try {
|
try {
|
||||||
await discardSession(transaction, kBenutzer, kSessionId);
|
await discardSession(transaction, kBenutzer, kSessionId);
|
||||||
await closeSession(transaction, kSessionId);
|
await closeSession(transaction, kSessionId);
|
||||||
@@ -61,3 +67,4 @@ export async function deliverOrder(transaction, kBenutzer, kAuftrag, kVersandArt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async function getReservedQuantity(transaction, kSessionId, kAuftragPosition) {
|
|||||||
return reserved;
|
return reserved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPositionArtikel(transaction, kAuftragPosition) {
|
export async function getPositionArtikel(transaction, kAuftragPosition) {
|
||||||
const result = await new sql.Request(transaction)
|
const result = await new sql.Request(transaction)
|
||||||
.input('kAuftragPosition', sql.Int, kAuftragPosition)
|
.input('kAuftragPosition', sql.Int, kAuftragPosition)
|
||||||
.query(`
|
.query(`
|
||||||
@@ -35,14 +35,22 @@ async function getPositionArtikel(transaction, kAuftragPosition) {
|
|||||||
return row?.kArtikel ?? 0;
|
return row?.kArtikel ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtikel, fehlmenge) {
|
export async function bookWareneingang(
|
||||||
|
transaction,
|
||||||
|
kBenutzer,
|
||||||
|
kWarenLagerPlatz,
|
||||||
|
kArtikel,
|
||||||
|
fehlmenge,
|
||||||
|
comment = POS_SHORTAGE_COMMENT,
|
||||||
|
buchungsart = BUCHUNGSART_WARENEINGANG,
|
||||||
|
) {
|
||||||
await new sql.Request(transaction)
|
await new sql.Request(transaction)
|
||||||
.input('kArtikel', sql.Int, kArtikel)
|
.input('kArtikel', sql.Int, kArtikel)
|
||||||
.input('kWarenLagerPlatz', sql.Int, kWarenLagerPlatz)
|
.input('kWarenLagerPlatz', sql.Int, kWarenLagerPlatz)
|
||||||
.input('kBenutzer', sql.Int, kBenutzer)
|
.input('kBenutzer', sql.Int, kBenutzer)
|
||||||
.input('fAnzahl', sql.Float, fehlmenge)
|
.input('fAnzahl', sql.Float, fehlmenge)
|
||||||
.input('cKommentar', sql.NVarChar, POS_SHORTAGE_COMMENT)
|
.input('cKommentar', sql.NVarChar, comment)
|
||||||
.input('kBuchungsart', sql.Int, BUCHUNGSART_WARENEINGANG)
|
.input('kBuchungsart', sql.Int, buchungsart)
|
||||||
.query(`
|
.query(`
|
||||||
DECLARE @kWarenlagerEingang INT;
|
DECLARE @kWarenlagerEingang INT;
|
||||||
EXEC dbo.spWarenlagerEingangSchreiben
|
EXEC dbo.spWarenlagerEingangSchreiben
|
||||||
@@ -69,6 +77,37 @@ async function bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtik
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bookReturnPositions(
|
||||||
|
transaction,
|
||||||
|
kBenutzer,
|
||||||
|
kWarenLagerPlatz,
|
||||||
|
returnPositions,
|
||||||
|
) {
|
||||||
|
logger.info(`returnBooking: processing ${returnPositions.length} return position(s) at kWarenLagerPlatz=${kWarenLagerPlatz}`);
|
||||||
|
for (const { kAuftragPosition, quantity } of returnPositions) {
|
||||||
|
if (!kAuftragPosition || quantity >= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const returnQty = Math.abs(quantity);
|
||||||
|
const kArtikel = await getPositionArtikel(transaction, kAuftragPosition);
|
||||||
|
logger.info(`returnBooking: kBestellPos=${kAuftragPosition} kArtikel=${kArtikel} returnQty=${returnQty}`);
|
||||||
|
if (!kArtikel) {
|
||||||
|
logger.info(`returnBooking: kBestellPos=${kAuftragPosition} no kArtikel (free position / Pfand?), skip return booking`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await bookWareneingang(
|
||||||
|
transaction,
|
||||||
|
kBenutzer,
|
||||||
|
kWarenLagerPlatz,
|
||||||
|
kArtikel,
|
||||||
|
returnQty,
|
||||||
|
'Korrekturbuchung erstellt durch POS-Abgleich (Retoure)',
|
||||||
|
BUCHUNGSART_WARENEINGANG,
|
||||||
|
);
|
||||||
|
logger.info(`returnBooking: successfully booked return of ${returnQty}x kArtikel=${kArtikel} into kWarenLagerPlatz=${kWarenLagerPlatz}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PosStockPositionService.FehlbestandEinbuchen — book missing stock, then re-reserve.
|
* PosStockPositionService.FehlbestandEinbuchen — book missing stock, then re-reserve.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,23 +4,30 @@ import { getDemoProductCount } from '../demo/store.js';
|
|||||||
import { getPool } from '../db.js';
|
import { getPool } from '../db.js';
|
||||||
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
|
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
|
||||||
|
|
||||||
|
const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1;
|
||||||
|
|
||||||
const PRODUCT_COUNT_SQL = `
|
const PRODUCT_COUNT_SQL = `
|
||||||
${CATEGORY_TREE_CTE}
|
${CATEGORY_TREE_CTE}
|
||||||
SELECT COUNT(DISTINCT a.kArtikel) AS ProductCount
|
SELECT COUNT(DISTINCT a.kArtikel) AS ProductCount
|
||||||
FROM dbo.tArtikel a
|
FROM dbo.tArtikel a
|
||||||
INNER JOIN dbo.tKategorieArtikel ka ON ka.kArtikel = a.kArtikel
|
INNER JOIN dbo.tKategorieArtikel ka ON ka.kArtikel = a.kArtikel
|
||||||
|
LEFT JOIN dbo.tlagerbestand lb ON lb.kArtikel = a.kArtikel
|
||||||
|
LEFT JOIN dbo.tArtikelBeschreibung ab ON ab.kArtikel = a.kArtikel AND ab.kSprache = @languageId
|
||||||
|
CROSS APPLY (
|
||||||
|
SELECT MAX(v) AS lastChanged
|
||||||
|
FROM (VALUES
|
||||||
|
(CONVERT(BIGINT, a.bRowversion)),
|
||||||
|
(CONVERT(BIGINT, lb.bRowversion)),
|
||||||
|
(CONVERT(BIGINT, ab.bRowversion)),
|
||||||
|
((SELECT MAX(CONVERT(BIGINT, abp.bRowversion)) FROM dbo.tArtikelbildPlattform abp WHERE abp.kArtikel = a.kArtikel AND (@kShop = 0 OR abp.kShop = @kShop))),
|
||||||
|
((SELECT MAX(CONVERT(BIGINT, p.bRowversion)) FROM dbo.tPreis p WHERE p.kArtikel = a.kArtikel AND (@kShop = 0 OR p.kShop = @kShop))),
|
||||||
|
((SELECT MAX(CONVERT(BIGINT, sp.bRowversion)) FROM dbo.tArtikelSonderpreis sp WHERE sp.kArtikel = a.kArtikel))
|
||||||
|
) AS t(v)
|
||||||
|
) rv
|
||||||
WHERE a.cAktiv = 'Y'
|
WHERE a.cAktiv = 'Y'
|
||||||
AND ka.kKategorie IN (SELECT kKategorie FROM CategoryTree)
|
AND ka.kKategorie IN (SELECT kKategorie FROM CategoryTree)
|
||||||
AND (@kShop = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks WHERE ks.kKategorie = ka.kKategorie AND ks.kShop = @kShop))
|
AND (@kShop = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks WHERE ks.kKategorie = ka.kKategorie AND ks.kShop = @kShop))
|
||||||
AND (
|
AND rv.lastChanged > @cursor;
|
||||||
CONVERT(BIGINT, a.bRowversion) > @cursor
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM dbo.tArtikelbildPlattform abp
|
|
||||||
WHERE abp.kArtikel = a.kArtikel
|
|
||||||
AND abp.kShop = @kShop
|
|
||||||
AND CONVERT(BIGINT, abp.bRowversion) > @cursor
|
|
||||||
)
|
|
||||||
);
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getProductCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
|
export async function getProductCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
|
||||||
@@ -30,6 +37,7 @@ export async function getProductCount({ cursor = 0, rootCategoryId = getRootCate
|
|||||||
|
|
||||||
const result = await categoryTreeRequest(getPool(), rootCategoryId)
|
const result = await categoryTreeRequest(getPool(), rootCategoryId)
|
||||||
.input('cursor', sql.BigInt, cursor)
|
.input('cursor', sql.BigInt, cursor)
|
||||||
|
.input('languageId', sql.Int, LANGUAGE_ID)
|
||||||
.query(PRODUCT_COUNT_SQL);
|
.query(PRODUCT_COUNT_SQL);
|
||||||
return result.recordset[0]?.ProductCount ?? 0;
|
return result.recordset[0]?.ProductCount ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,13 @@ import { getActiveShopId } from '../shop.js';
|
|||||||
|
|
||||||
const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1;
|
const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1;
|
||||||
const TAX_ZONE_NAME = process.env.TAX_ZONE_NAME || 'Zone-EU';
|
const TAX_ZONE_NAME = process.env.TAX_ZONE_NAME || 'Zone-EU';
|
||||||
|
const WARENLAGER_ID = Number(process.env.WARENLAGER_ID) || Number(process.env.JTL_KWARENLAGER) || 1;
|
||||||
|
|
||||||
const PRODUCT_LIST_SQL = `
|
const PRODUCT_LIST_SQL = `
|
||||||
WITH TaxRates AS (
|
WITH TaxRates AS (
|
||||||
SELECT kSteuerklasse, fSteuersatz
|
SELECT kSteuerklasse, fSteuersatz
|
||||||
FROM dbo.tSteuersatz
|
FROM dbo.tSteuersatz
|
||||||
WHERE kSteuerzone IN (SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = @taxZoneName)
|
WHERE kSteuerzone IN (SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = @taxZoneName)
|
||||||
),
|
|
||||||
ImageRV AS (
|
|
||||||
SELECT kArtikel, MAX(CONVERT(BIGINT, bRowversion)) AS maxImageRV
|
|
||||||
FROM dbo.tArtikelbildPlattform
|
|
||||||
WHERE kShop = @kShop
|
|
||||||
GROUP BY kArtikel
|
|
||||||
)
|
)
|
||||||
SELECT TOP (@limit)
|
SELECT TOP (@limit)
|
||||||
a.kArtikel AS id,
|
a.kArtikel AS id,
|
||||||
@@ -28,11 +23,7 @@ SELECT TOP (@limit)
|
|||||||
a.fVKNetto AS netPrice,
|
a.fVKNetto AS netPrice,
|
||||||
tr.fSteuersatz AS taxRate,
|
tr.fSteuersatz AS taxRate,
|
||||||
a.dErstelldatum AS createdAt,
|
a.dErstelldatum AS createdAt,
|
||||||
CASE
|
rv.lastChanged,
|
||||||
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
|
SELECT TOP 1 img.cHash
|
||||||
FROM dbo.tArtikelbildPlattform abp
|
FROM dbo.tArtikelbildPlattform abp
|
||||||
@@ -45,6 +36,21 @@ SELECT TOP (@limit)
|
|||||||
FROM dbo.tkategorieartikel ka
|
FROM dbo.tkategorieartikel ka
|
||||||
WHERE ka.kArtikel = a.kArtikel
|
WHERE ka.kArtikel = a.kArtikel
|
||||||
) AS categoryIds,
|
) AS categoryIds,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM dbo.tKategorieArtikel ka2
|
||||||
|
INNER JOIN dbo.tArtikel a2 ON a2.kArtikel = ka2.kArtikel AND a2.cAktiv = 'Y'
|
||||||
|
INNER JOIN dbo.tArtikelBeschreibung ab2 ON ab2.kArtikel = a2.kArtikel AND ab2.kSprache = @languageId
|
||||||
|
WHERE ka2.kKategorie = (
|
||||||
|
SELECT TOP 1 ka1.kKategorie
|
||||||
|
FROM dbo.tKategorieArtikel ka1
|
||||||
|
WHERE ka1.kArtikel = a.kArtikel
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
ab2.cName < ab.cName
|
||||||
|
OR (ab2.cName = ab.cName AND a2.kArtikel < a.kArtikel)
|
||||||
|
)
|
||||||
|
) AS sort,
|
||||||
a.nIstVater AS isParent,
|
a.nIstVater AS isParent,
|
||||||
a.kVaterArtikel AS parentArticleId,
|
a.kVaterArtikel AS parentArticleId,
|
||||||
CASE WHEN a.kStueckliste <> 0 THEN '1' ELSE '0' END AS isCompositeProduct,
|
CASE WHEN a.kStueckliste <> 0 THEN '1' ELSE '0' END AS isCompositeProduct,
|
||||||
@@ -53,22 +59,33 @@ SELECT TOP (@limit)
|
|||||||
FROM Pos.vProductVariant pv
|
FROM Pos.vProductVariant pv
|
||||||
WHERE pv.kProduct = a.kArtikel
|
WHERE pv.kProduct = a.kArtikel
|
||||||
) AS variantName,
|
) AS variantName,
|
||||||
a.cBarcode AS barcode
|
a.cBarcode AS barcode,
|
||||||
|
a.cLagerAktiv,
|
||||||
|
ISNULL(v.fBestand, 0) AS fBestand
|
||||||
FROM dbo.tArtikel a
|
FROM dbo.tArtikel a
|
||||||
INNER JOIN dbo.tArtikelBeschreibung ab ON ab.kArtikel = a.kArtikel AND ab.kSprache = @languageId
|
INNER JOIN dbo.tArtikelBeschreibung ab ON ab.kArtikel = a.kArtikel AND ab.kSprache = @languageId
|
||||||
|
LEFT JOIN dbo.tlagerbestand lb ON lb.kArtikel = a.kArtikel
|
||||||
LEFT JOIN TaxRates tr ON tr.kSteuerklasse = a.kSteuerklasse
|
LEFT JOIN TaxRates tr ON tr.kSteuerklasse = a.kSteuerklasse
|
||||||
LEFT JOIN ImageRV ir ON ir.kArtikel = a.kArtikel
|
LEFT JOIN dbo.vLagerbestandProLager v ON v.kArtikel = a.kArtikel AND v.kWarenlager = @warenlagerId
|
||||||
|
CROSS APPLY (
|
||||||
|
SELECT MAX(val) AS lastChanged
|
||||||
|
FROM (VALUES
|
||||||
|
(CONVERT(BIGINT, a.bRowversion)),
|
||||||
|
(CONVERT(BIGINT, lb.bRowversion)),
|
||||||
|
(CONVERT(BIGINT, ab.bRowversion)),
|
||||||
|
((SELECT MAX(CONVERT(BIGINT, abp.bRowversion)) FROM dbo.tArtikelbildPlattform abp WHERE abp.kArtikel = a.kArtikel AND (@kShop = 0 OR abp.kShop = @kShop))),
|
||||||
|
((SELECT MAX(CONVERT(BIGINT, p.bRowversion)) FROM dbo.tPreis p WHERE p.kArtikel = a.kArtikel AND (@kShop = 0 OR p.kShop = @kShop))),
|
||||||
|
((SELECT MAX(CONVERT(BIGINT, sp.bRowversion)) FROM dbo.tArtikelSonderpreis sp WHERE sp.kArtikel = a.kArtikel))
|
||||||
|
) AS t(val)
|
||||||
|
) rv
|
||||||
WHERE a.cAktiv = 'Y'
|
WHERE a.cAktiv = 'Y'
|
||||||
AND (@kShop = 0 OR EXISTS (
|
AND (@kShop = 0 OR EXISTS (
|
||||||
SELECT 1 FROM dbo.tKategorieArtikel ka
|
SELECT 1 FROM dbo.tKategorieArtikel ka
|
||||||
INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie AND ks.kShop = @kShop
|
INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie AND ks.kShop = @kShop
|
||||||
WHERE ka.kArtikel = a.kArtikel
|
WHERE ka.kArtikel = a.kArtikel
|
||||||
))
|
))
|
||||||
AND (
|
AND rv.lastChanged > @cursor
|
||||||
CONVERT(BIGINT, a.bRowversion) > @cursor
|
ORDER BY rv.lastChanged ASC;
|
||||||
OR (ir.maxImageRV IS NOT NULL AND ir.maxImageRV > @cursor)
|
|
||||||
)
|
|
||||||
ORDER BY lastChanged ASC;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function priceOverridesSql(articleIds) {
|
function priceOverridesSql(articleIds) {
|
||||||
@@ -82,6 +99,26 @@ function priceOverridesSql(articleIds) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the first image hash (ordered by nNr) per article, matching the main
|
||||||
|
// query's imgHash selection. Used to resolve the parent's image for products
|
||||||
|
// without a picture of their own.
|
||||||
|
function imageHashesSql(articleIds) {
|
||||||
|
const idList = articleIds.join(',');
|
||||||
|
return `
|
||||||
|
SELECT articleId, imgHash
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
abp.kArtikel AS articleId,
|
||||||
|
img.cHash AS imgHash,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY abp.kArtikel ORDER BY abp.nNr) AS rn
|
||||||
|
FROM dbo.tArtikelbildPlattform abp
|
||||||
|
INNER JOIN dbo.tBild img ON img.kBild = abp.kBild
|
||||||
|
WHERE abp.kArtikel IN (${idList})
|
||||||
|
) t
|
||||||
|
WHERE rn = 1 AND imgHash IS NOT NULL AND imgHash <> '';
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(date) {
|
function formatDateTime(date) {
|
||||||
if (!date) {
|
if (!date) {
|
||||||
return '0001-01-01 00:00:00';
|
return '0001-01-01 00:00:00';
|
||||||
@@ -109,6 +146,7 @@ export async function getProductList({ cursor = 0, limit = 20 } = {}) {
|
|||||||
.input('languageId', sql.Int, LANGUAGE_ID)
|
.input('languageId', sql.Int, LANGUAGE_ID)
|
||||||
.input('taxZoneName', sql.NVarChar, TAX_ZONE_NAME)
|
.input('taxZoneName', sql.NVarChar, TAX_ZONE_NAME)
|
||||||
.input('kShop', sql.Int, getActiveShopId())
|
.input('kShop', sql.Int, getActiveShopId())
|
||||||
|
.input('warenlagerId', sql.Int, WARENLAGER_ID)
|
||||||
.query(PRODUCT_LIST_SQL),
|
.query(PRODUCT_LIST_SQL),
|
||||||
getCustomerGroupIds(),
|
getCustomerGroupIds(),
|
||||||
]);
|
]);
|
||||||
@@ -118,10 +156,20 @@ export async function getProductList({ cursor = 0, limit = 20 } = {}) {
|
|||||||
|
|
||||||
const overridesByArticle = new Map();
|
const overridesByArticle = new Map();
|
||||||
let attributesByArticle = new Map();
|
let attributesByArticle = new Map();
|
||||||
|
let parentImageHashes = new Map();
|
||||||
if (articleIds.length > 0) {
|
if (articleIds.length > 0) {
|
||||||
const [overrideResult, attributeMap] = await Promise.all([
|
// Include any parent articles referenced by products in this batch so we
|
||||||
|
// can resolve the parent image for products without a picture of their own,
|
||||||
|
// even when the parent itself is not part of the batch.
|
||||||
|
const parentIds = products
|
||||||
|
.map((p) => (p.parentArticleId > 0 && !p.imgHash ? Number(p.parentArticleId) : null))
|
||||||
|
.filter((id) => id !== null && !articleIds.includes(id));
|
||||||
|
const hashIds = [...new Set([...articleIds, ...parentIds])];
|
||||||
|
|
||||||
|
const [overrideResult, attributeMap, imageHashResult] = await Promise.all([
|
||||||
pool.request().query(priceOverridesSql(articleIds)),
|
pool.request().query(priceOverridesSql(articleIds)),
|
||||||
getProductAttributes(pool, articleIds),
|
getProductAttributes(pool, articleIds),
|
||||||
|
pool.request().query(imageHashesSql(hashIds)),
|
||||||
]);
|
]);
|
||||||
attributesByArticle = attributeMap;
|
attributesByArticle = attributeMap;
|
||||||
for (const row of overrideResult.recordset) {
|
for (const row of overrideResult.recordset) {
|
||||||
@@ -130,6 +178,9 @@ export async function getProductList({ cursor = 0, limit = 20 } = {}) {
|
|||||||
}
|
}
|
||||||
overridesByArticle.get(row.articleId).set(row.customerGroupId, row.netPrice);
|
overridesByArticle.get(row.articleId).set(row.customerGroupId, row.netPrice);
|
||||||
}
|
}
|
||||||
|
for (const row of imageHashResult.recordset) {
|
||||||
|
parentImageHashes.set(row.articleId, row.imgHash);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return products.map((product) => {
|
return products.map((product) => {
|
||||||
@@ -151,10 +202,16 @@ export async function getProductList({ cursor = 0, limit = 20 } = {}) {
|
|||||||
const categoryIds = product.categoryIds ? product.categoryIds.split(',') : [];
|
const categoryIds = product.categoryIds ? product.categoryIds.split(',') : [];
|
||||||
const articleAttributes = attributesByArticle.get(product.id);
|
const articleAttributes = attributesByArticle.get(product.id);
|
||||||
|
|
||||||
|
// If a product has a parent and no picture of its own, fall back to the
|
||||||
|
// parent's image hash.
|
||||||
|
const hasOwnImage = !!product.imgHash;
|
||||||
|
const parentHash = !hasOwnImage ? parentImageHashes.get(product.parentArticleId) : null;
|
||||||
|
const imageHash = hasOwnImage ? product.imgHash : parentHash;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_id: String(product.id),
|
_id: String(product.id),
|
||||||
imghash: product.imgHash ?? null,
|
imghash: imageHash ?? null,
|
||||||
imgsrc: product.imgHash ?? null,
|
imgsrc: imageHash ?? null,
|
||||||
sku: product.sku,
|
sku: product.sku,
|
||||||
barcode: product.barcode ?? null,
|
barcode: product.barcode ?? null,
|
||||||
name: product.name,
|
name: product.name,
|
||||||
@@ -162,6 +219,7 @@ export async function getProductList({ cursor = 0, limit = 20 } = {}) {
|
|||||||
price: basePrice,
|
price: basePrice,
|
||||||
created_at: formatDateTime(product.createdAt),
|
created_at: formatDateTime(product.createdAt),
|
||||||
lastChanged: String(product.lastChanged),
|
lastChanged: String(product.lastChanged),
|
||||||
|
sort: String(product.sort ?? 0),
|
||||||
categories_id: categoryIds[0] ?? '0',
|
categories_id: categoryIds[0] ?? '0',
|
||||||
categories: categoryIds.map((categoryId) => ({ categoryId })),
|
categories: categoryIds.map((categoryId) => ({ categoryId })),
|
||||||
prices,
|
prices,
|
||||||
@@ -170,6 +228,8 @@ export async function getProductList({ cursor = 0, limit = 20 } = {}) {
|
|||||||
variants: product.variantName ?? '',
|
variants: product.variantName ?? '',
|
||||||
isCompositeProduct: product.isCompositeProduct,
|
isCompositeProduct: product.isCompositeProduct,
|
||||||
attributes: articleAttributes?.attributes ?? [],
|
attributes: articleAttributes?.attributes ?? [],
|
||||||
|
use_stock: product.cLagerAktiv === 'Y' || product.cLagerAktiv === '1' ? '1' : '0',
|
||||||
|
quantity: String(product.fBestand ?? 0),
|
||||||
...(articleAttributes?.deposit ?? {}),
|
...(articleAttributes?.deposit ?? {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
15
src/shop.js
15
src/shop.js
@@ -2,6 +2,7 @@ import sql from 'mssql';
|
|||||||
|
|
||||||
let activeShopId = 0;
|
let activeShopId = 0;
|
||||||
let activeShopSubshopId = 0;
|
let activeShopSubshopId = 0;
|
||||||
|
let activeMaxLastChanged = 0;
|
||||||
|
|
||||||
export function setActiveShop(id) {
|
export function setActiveShop(id) {
|
||||||
activeShopId = id;
|
activeShopId = id;
|
||||||
@@ -19,12 +20,24 @@ export function getActiveShopSubshopId() {
|
|||||||
return activeShopSubshopId;
|
return activeShopSubshopId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setActiveMaxLastChanged(value) {
|
||||||
|
activeMaxLastChanged = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveMaxLastChanged() {
|
||||||
|
return activeMaxLastChanged;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchActiveShop(pool) {
|
export async function fetchActiveShop(pool) {
|
||||||
const result = await pool.request().query(`
|
const result = await pool.request().query(`
|
||||||
SELECT TOP 1 kShop, kShopSubshop FROM dbo.tShopSubshop WHERE nGesperrt = 0 ORDER BY kShop
|
SELECT TOP 1 kShop, kShopSubshop, nMaxLastChanged
|
||||||
|
FROM dbo.tShopSubshop
|
||||||
|
WHERE nGesperrt = 0
|
||||||
|
ORDER BY kShop
|
||||||
`);
|
`);
|
||||||
const id = result.recordset[0]?.kShop ?? 0;
|
const id = result.recordset[0]?.kShop ?? 0;
|
||||||
setActiveShop(id);
|
setActiveShop(id);
|
||||||
setActiveShopSubshop(result.recordset[0]?.kShopSubshop ?? 0);
|
setActiveShopSubshop(result.recordset[0]?.kShopSubshop ?? 0);
|
||||||
|
setActiveMaxLastChanged(result.recordset[0]?.nMaxLastChanged ?? 0);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
19
todo.md
Normal file
19
todo.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- when search is set (matches order number / note / invoice number)
|
||||||
|
SELECT TOP(@limit) *
|
||||||
|
FROM Pos.vOrder
|
||||||
|
WITH (READUNCOMMITTED)
|
||||||
|
WHERE vOrder.cOrderNumber LIKE @filter
|
||||||
|
OR vOrder.cNote LIKE @filter
|
||||||
|
OR vOrder.cInvoiceNumber LIKE @filter;
|
||||||
|
-- @limit = 1000, @filter = '%xyz%'
|
||||||
|
|
||||||
|
-- when searchCustomer is set
|
||||||
|
SELECT TOP(@limit) *
|
||||||
|
FROM Pos.vOrder
|
||||||
|
WITH (READUNCOMMITTED)
|
||||||
|
WHERE vOrder.cCustomerNumber LIKE @filterCustomer
|
||||||
|
OR vOrder.cShippingAddressName LIKE @filterCustomer
|
||||||
|
OR vOrder.cBillingAddressName LIKE @filterCustomer
|
||||||
|
OR vOrder.cShippingAddressCompany LIKE @filterCustomer
|
||||||
|
OR vOrder.cBillingAddressCompany LIKE @filterCustomer;
|
||||||
|
-- @filterCustomer = '%abc%'
|
||||||
Reference in New Issue
Block a user