From 7bd8df22d1c7c0265dd685f1be2dcbd53ba57876 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 20 Jul 2026 15:49:55 +0200 Subject: [PATCH] pfand for cpp --- API.md | 56 +++- jtlsrv-cpp/src/queries/product_attributes.hpp | 239 ++++++++++++++++++ jtlsrv-cpp/src/queries/product_list.hpp | 32 ++- 3 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 jtlsrv-cpp/src/queries/product_attributes.hpp diff --git a/API.md b/API.md index 396ad55..a12bc43 100644 --- a/API.md +++ b/API.md @@ -2,6 +2,15 @@ HTTPS API that mirrors the JTL-POS ↔ Wawi sync protocol. Use this to build a custom POS client that syncs catalog data and submits orders. +Two servers implement this protocol: + +| Implementation | Entry | Handlers | +|---|---|---| +| **Node.js** | `server.js` | [`src/endpoints/`](src/endpoints/) | +| **C++** | `jtlsrv-cpp/` | [`jtlsrv-cpp/src/endpoints/`](jtlsrv-cpp/src/endpoints/) | + +Clients talk to either over the same paths and shapes. See [Node vs C++](#node-vs-c) for the few behavioral differences. + Base URL: `https://:` (default port `4443`) Paths may be called as `/v1/...` or `/api/v1/...` — both resolve to the same handlers. @@ -193,9 +202,12 @@ Notable fields: | `isCompositeProduct` | `"1"` if the article is a Stückliste parent | | `imghash` | Pass to `/v1/pimage?path=...` | | Deposit fields | Present when JTL-POS Pfand attributes are set | +| `attributes` | Article attributes from `tArtikelAttribut` (incl. Pfand) | Many other product fields are filled with static defaults (`sort`, `use_stock`, `unit`, etc.) for JTL-POS compatibility. +**Node only:** `prices[]` applies per–customer-group net overrides from `tPreis` / `tPreisDetail`. C++ currently fills every group with the base gross price. + ### `GET /v1/productcomposite` BOM rows for composite (Stückliste) parents. @@ -263,9 +275,10 @@ No `limit` — returns all groups newer than the cursor. ### `GET /v1/cimage` — Category image -| Param | Required | -|---|---| -| `path` | yes — image hash from `imghash` / `imgsrc` | +| Param | Required | Notes | +|---|---|---| +| `path` | yes | Image hash from `imghash` / `imgsrc` | +| `size` | no | **C++ only.** Target max dimension in px (default `200`). Use `0` for the full unresized image. Node always serves a ≤200px resize and ignores `size`. | **Success (200):** binary image body (`image/jpeg`, `image/png`, …), resized by the server. @@ -435,4 +448,39 @@ Invalid JSON body → `500` with `[]`. | `GET` | `/v1/cimage` | Category image by hash | | `POST` | `/v1/order` | Create order(s) | -Handlers live in [`src/endpoints/`](src/endpoints/). +Handlers: Node [`src/endpoints/`](src/endpoints/), C++ [`jtlsrv-cpp/src/endpoints/`](jtlsrv-cpp/src/endpoints/). + +--- + +## Node vs C++ + +Both expose the same route table, pairing flow, cursor sync protocol, and order request/response contract. A client written against this document works with either binary. + +### Same (client-visible) + +| Area | Behavior | +|---|---| +| Routes | Identical methods and paths (`/v1/...` and `/api/v1/...`) | +| Pairing | Short `authCode` → discovery; 6-digit → pair / revoke / register | +| Init / list params | Same cursor names and default `limit` values | +| Category / composite / deleted | Same JSON field names and cursor semantics | +| Order | Same `{ "orders": [...] }` body, result array, `OK` / `ERROR`, HTTP 200 vs 500 | +| Auth | Neither validates `authToken` on sync/order after pairing | + +### Differences that affect clients + +| Area | Node | C++ | +|---|---|---| +| **Group prices** | Per–customer-group overrides from `tPreis` | Every group gets the base gross price | +| **Image `size`** | Ignored; always resize ≤200px | Honored (`size`, default `200`; `0` = full) | +| **`discountPercent`** | Fixed 2 decimals (`"0.00"`) | `std::to_string` of the double (e.g. `"0.000000"`) | +| **Init when DB is down** | Handler throws → HTTP 500 | Returns all counts as `"0"` (HTTP 200) | +| **Product `created_at`** | Formatted `YYYY-MM-DD HH:mm:ss` | Whatever ODBC returns for `dErstelldatum` | + +### Safe client strategy + +- Treat optional product fields (per-group price overrides) as best-effort when talking to C++. +- Prefer `price` for the default gross; do not assume `prices[]` differ by group unless you know you are on Node. +- Call images with `?path=` only; omit `size` unless you need C++-specific sizing. +- Parse `discountPercent` as a number, not a fixed-format string. +- On init, treat HTTP 500 and all-zero counts similarly as “nothing to sync / unavailable”. diff --git a/jtlsrv-cpp/src/queries/product_attributes.hpp b/jtlsrv-cpp/src/queries/product_attributes.hpp new file mode 100644 index 0000000..ce050e0 --- /dev/null +++ b/jtlsrv-cpp/src/queries/product_attributes.hpp @@ -0,0 +1,239 @@ +#pragma once +// Port of src/queries/product-attributes.js — article attributes + Pfand deposit fields. + +#include "../db/pool.hpp" +#include "../http.hpp" +#include "../log.hpp" +#include "nlohmann/json.hpp" + +#include +#include +#include +#include +#include +#include + +struct ProductAttributeBundle { + nlohmann::json attributes = nlohmann::json::array(); + // null when no Pfand attributes present; otherwise deposit / deposit_name / d_price + nlohmann::json deposit = nullptr; +}; + +struct AttrRow { + int64_t article_id = 0; + int64_t k_attribut = 0; + int k_shop = 0; + int n_sortierung = 0; + int k_feld_typ = 0; + std::string c_gruppe_name; + std::string c_attribut_id; + std::string c_name; + std::string c_wert_varchar; + bool has_n_wert_int = false; + int n_wert_int = 0; + bool has_f_wert_decimal = false; + double f_wert_decimal = 0.0; +}; + +inline std::string join_int64s(const std::vector& ids) { + if (ids.empty()) return "NULL"; + std::ostringstream oss; + for (size_t i = 0; i < ids.size(); ++i) { + if (i) oss << ','; + oss << ids[i]; + } + return oss.str(); +} + +inline const std::vector& get_pfand_k_attribute_ids() { + static std::vector cached; + static bool loaded = false; + if (loaded) return cached; + + const char* sql = + "SELECT a.kAttribut " + "FROM dbo.tAttribut a " + "INNER JOIN dbo.tAttributSprache s ON s.kAttribut = a.kAttribut AND s.kSprache = 0 " + "WHERE a.cGruppeName = 'JTL-POS' " + "AND a.cAttributId IN ('Pfandartikel', 'Pfandart (Bezeichnung auf Ausdruck)', 'Pfandbetrag')"; + + ResultSet rs; + if (!get_pool().execute(sql, rs)) { + logc::warn("failed to load Pfand attribute metadata"); + return cached; + } + for (auto& row : rs) { + if (!row.empty()) { + cached.push_back(parse_int64(row[0].str, 0)); + } + } + loaded = true; + return cached; +} + +inline const std::vector& get_active_shop_ids_for_attrs() { + static std::vector cached; + static bool loaded = false; + if (loaded) return cached; + + const char* sql = + "WITH ActiveShops AS (" + " SELECT ss.kShop, s.kKategorie AS rootKategorie " + " FROM dbo.tShopSubshop ss " + " INNER JOIN dbo.tShop s ON s.kShop = ss.kShop " + " WHERE ss.nGesperrt = 0" + ") SELECT DISTINCT ash.kShop FROM ActiveShops ash"; + + ResultSet rs; + if (!get_pool().execute(sql, rs)) { + logc::warn("failed to load active shops for attributes"); + return cached; + } + for (auto& row : rs) { + if (!row.empty()) { + cached.push_back(parse_int64(row[0].str, 0)); + } + } + loaded = true; + return cached; +} + +inline nlohmann::json to_pos_attribute(const AttrRow& row) { + return { + {"aname", row.c_name}, + {"aprice", "0.0"}, + {"asort", std::to_string(row.n_sortierung)}, + {"atype", std::to_string(row.k_feld_typ)}, + {"agroup", row.c_gruppe_name}, + }; +} + +inline nlohmann::json derive_deposit_fields(const std::vector& rows) { + const AttrRow* pfand_artikel = nullptr; + const AttrRow* pfand_art = nullptr; + const AttrRow* pfand_betrag = nullptr; + + for (const auto& row : rows) { + if (row.c_attribut_id == "Pfandartikel") pfand_artikel = &row; + else if (row.c_attribut_id == "Pfandart (Bezeichnung auf Ausdruck)") pfand_art = &row; + else if (row.c_attribut_id == "Pfandbetrag") pfand_betrag = &row; + } + + if (!pfand_artikel && !pfand_art && !pfand_betrag) { + return nullptr; + } + + std::string d_price = "0.0"; + if (pfand_betrag && pfand_betrag->has_f_wert_decimal) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.2f", pfand_betrag->f_wert_decimal); + d_price = buf; + } + + return { + {"deposit", (pfand_artikel && pfand_artikel->has_n_wert_int && pfand_artikel->n_wert_int == 1) ? "1" : "0"}, + {"deposit_name", pfand_art ? pfand_art->c_wert_varchar : ""}, + {"d_price", d_price}, + }; +} + +inline std::vector merge_article_attributes(std::vector rows) { + // Later rows for the same kAttribut win (shop-specific over global), matching JS Map set order. + std::map by_attr; + for (auto& row : rows) { + by_attr[row.k_attribut] = std::move(row); + } + std::vector merged; + merged.reserve(by_attr.size()); + for (auto& [_, row] : by_attr) { + merged.push_back(std::move(row)); + } + std::sort(merged.begin(), merged.end(), [](const AttrRow& a, const AttrRow& b) { + return a.n_sortierung < b.n_sortierung; + }); + return merged; +} + +inline AttrRow parse_attr_row(const Row& row) { + AttrRow a; + if (row.size() < 11) return a; + a.article_id = parse_int64(row[0].str, 0); + a.k_attribut = parse_int64(row[1].str, 0); + a.k_shop = parse_int(row[2].str, 0); + a.n_sortierung = parse_int(row[3].str, 0); + a.k_feld_typ = parse_int(row[4].str, 0); + a.c_gruppe_name = row[5].type == CellType::Null ? "" : row[5].str; + a.c_attribut_id = row[6].type == CellType::Null ? "" : row[6].str; + a.c_name = row[7].type == CellType::Null ? "" : row[7].str; + a.c_wert_varchar = row[8].type == CellType::Null ? "" : row[8].str; + if (row[9].type != CellType::Null) { + a.has_n_wert_int = true; + a.n_wert_int = parse_int(row[9].str, 0); + } + if (row[10].type != CellType::Null) { + a.has_f_wert_decimal = true; + a.f_wert_decimal = parse_double(row[10].str, 0); + } + return a; +} + +inline std::map get_product_attributes( + const std::vector& article_ids) { + std::map out; + if (article_ids.empty()) return out; + + const auto& pfand_ids = get_pfand_k_attribute_ids(); + const auto& active_shops = get_active_shop_ids_for_attrs(); + + std::string sql = + "SELECT " + " aa.kArtikel AS articleId, " + " aa.kAttribut, " + " aa.kShop, " + " at.nSortierung, " + " at.kFeldTyp, " + " at.cGruppeName, " + " at.cAttributId, " + " ats.cName, " + " aas.cWertVarchar, " + " aas.nWertInt, " + " aas.fWertDecimal " + "FROM dbo.tArtikelAttribut aa " + "INNER JOIN dbo.tArtikelAttributSprache aas " + " ON aas.kArtikelAttribut = aa.kArtikelAttribut AND aas.kSprache = 0 " + "INNER JOIN dbo.tAttribut at ON at.kAttribut = aa.kAttribut " + "INNER JOIN dbo.tAttributSprache ats ON ats.kAttribut = at.kAttribut AND ats.kSprache = 0 " + "WHERE aa.kArtikel IN (" + join_int64s(article_ids) + ") " + " AND (" + " aa.kShop = 0 " + " OR (" + " aa.kShop IN (" + join_int64s(active_shops) + ") " + " AND aa.kAttribut IN (" + join_int64s(pfand_ids) + ") " + " )" + " ) " + "ORDER BY aa.kArtikel, at.nSortierung, aa.kShop"; + + ResultSet rs; + if (!get_pool().execute(sql, rs)) { + logc::warn("product attributes query failed (%zu articles)", article_ids.size()); + return out; + } + + std::map> rows_by_article; + for (const auto& row : rs) { + AttrRow a = parse_attr_row(row); + if (a.article_id == 0) continue; + rows_by_article[a.article_id].push_back(std::move(a)); + } + + for (auto& [article_id, rows] : rows_by_article) { + auto merged = merge_article_attributes(std::move(rows)); + ProductAttributeBundle bundle; + for (const auto& row : merged) { + bundle.attributes.push_back(to_pos_attribute(row)); + } + bundle.deposit = derive_deposit_fields(merged); + out[article_id] = std::move(bundle); + } + return out; +} diff --git a/jtlsrv-cpp/src/queries/product_list.hpp b/jtlsrv-cpp/src/queries/product_list.hpp index a8c8a08..892db59 100644 --- a/jtlsrv-cpp/src/queries/product_list.hpp +++ b/jtlsrv-cpp/src/queries/product_list.hpp @@ -4,6 +4,7 @@ #include "nlohmann/json.hpp" #include "shop.hpp" #include "customer_groups.hpp" +#include "product_attributes.hpp" #include "../config.hpp" #include "../http.hpp" #include @@ -74,6 +75,14 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) { } auto cg_ids = get_customer_group_ids(); + + std::vector article_ids; + article_ids.reserve(rs.size()); + for (auto& row : rs) { + article_ids.push_back(parse_int64(row[0].str, 0)); + } + auto attrs_by_article = get_product_attributes(article_ids); + nlohmann::json result = nlohmann::json::array(); for (auto& row : rs) { @@ -102,6 +111,21 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) { }); } + int64_t article_id = parse_int64(row[0].str, 0); + nlohmann::json attributes = nlohmann::json::array(); + std::string deposit = "0"; + std::string deposit_name = ""; + std::string d_price = "0.0"; + auto attrs_it = attrs_by_article.find(article_id); + if (attrs_it != attrs_by_article.end()) { + attributes = attrs_it->second.attributes; + if (!attrs_it->second.deposit.is_null()) { + deposit = attrs_it->second.deposit.value("deposit", "0"); + deposit_name = attrs_it->second.deposit.value("deposit_name", ""); + d_price = attrs_it->second.deposit.value("d_price", "0.0"); + } + } + nlohmann::json product = { {"_id", row[0].str}, {"imghash", row[7].str.empty() ? nullptr : nlohmann::json(row[7].str)}, @@ -119,13 +143,13 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) { {"parent", parse_int64(row[10].str, 0) > 0 ? row[10].str : "0"}, {"variants", row[12].str}, {"isCompositeProduct", row[11].str}, - {"attributes", nlohmann::json::array()}, + {"attributes", std::move(attributes)}, {"sort", "0"}, {"p_price", "0.00"}, {"discountable", "0"}, - {"deposit", "0"}, + {"deposit", deposit}, {"discount", ""}, - {"d_price", "0.0"}, + {"d_price", d_price}, {"tax_rate2", ""}, {"use_in_out_tax", "0"}, {"barcode", row[13].str.empty() ? nullptr : nlohmann::json(row[13].str)}, @@ -139,7 +163,7 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) { {"tags", ""}, {"variants", row[12].str}, {"print_kitchen_receipt", "0"}, - {"deposit_name", ""}, + {"deposit_name", deposit_name}, {"updated_at", "0001-01-01 00:00:00"}, {"configurationGroups", ""}, {"options", nullptr},