diff --git a/API.md b/API.md
index cc8e3e1..9f963e3 100644
--- a/API.md
+++ b/API.md
@@ -134,6 +134,7 @@ Returns how many entities changed since each cursor. Poll this; only fetch list
|---|---|---|
| `lastChangedProduct` | `0` | Products |
| `lastChangedCategory` | `0` | Categories |
+| `lastChangedCustomer` | `0` | Customers |
| `lastChangedCustomerGroup` | `0` | Customer groups |
| `lastChangedCompositeProduct` | `0` | Composite (Stückliste) products |
| `lastChangedDeletedEntity` | `0` | Deleted entities |
@@ -293,6 +294,53 @@ No `limit` — returns all groups newer than the cursor.
]
```
+### `GET /v1/customer`
+
+Customer deltas. Only customers of customer group `1` are returned, 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 <= nMaxLastChanged` of the active subshop are returned.
+
+| 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`
| Param | Default |
@@ -488,6 +536,7 @@ Invalid JSON body → `500` with `[]`.
| `GET` | `/v1/product` | Product deltas |
| `GET` | `/v1/productcomposite` | Composite BOM deltas |
| `GET` | `/v1/customergroup` | Customer group deltas |
+| `GET` | `/v1/customer` | Customer deltas |
| `GET` | `/v1/deletedentity` | Deletion deltas |
| `GET` | `/v1/pimage` | Product image by hash |
| `GET` | `/v1/cimage` | Category image by hash |
diff --git a/jtlsrv-cpp/Makefile b/jtlsrv-cpp/Makefile
index 7de6f82..436ad0f 100644
--- a/jtlsrv-cpp/Makefile
+++ b/jtlsrv-cpp/Makefile
@@ -32,6 +32,7 @@ CPP_SRCS := \
src/endpoints/productcomposite.cpp \
src/endpoints/deleted_entity.cpp \
src/endpoints/customergroup.cpp \
+ src/endpoints/customer.cpp \
src/endpoints/order.cpp \
src/endpoints/pimage.cpp \
src/endpoints/cimage.cpp \
diff --git a/jtlsrv-cpp/README.md b/jtlsrv-cpp/README.md
index 5f2339f..8445c1d 100644
--- a/jtlsrv-cpp/README.md
+++ b/jtlsrv-cpp/README.md
@@ -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/deletedentity` | Deleted entity sync |
| GET | `/v1/customergroup` | Customer group sync |
+| GET | `/v1/customer` | Customer sync |
| POST | `/v1/order` | Submit orders |
| GET | `/v1/pimage` | Product image (resized) |
| GET | `/v1/cimage` | Category image (resized) |
diff --git a/jtlsrv-cpp/src/endpoints/customer.cpp b/jtlsrv-cpp/src/endpoints/customer.cpp
new file mode 100644
index 0000000..e9b68fc
--- /dev/null
+++ b/jtlsrv-cpp/src/endpoints/customer.cpp
@@ -0,0 +1,16 @@
+#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();
+ int64_t max_last_changed = get_active_max_last_changed();
+
+ auto customers = get_customer_list(cursor, limit, shop, subshop, max_last_changed);
+ resp.send_json(200, customers);
+}
diff --git a/jtlsrv-cpp/src/endpoints/init.cpp b/jtlsrv-cpp/src/endpoints/init.cpp
index fac7f69..b926764 100644
--- a/jtlsrv-cpp/src/endpoints/init.cpp
+++ b/jtlsrv-cpp/src/endpoints/init.cpp
@@ -10,23 +10,26 @@
void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t product_cursor = req.get_query_int64("lastChangedProduct");
int64_t category_cursor = req.get_query_int64("lastChangedCategory");
+ int64_t customer_cursor = req.get_query_int64("lastChangedCustomer");
int64_t cg_cursor = req.get_query_int64("lastChangedCustomerGroup");
int64_t composite_cursor = req.get_query_int64("lastChangedCompositeProduct");
int64_t deleted_cursor = req.get_query_int64("lastChangedDeletedEntity");
int root = config::get_int("ROOT_CATEGORY_ID", 1);
int shop = get_active_shop_id();
+ 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;
if (get_pool().execute_scalar("SELECT 1") != 0) {
product_count = get_product_count(root, shop, product_cursor);
category_count = get_category_count(root, shop, category_cursor);
+ customer_count = get_customer_count(shop, subshop, customer_cursor);
cg_count = get_customer_group_count(cg_cursor);
composite_count = get_composite_count(shop, composite_cursor);
deleted_count = get_deleted_count(deleted_cursor);
- max_order_id_count = get_max_order_id_count(get_active_shop_subshop_id());
+ max_order_id_count = get_max_order_id_count(subshop);
if (product_cursor == 0 && category_cursor == 0 &&
product_count == 0 && category_count == 0 && deleted_count == 0) {
logc::warn("init: all counts zero with cursors at 0 — check DB connectivity and shop/category config");
@@ -39,7 +42,7 @@ void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
{"version", "1.10.12.0"},
{"product_count", std::to_string(product_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)},
{"compositeProduct_count", std::to_string(composite_count)},
{"configurationGroup_count", "0"},
diff --git a/jtlsrv-cpp/src/main.cpp b/jtlsrv-cpp/src/main.cpp
index cb6b4ff..ab07eae 100644
--- a/jtlsrv-cpp/src/main.cpp
+++ b/jtlsrv-cpp/src/main.cpp
@@ -107,6 +107,7 @@ extern void handle_product(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_productcomposite(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_deleted_entity(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_customergroup(HttpRequest&, HttpResponse&, RouteContext&);
+extern void handle_customer(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_order(HttpRequest&, HttpResponse&, RouteContext&);
extern void handle_pimage(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/deletedentity", handle_deleted_entity);
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("GET", "/v1/pimage", handle_pimage);
router.add_route("GET", "/v1/cimage", handle_cimage);
diff --git a/jtlsrv-cpp/src/queries/counts.hpp b/jtlsrv-cpp/src/queries/counts.hpp
index b28a85d..1cf3587 100644
--- a/jtlsrv-cpp/src/queries/counts.hpp
+++ b/jtlsrv-cpp/src/queries/counts.hpp
@@ -39,6 +39,15 @@ static const char* COMPOSITE_PRODUCT_COUNT_SQL =
"AND ks.kShop = ? WHERE ka.kArtikel = a.kArtikel)) "
"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 k.kKundenGruppe = 1 "
+ "AND (? = 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 =
"SELECT COUNT(*) AS cnt FROM Pos.vDeletedEntity "
"WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > ?";
@@ -63,6 +72,12 @@ inline int64_t get_composite_count(int k_shop, int64_t cursor) {
{{ParamType::Int,"",k_shop},{ParamType::Int,"",k_shop},
{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) {
return get_pool().execute_scalar(DELETED_ENTITY_COUNT_SQL,
{{ParamType::BigInt,"",cursor}});
diff --git a/jtlsrv-cpp/src/queries/customer_list.hpp b/jtlsrv-cpp/src/queries/customer_list.hpp
new file mode 100644
index 0000000..03f891b
--- /dev/null
+++ b/jtlsrv-cpp/src/queries/customer_list.hpp
@@ -0,0 +1,101 @@
+#pragma once
+#include "../db/pool.hpp"
+#include "nlohmann/json.hpp"
+#include "shop.hpp"
+
+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) > ? "
+ " AND CONVERT(BIGINT, vCustomer.bLastChanged) <= ? "
+ " AND vCustomer.kCustomerGroupId = 1 "
+ "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, int64_t max_last_changed) {
+ std::vector ps = {
+ {ParamType::Int, "", limit},
+ {ParamType::Int, "", subshop},
+ {ParamType::Int, "", shop},
+ {ParamType::BigInt, "", cursor},
+ {ParamType::BigInt, "", max_last_changed},
+ };
+ 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;
+}
diff --git a/jtlsrv-cpp/src/queries/shop.cpp b/jtlsrv-cpp/src/queries/shop.cpp
index 15668fb..c55cda6 100644
--- a/jtlsrv-cpp/src/queries/shop.cpp
+++ b/jtlsrv-cpp/src/queries/shop.cpp
@@ -1,9 +1,11 @@
#include "shop.hpp"
#include "../log.hpp"
+#include "../http.hpp"
#include
int g_active_shop_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) {
if (cell.type == CellType::Int64) return static_cast(cell.i64);
@@ -11,10 +13,16 @@ static int cell_to_int(const Cell& cell) {
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() {
ResultSet rs;
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",
rs) || rs.empty()) {
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_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;
}
diff --git a/jtlsrv-cpp/src/queries/shop.hpp b/jtlsrv-cpp/src/queries/shop.hpp
index b2cfa6d..e9aeb48 100644
--- a/jtlsrv-cpp/src/queries/shop.hpp
+++ b/jtlsrv-cpp/src/queries/shop.hpp
@@ -3,8 +3,10 @@
extern int g_active_shop_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_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();
diff --git a/scripts/generate-demo-catalog.mjs b/scripts/generate-demo-catalog.mjs
index 470a768..f950c1a 100644
--- a/scripts/generate-demo-catalog.mjs
+++ b/scripts/generate-demo-catalog.mjs
@@ -651,6 +651,93 @@ function buildProducts(categories, customerGroupIds) {
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() {
console.log('Generating demo catalog…');
fs.mkdirSync(IMAGES_DIR, { recursive: true });
@@ -685,6 +772,7 @@ async function main() {
categories,
customerGroups.map((g) => Number(g.customerGroupId))
);
+ const customers = buildCustomers(customerGroups.map((g) => Number(g.customerGroupId)));
const perCategory = new Map();
for (const product of products) {
perCategory.set(product.categories_id, (perCategory.get(product.categories_id) || 0) + 1);
@@ -759,6 +847,7 @@ async function main() {
version: 1,
generatedAt: new Date().toISOString(),
customerGroups,
+ customers,
categories,
products,
composites,
diff --git a/src/demo/store.js b/src/demo/store.js
index a901dcd..81cb8b2 100644
--- a/src/demo/store.js
+++ b/src/demo/store.js
@@ -47,6 +47,7 @@ export async function loadDemoCatalog() {
categories: catalog.categories.length,
products: catalog.products.length,
customerGroups: catalog.customerGroups.length,
+ customers: (catalog.customers ?? []).length,
composites: catalog.composites.length,
imagesDir: IMAGES_DIR,
};
@@ -90,6 +91,15 @@ export function getDemoCustomerGroupCount({ cursor = 0 } = {}) {
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 } = {}) {
return afterCursor(ensureLoaded().composites, cursor).slice(0, limit);
}
diff --git a/src/endpoints/customer.js b/src/endpoints/customer.js
new file mode 100644
index 0000000..8aa3703
--- /dev/null
+++ b/src/endpoints/customer.js
@@ -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);
+}
diff --git a/src/endpoints/index.js b/src/endpoints/index.js
index cded7c8..a5a9e04 100644
--- a/src/endpoints/index.js
+++ b/src/endpoints/index.js
@@ -1,13 +1,15 @@
import * as category from './category.js';
import * as cimage from './cimage.js';
import * as client from './client.js';
+import * as customer from './customer.js';
import * as customergroup from './customergroup.js';
import * as deletedEntity from './deleted-entity.js';
import * as init from './init.js';
import * as newpin from './newpin.js';
import * as order from './order.js';
+import * as orderSearch from './order-search.js';
import * as pimage from './pimage.js';
import * as product from './product.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];
diff --git a/src/endpoints/init.js b/src/endpoints/init.js
index ae00f34..8b000cf 100644
--- a/src/endpoints/init.js
+++ b/src/endpoints/init.js
@@ -2,6 +2,7 @@ import { sendJson } from '../http.js';
import { getCategoryCount } from '../queries/category-count.js';
import { getMaxOrderIdCount } from '../queries/max-order-id.js';
import { getCompositeProductCount } from '../queries/composite-product-count.js';
+import { getCustomerCount } from '../queries/customer-count.js';
import { getCustomerGroupCount } from '../queries/customer-groups.js';
import { getDeletedEntityCount } from '../queries/deleted-entity-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 }) {
const productCursor = Number(url.searchParams.get('lastChangedProduct')) || 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 compositeProductCursor = Number(url.searchParams.get('lastChangedCompositeProduct')) || 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 }),
getCategoryCount({ cursor: categoryCursor }),
+ getCustomerCount({ cursor: customerCursor }),
getCustomerGroupCount({ cursor: customerGroupCursor }),
getCompositeProductCount({ cursor: compositeProductCursor }),
getDeletedEntityCount({ cursor: deletedEntityCursor }),
@@ -29,7 +32,7 @@ export async function handle(_req, res, { url }) {
version: '1.10.12.0',
product_count: String(productCount),
category_count: String(categoryCount),
- customer_count: '0',
+ customer_count: String(customerCount),
customerGroup_count: String(customerGroupCount),
compositeProduct_count: String(compositeProductCount),
configurationGroup_count: '0',
diff --git a/src/endpoints/order-search.js b/src/endpoints/order-search.js
new file mode 100644
index 0000000..5337ce1
--- /dev/null
+++ b/src/endpoints/order-search.js
@@ -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);
+}
diff --git a/src/http.js b/src/http.js
index 42cf203..ccff0f1 100644
--- a/src/http.js
+++ b/src/http.js
@@ -14,7 +14,7 @@ export function readBody(req) {
}
const CORS_HEADERS = {
- 'Access-Control-Allow-Origin': '*',
+ //'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
diff --git a/src/queries/customer-count.js b/src/queries/customer-count.js
new file mode 100644
index 0000000..6461cd6
--- /dev/null
+++ b/src/queries/customer-count.js
@@ -0,0 +1,35 @@
+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 k.kKundenGruppe = 1
+ AND (@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;
+}
diff --git a/src/queries/customer-list.js b/src/queries/customer-list.js
new file mode 100644
index 0000000..9a048ae
--- /dev/null
+++ b/src/queries/customer-list.js
@@ -0,0 +1,101 @@
+import sql from 'mssql';
+import { isDemoMode } from '../demo/mode.js';
+import { getDemoCustomerList } from '../demo/store.js';
+import { getPool } from '../db.js';
+import { getActiveMaxLastChanged, 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
+ AND CONVERT(BIGINT, vCustomer.bLastChanged) <= @MaxLastChanged
+ AND vCustomer.kCustomerGroupId = 1
+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)
+ .input('MaxLastChanged', sql.BigInt, getActiveMaxLastChanged())
+ .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),
+ }));
+}
diff --git a/src/shop.js b/src/shop.js
index ddbc8d6..fc745ef 100644
--- a/src/shop.js
+++ b/src/shop.js
@@ -2,6 +2,7 @@ import sql from 'mssql';
let activeShopId = 0;
let activeShopSubshopId = 0;
+let activeMaxLastChanged = 0;
export function setActiveShop(id) {
activeShopId = id;
@@ -19,12 +20,24 @@ export function getActiveShopSubshopId() {
return activeShopSubshopId;
}
+export function setActiveMaxLastChanged(value) {
+ activeMaxLastChanged = value;
+}
+
+export function getActiveMaxLastChanged() {
+ return activeMaxLastChanged;
+}
+
export async function fetchActiveShop(pool) {
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;
setActiveShop(id);
setActiveShopSubshop(result.recordset[0]?.kShopSubshop ?? 0);
+ setActiveMaxLastChanged(result.recordset[0]?.nMaxLastChanged ?? 0);
return id;
}
diff --git a/todo.md b/todo.md
new file mode 100644
index 0000000..f9d2d0e
--- /dev/null
+++ b/todo.md
@@ -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%'
\ No newline at end of file