pfand for cpp

This commit is contained in:
seb
2026-07-20 15:49:55 +02:00
parent 8057933939
commit 7bd8df22d1
3 changed files with 319 additions and 8 deletions

56
API.md
View File

@@ -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://<host>:<port>` (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 percustomer-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** | Percustomer-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=<hash>` 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”.

View File

@@ -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 <algorithm>
#include <cstdio>
#include <map>
#include <sstream>
#include <string>
#include <vector>
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<int64_t>& 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<int64_t>& get_pfand_k_attribute_ids() {
static std::vector<int64_t> 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<int64_t>& get_active_shop_ids_for_attrs() {
static std::vector<int64_t> 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<AttrRow>& 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<AttrRow> merge_article_attributes(std::vector<AttrRow> rows) {
// Later rows for the same kAttribut win (shop-specific over global), matching JS Map set order.
std::map<int64_t, AttrRow> by_attr;
for (auto& row : rows) {
by_attr[row.k_attribut] = std::move(row);
}
std::vector<AttrRow> 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<int64_t, ProductAttributeBundle> get_product_attributes(
const std::vector<int64_t>& article_ids) {
std::map<int64_t, ProductAttributeBundle> 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<int64_t, std::vector<AttrRow>> 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;
}

View File

@@ -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 <cmath>
@@ -74,6 +75,14 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
}
auto cg_ids = get_customer_group_ids();
std::vector<int64_t> 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},