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