Compare commits

..

7 Commits

Author SHA1 Message Date
seb
fe0d2e43f4 u 2026-08-23 04:12:14 +02:00
seb
5caedbe345 u 2026-08-23 03:53:08 +02:00
seb
73d256e8a6 prouct pic delete sync fix 2026-08-23 01:56:03 +02:00
seb
ccf7854e6c u 2026-08-22 05:40:06 +02:00
sebseb7
390d66890b u 2026-08-18 00:55:12 +00:00
seb
c42a14a437 u 2026-08-15 04:11:54 +02:00
seb
e1a009ba5c u 2026-08-15 03:04:14 +02:00
30 changed files with 1222 additions and 79 deletions

View File

@@ -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
View File

@@ -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 percustomer-group net overrides from `tPreis` / `tPreisDetail`. C++ currently fills every group with the base gross price. **Node only:** `prices[]` applies percustomer-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
View 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.

View File

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

View File

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

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

View File

@@ -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"},

View File

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

View File

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

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

View File

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

View File

@@ -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();

View File

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

View File

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

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

View File

@@ -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,
];

View File

@@ -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',

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

View File

@@ -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',

View File

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

View File

@@ -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() } = {}) {

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

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? {}),
}; };
}); });

View File

@@ -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
View 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%'