From 1de4144ae2271aac2707800a3d6d0805ff862d80 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 15 Jul 2026 03:11:30 +0200 Subject: [PATCH] u --- jtlsrv-cpp/src/endpoints/category.cpp | 4 +- jtlsrv-cpp/src/endpoints/client.cpp | 35 +- jtlsrv-cpp/src/endpoints/customergroup.cpp | 2 +- jtlsrv-cpp/src/endpoints/deleted_entity.cpp | 4 +- jtlsrv-cpp/src/endpoints/init.cpp | 13 +- jtlsrv-cpp/src/endpoints/product.cpp | 4 +- jtlsrv-cpp/src/endpoints/productcomposite.cpp | 4 +- jtlsrv-cpp/src/http.cpp | 27 ++ jtlsrv-cpp/src/http.hpp | 7 + jtlsrv-cpp/src/main.cpp | 10 +- jtlsrv-cpp/src/queries/category_list.hpp | 3 +- jtlsrv-cpp/src/queries/create_order.hpp | 327 ++++++++++++++---- jtlsrv-cpp/src/queries/customer_groups.hpp | 5 +- jtlsrv-cpp/src/queries/image.hpp | 7 +- jtlsrv-cpp/src/queries/product_list.hpp | 8 +- jtlsrv-cpp/src/router.cpp | 17 +- package.json | 1 + scripts/s3-backup/README.md | 51 +++ scripts/s3-backup/backup.mjs | 223 +++++++++--- scripts/s3-backup/ensure-certs.mjs | 106 +++++- scripts/s3-backup/server.mjs | 38 +- scripts/s3-backup/sigv4.mjs | 12 +- src/queries/create-order.js | 242 ++++++++++--- 23 files changed, 921 insertions(+), 229 deletions(-) create mode 100644 scripts/s3-backup/README.md diff --git a/jtlsrv-cpp/src/endpoints/category.cpp b/jtlsrv-cpp/src/endpoints/category.cpp index 7fc1201..12ea7a7 100644 --- a/jtlsrv-cpp/src/endpoints/category.cpp +++ b/jtlsrv-cpp/src/endpoints/category.cpp @@ -4,8 +4,8 @@ #include "../queries/category_list.hpp" void handle_category(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) { - int64_t cursor = std::stoll(req.get_query_param("lastChangedCategory", "0")); - int limit = std::stoi(req.get_query_param("limit", "20")); + int64_t cursor = req.get_query_int64("lastChangedCategory"); + int limit = req.get_query_int("limit", 20); auto categories = get_category_list(cursor, limit); resp.send_json(200, categories); } diff --git a/jtlsrv-cpp/src/endpoints/client.cpp b/jtlsrv-cpp/src/endpoints/client.cpp index 7120ef4..d712dab 100644 --- a/jtlsrv-cpp/src/endpoints/client.cpp +++ b/jtlsrv-cpp/src/endpoints/client.cpp @@ -6,16 +6,27 @@ #include "../tls_server.hpp" #include "../router.hpp" +static std::string config_string(const json& config, const char* key, + const std::string& def = "") { + if (!config.is_object()) return def; + json v = config.value(key, json()); + if (v.is_null()) return def; + if (v.is_string()) return v.get(); + if (v.is_number_integer()) return std::to_string(v.get()); + if (v.is_number_unsigned()) return std::to_string(v.get()); + return def; +} + static json build_client_step1(const json& config) { return { {"authCode", nullptr}, - {"authToken", config["authToken"]}, - {"certificateFingerprint", config["certificateFingerprint"]}, - {"certificateSerialNumber", config["certificateSerialNumber"]}, - {"mandantId", config["mandantId"]}, + {"authToken", config_string(config, "authToken")}, + {"certificateFingerprint", config_string(config, "certificateFingerprint")}, + {"certificateSerialNumber", config_string(config, "certificateSerialNumber")}, + {"mandantId", config_string(config, "mandantId")}, {"mandantName", nullptr}, {"mandantDatabase", nullptr}, - {"serverFingerprint", config["serverFingerprint"]}, + {"serverFingerprint", config_string(config, "serverFingerprint")}, {"name", nullptr}, {"serverTimestamp", server_timestamp()}, }; @@ -24,12 +35,12 @@ static json build_client_step1(const json& config) { static json build_client_step2(const std::string& auth_code, const json& config) { return { {"authCode", auth_code}, - {"authToken", config["authToken"]}, - {"certificateFingerprint", config["certificateFingerprint"]}, - {"certificateSerialNumber", config["certificateSerialNumber"]}, - {"mandantId", config["mandantId"]}, - {"mandantName", config["mandantName"]}, - {"mandantDatabase", config["mandantDatabase"]}, + {"authToken", config_string(config, "authToken")}, + {"certificateFingerprint", config_string(config, "certificateFingerprint")}, + {"certificateSerialNumber", config_string(config, "certificateSerialNumber")}, + {"mandantId", config_string(config, "mandantId")}, + {"mandantName", config_string(config, "mandantName")}, + {"mandantDatabase", config_string(config, "mandantDatabase")}, {"serverFingerprint", nullptr}, {"name", nullptr}, {"serverTimestamp", server_timestamp()}, @@ -47,7 +58,7 @@ void handle_client(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) { if (auth_code.size() == 6) { if (ctx.pairing_store->has_pairing_code(auth_code)) { ctx.pairing_store->revoke_pairing_code(auth_code); - ctx.pairing_store->register_device(ctx.config["authToken"].get(), name); + ctx.pairing_store->register_device(config_string(ctx.config, "authToken"), name); return resp.send_json(200, build_client_step2(auth_code, ctx.config)); } return resp.send_json(400, { diff --git a/jtlsrv-cpp/src/endpoints/customergroup.cpp b/jtlsrv-cpp/src/endpoints/customergroup.cpp index 92d010f..fabe3cc 100644 --- a/jtlsrv-cpp/src/endpoints/customergroup.cpp +++ b/jtlsrv-cpp/src/endpoints/customergroup.cpp @@ -4,7 +4,7 @@ #include "../queries/customer_groups.hpp" void handle_customergroup(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) { - int64_t cursor = std::stoll(req.get_query_param("lastChangedCustomerGroup", "0")); + int64_t cursor = req.get_query_int64("lastChangedCustomerGroup"); auto groups = get_customer_group_list(cursor); resp.send_json(200, groups); } diff --git a/jtlsrv-cpp/src/endpoints/deleted_entity.cpp b/jtlsrv-cpp/src/endpoints/deleted_entity.cpp index ae8de30..8e2a5d7 100644 --- a/jtlsrv-cpp/src/endpoints/deleted_entity.cpp +++ b/jtlsrv-cpp/src/endpoints/deleted_entity.cpp @@ -4,8 +4,8 @@ #include "../queries/deleted_entity_list.hpp" void handle_deleted_entity(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) { - int64_t cursor = std::stoll(req.get_query_param("lastChangedDeletedEntity", "0")); - int limit = std::stoi(req.get_query_param("limit", "600")); + int64_t cursor = req.get_query_int64("lastChangedDeletedEntity"); + int limit = req.get_query_int("limit", 600); auto deleted = get_deleted_entity_list(cursor, limit); resp.send_json(200, deleted); } diff --git a/jtlsrv-cpp/src/endpoints/init.cpp b/jtlsrv-cpp/src/endpoints/init.cpp index 2ab66ae..fac7f69 100644 --- a/jtlsrv-cpp/src/endpoints/init.cpp +++ b/jtlsrv-cpp/src/endpoints/init.cpp @@ -8,11 +8,11 @@ #include "../config.hpp" void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) { - int64_t product_cursor = std::stoll(req.get_query_param("lastChangedProduct", "0")); - int64_t category_cursor = std::stoll(req.get_query_param("lastChangedCategory", "0")); - int64_t cg_cursor = std::stoll(req.get_query_param("lastChangedCustomerGroup", "0")); - int64_t composite_cursor = std::stoll(req.get_query_param("lastChangedCompositeProduct", "0")); - int64_t deleted_cursor = std::stoll(req.get_query_param("lastChangedDeletedEntity", "0")); + int64_t product_cursor = req.get_query_int64("lastChangedProduct"); + int64_t category_cursor = req.get_query_int64("lastChangedCategory"); + 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(); @@ -27,9 +27,6 @@ void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) { 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()); - logc::info("init counts: products=%lld categories=%lld cg=%lld composite=%lld deleted=%lld max_order=%lld root=%d shop=%d subshop=%d", - product_count, category_count, cg_count, composite_count, deleted_count, max_order_id_count, - root, shop, get_active_shop_subshop_id()); 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"); diff --git a/jtlsrv-cpp/src/endpoints/product.cpp b/jtlsrv-cpp/src/endpoints/product.cpp index 9c9d07b..ef87ccd 100644 --- a/jtlsrv-cpp/src/endpoints/product.cpp +++ b/jtlsrv-cpp/src/endpoints/product.cpp @@ -4,8 +4,8 @@ #include "../queries/product_list.hpp" void handle_product(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) { - int64_t cursor = std::stoll(req.get_query_param("lastChangedProduct", "0")); - int limit = std::stoi(req.get_query_param("limit", "20")); + int64_t cursor = req.get_query_int64("lastChangedProduct"); + int limit = req.get_query_int("limit", 20); auto products = get_product_list(cursor, limit); resp.send_json(200, products); } diff --git a/jtlsrv-cpp/src/endpoints/productcomposite.cpp b/jtlsrv-cpp/src/endpoints/productcomposite.cpp index e59c8a6..3eb3aad 100644 --- a/jtlsrv-cpp/src/endpoints/productcomposite.cpp +++ b/jtlsrv-cpp/src/endpoints/productcomposite.cpp @@ -4,8 +4,8 @@ #include "../queries/composite_product_list.hpp" void handle_productcomposite(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) { - int64_t cursor = std::stoll(req.get_query_param("lastChangedCompositeProduct", "0")); - int limit = std::stoi(req.get_query_param("limit", "100")); + int64_t cursor = req.get_query_int64("lastChangedCompositeProduct"); + int limit = req.get_query_int("limit", 100); auto composites = get_composite_product_list(cursor, limit); resp.send_json(200, composites); } diff --git a/jtlsrv-cpp/src/http.cpp b/jtlsrv-cpp/src/http.cpp index f9f18a1..e37a40e 100644 --- a/jtlsrv-cpp/src/http.cpp +++ b/jtlsrv-cpp/src/http.cpp @@ -7,6 +7,25 @@ #include #include +// --------------------------------------------------------------------------- +// Safe parsing +// --------------------------------------------------------------------------- + +int parse_int(const std::string& s, int fallback) { + if (s.empty()) return fallback; + try { return std::stoi(s); } catch (...) { return fallback; } +} + +int64_t parse_int64(const std::string& s, int64_t fallback) { + if (s.empty()) return fallback; + try { return std::stoll(s); } catch (...) { return fallback; } +} + +double parse_double(const std::string& s, double fallback) { + if (s.empty()) return fallback; + try { return std::stod(s); } catch (...) { return fallback; } +} + // --------------------------------------------------------------------------- // HttpRequest // --------------------------------------------------------------------------- @@ -39,6 +58,14 @@ std::string HttpRequest::get_query_param(const std::string& key, const std::stri return decoded; } +int HttpRequest::get_query_int(const std::string& key, int def) const { + return parse_int(get_query_param(key), def); +} + +int64_t HttpRequest::get_query_int64(const std::string& key, int64_t def) const { + return parse_int64(get_query_param(key), def); +} + // --------------------------------------------------------------------------- // HttpResponse // --------------------------------------------------------------------------- diff --git a/jtlsrv-cpp/src/http.hpp b/jtlsrv-cpp/src/http.hpp index a4387fc..1ef598c 100644 --- a/jtlsrv-cpp/src/http.hpp +++ b/jtlsrv-cpp/src/http.hpp @@ -17,6 +17,11 @@ struct tls_session; using json = nlohmann::json; +// Safe string-to-number parsing (never throws). +int parse_int(const std::string& s, int fallback = 0); +int64_t parse_int64(const std::string& s, int64_t fallback = 0); +double parse_double(const std::string& s, double fallback = 0.0); + // --------------------------------------------------------------------------- // Parsed HTTP request // --------------------------------------------------------------------------- @@ -32,6 +37,8 @@ struct HttpRequest { std::string query_string; // "authCode=xxx&name=yyy" std::string get_query_param(const std::string& key, const std::string& def = "") const; + int get_query_int(const std::string& key, int def = 0) const; + int64_t get_query_int64(const std::string& key, int64_t def = 0) const; }; // --------------------------------------------------------------------------- diff --git a/jtlsrv-cpp/src/main.cpp b/jtlsrv-cpp/src/main.cpp index 80ee204..d2af08c 100644 --- a/jtlsrv-cpp/src/main.cpp +++ b/jtlsrv-cpp/src/main.cpp @@ -82,6 +82,14 @@ static void handle_request(tls_session* sess) { } size_t resp_size = resp_body.size(); + const bool is_init = (req.path == "/v1/init"); + const bool log_request = !is_init + || router.should_log_init(url, resp.status_code, static_cast(elapsed), resp_body); + + if (!log_request) { + return; + } + // Console log with response size and truncated body const char* ip = sess->peer_ip.c_str(); if (resp_size > 0) { @@ -132,7 +140,7 @@ int main(int /*argc*/, char* argv[]) { // Initialize pairing store pairing_store.set_pairing_code(config::get("PAIRING_CODE", "307018"), "JTL-POS"); - pairing_store.register_device(server_config["authToken"].get(), "JTL-POS"); + pairing_store.register_device(server_config.value("authToken", std::string("df40ad2067954646abb0499548a52241")), "JTL-POS"); // Connect to MSSQL if (get_pool().connect() == 0) { diff --git a/jtlsrv-cpp/src/queries/category_list.hpp b/jtlsrv-cpp/src/queries/category_list.hpp index bee0337..24e73c0 100644 --- a/jtlsrv-cpp/src/queries/category_list.hpp +++ b/jtlsrv-cpp/src/queries/category_list.hpp @@ -41,7 +41,8 @@ inline nlohmann::json get_category_list(int64_t cursor, int limit) { auto ts = server_timestamp(); nlohmann::json result = nlohmann::json::array(); for (auto& row : rs) { - std::string pid = (std::stoll(row[1].str) == root) ? "0" : row[1].str; + int64_t parent_id = parse_int64(row[1].str, 0); + std::string pid = (parent_id == root) ? "0" : row[1].str; result.push_back({ {"_id", row[0].str}, {"imghash", row[4].str.empty() ? nullptr : nlohmann::json(row[4].str)}, diff --git a/jtlsrv-cpp/src/queries/create_order.hpp b/jtlsrv-cpp/src/queries/create_order.hpp index caa673f..8b9e2b4 100644 --- a/jtlsrv-cpp/src/queries/create_order.hpp +++ b/jtlsrv-cpp/src/queries/create_order.hpp @@ -35,6 +35,11 @@ struct Config { int customerNumberSequence = 6; }; +constexpr int VERSANDPOSITION_TYPE = 2; +constexpr int ZAHLUNG_TYPE_ZAHLUNG = 10; +constexpr int NIST_READONLY_NICHT_AENDERBAR = 2; +constexpr int NIST_EXTERNE_RECHNUNG_KEINE = 2; + Config g_config; std::unique_ptr g_resolved_defaults; std::map g_zahlungsart_cache; @@ -128,6 +133,12 @@ inline int json_int(const nlohmann::json& obj, const char* key, int fallback = 0 return to_int(*it, fallback); } +inline double json_double(const nlohmann::json& obj, const char* key, double fallback = 0) { + auto it = obj.find(key); + if (it == obj.end()) return fallback; + return to_number(*it, fallback); +} + inline int steuerklasse_for_vat(double vat) { if (vat >= 15) return 1; if (vat > 0) return 2; @@ -232,38 +243,158 @@ inline int allocate_pk(OdbcPool::Connection* c, const std::string& tableName) { return std::stoi(rs[0][0].str); } +inline int parse_import_setting(const nlohmann::json& order) { + if (!order.contains("settings") || order["settings"].is_null()) return 0; + return json_int(order["settings"], "importSetting", 0); +} + +inline int parse_invoice_setting(const nlohmann::json& order) { + if (!order.contains("settings") || order["settings"].is_null()) return 0; + return json_int(order["settings"], "invoiceSetting", 0); +} + +inline int resolve_n_ist_readonly(const nlohmann::json& order) { + return parse_import_setting(order) == 0 ? NIST_READONLY_NICHT_AENDERBAR : 0; +} + +inline int resolve_n_ist_externe_rechnung(const nlohmann::json& order) { + const int import_setting = parse_import_setting(order); + const int invoice_setting = parse_invoice_setting(order); + if (import_setting >= 2 && import_setting <= 5) return 0; + if (invoice_setting & 1) return 0; + if (import_setting == 0) return NIST_EXTERNE_RECHNUNG_KEINE; + return 0; +} + +struct VersandArtRow { + int kVersandArt = 0; + std::string cName; + double fPrice = 0; + double fMwSt = 19; +}; + +inline bool is_versandposition(const nlohmann::json& item) { + return json_int(item, "type", 0) == VERSANDPOSITION_TYPE; +} + +inline bool has_versandposition(const nlohmann::json& items) { + if (!items.is_array()) return false; + for (const auto& item : items) { + if (is_versandposition(item)) return true; + } + return false; +} + +inline bool has_non_return_sale_items(const nlohmann::json& items) { + if (!items.is_array()) return false; + for (const auto& item : items) { + if (json_double(item, "isReturn", 0) == 0) return true; + } + return false; +} + +inline bool should_inject_selbstabholer_shipping(const nlohmann::json& items) { + if (has_versandposition(items)) return false; + return has_non_return_sale_items(items); +} + +inline std::optional lookup_versand_art(OdbcPool::Connection* c, + const std::string& shipping_name) { + auto try_lookup = [&](const std::string& cName) -> std::optional { + std::vector ps = {{ParamType::NVarChar, cName, 0}}; + ResultSet rs; + if (!get_pool().execute(c, + "SELECT TOP 1 kVersandArt, cName, fPrice, fMwSt " + "FROM dbo.tVersandArt WHERE cName = ?", ps, rs) + || rs.empty() || rs[0].empty()) { + return std::nullopt; + } + VersandArtRow row; + row.kVersandArt = std::stoi(rs[0][0].str); + row.cName = rs[0][1].str; + row.fPrice = rs[0].size() > 2 ? to_number(nlohmann::json(rs[0][2].str), 0) : 0; + row.fMwSt = rs[0].size() > 3 ? to_number(nlohmann::json(rs[0][3].str), 19) : 19; + return row; + }; + + std::string name = shipping_name; + if (name.empty()) name = "Selbstabholer"; + if (auto row = try_lookup(name)) return row; + if (name != "Selbstabholer") return try_lookup("Selbstabholer"); + return std::nullopt; +} + +inline nlohmann::json synthetic_shipping_item(const VersandArtRow& versand_art) { + const double vat = versand_art.fMwSt; + const double gross = versand_art.fPrice; + const double net = gross / (1 + vat / 100); + return { + {"type", std::to_string(VERSANDPOSITION_TYPE)}, + {"quantity", "1"}, + {"name", versand_art.cName}, + {"priceGross", std::to_string(gross)}, + {"priceNet", std::to_string(net)}, + {"vat", std::to_string(vat)}, + {"isReturn", "0"} + }; +} + inline nlohmann::json resolve_zahlungsart(OdbcPool::Connection* c, const std::string& name) { - std::string key = name.empty() ? "bar" : name; - std::transform(key.begin(), key.end(), key.begin(), [](unsigned char ch) { return std::tolower(ch); }); - auto it = g_zahlungsart_cache.find(key); + const std::string lookup_name = name.empty() ? "Bar" : name; + const std::string cache_key = [&]() { + std::string key = lookup_name; + std::transform(key.begin(), key.end(), key.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + return key; + }(); + auto it = g_zahlungsart_cache.find(cache_key); if (it != g_zahlungsart_cache.end()) return it->second; - std::vector ps = {{ParamType::NVarChar, key, 0}}; - ResultSet rs; - get_pool().execute(c, "SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE cName = ?", ps, rs); + auto fetch_row = [&](const char* sql) -> std::optional> { + std::vector ps = {{ParamType::NVarChar, lookup_name, 0}}; + ResultSet rs; + if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) { + return std::nullopt; + } + return std::make_pair(std::stoi(rs[0][0].str), rs[0][1].str); + }; - if (!rs.empty() && !rs[0].empty()) { - nlohmann::json z = {{"kZahlungsart", std::stoi(rs[0][0].str)}, {"cName", rs[0][1].str}}; - g_zahlungsart_cache[key] = z; - return z; + std::optional> row = + fetch_row("SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE cName = ?"); + if (!row) { + row = fetch_row( + "SELECT TOP 1 z.kZahlungsart, z.cName " + "FROM dbo.tZahlungsArtSprache zs " + "INNER JOIN dbo.tZahlungsart z ON z.kZahlungsart = zs.kZahlungsart " + "WHERE zs.cName = ?"); + } + if (!row) { + row = fetch_row( + "SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE UPPER(cName) = UPPER(?)"); } - int kZahlungsart = allocate_pk(c, "tZahlungsart"); - const char* insert = - "INSERT INTO dbo.tZahlungsart" - " (kZahlungsart, cName, cPrtString, nLastschrift, cPrtStringVor, cPaymentOption, cKonto," - " nAusliefernVorZahlung, nPrioritaet, nMahnwesenAktiv, fSkontoWert, nSkontoZeitraum," - " nMatchingOptionen, nIstStandard, nAktiv)" - " VALUES (?, ?, '', 0, '', '', '', 0, 0, 0, 0, 0, 0, 0, 1)"; - std::vector ps2 = { - {ParamType::Int, "", kZahlungsart}, - {ParamType::NVarChar, key, 0} - }; - get_pool().execute(c, insert, ps2, rs); + nlohmann::json zahlungsart; + if (row) { + zahlungsart = {{"kZahlungsart", row->first}, {"cName", row->second}}; + } else { + int kZahlungsart = allocate_pk(c, "tZahlungsart"); + const char* insert = + "INSERT INTO dbo.tZahlungsart" + " (kZahlungsart, cName, cPrtString, nLastschrift, cPrtStringVor, cPaymentOption, cKonto," + " nAusliefernVorZahlung, nPrioritaet, nMahnwesenAktiv, fSkontoWert, nSkontoZeitraum," + " nMatchingOptionen, nIstStandard, nAktiv)" + " VALUES (?, ?, '', 0, '', '', '', 0, 0, 0, 0, 0, 0, 0, 1)"; + std::vector ps2 = { + {ParamType::Int, "", kZahlungsart}, + {ParamType::NVarChar, lookup_name, 0} + }; + ResultSet rs; + get_pool().execute(c, insert, ps2, rs); + zahlungsart = {{"kZahlungsart", kZahlungsart}, {"cName", lookup_name}}; + } - nlohmann::json z = {{"kZahlungsart", kZahlungsart}, {"cName", key}}; - g_zahlungsart_cache[key] = z; - return z; + g_zahlungsart_cache[cache_key] = zahlungsart; + return zahlungsart; } inline std::string next_customer_number(OdbcPool::Connection* c) { @@ -303,21 +434,6 @@ inline std::string resolve_auftrag_c_kunden_nr(const nlohmann::json& order) { return order.value("customerNumber", ""); } -// POS settings.invoiceSetting: 0 = external/POS invoice (nIstExterneRechnung=1). -inline int resolve_n_ist_externe_rechnung(const nlohmann::json& order) { - if (!order.contains("settings") || order["settings"].is_null()) return 1; - const auto& settings = order["settings"]; - if (!settings.contains("invoiceSetting")) return 1; - const auto& inv = settings["invoiceSetting"]; - if (inv.is_boolean()) return inv.get() ? 0 : 1; - if (inv.is_number_integer()) return inv.get() == 1 ? 0 : 1; - if (inv.is_string()) { - std::string s = inv.get(); - return (s == "1" || s == "true") ? 0 : 1; - } - return 1; -} - inline std::pair create_customer(OdbcPool::Connection* c, const std::string& customer_number, const nlohmann::json& address, const Defaults& defaults) { const nlohmann::json a = address.is_null() ? nlohmann::json::object() : address; @@ -328,8 +444,8 @@ inline std::pair create_customer(OdbcPool::Connection* c, const std::st const char* sql = "DECLARE @returnValue INT;" - "DECLARE @p1 dbo.TYPE_spkundeInsert;" - "INSERT INTO @p1" + "DECLARE @kunde_daten dbo.TYPE_spkundeInsert;" + "INSERT INTO @kunde_daten" " (kInetKunde, kKundenKategorie, cKundenNr, cFirma, cAnrede, cTitel, cVorname, cName," " cStrasse, cPLZ, cOrt, cLand, cTel, cFax, cEMail, dErstellt, cMobil, fRabatt, cUSTID, cNewsletter," " cZusatz, cEbayName, kBuyer, cAdressZusatz, cGeburtstag, cWWW, cSperre, cPostID, kKundenGruppe," @@ -342,7 +458,7 @@ inline std::pair create_customer(OdbcPool::Connection* c, const std::st " 0, ?, ?, ?, ?, ?, N'', 0," " ?, N'', 0, 0, 0, 0, 0," " NULL, 0, 0, 0);" - "EXEC @returnValue = Kunde.spKundeInsert @daten = @p1;" + "EXEC @returnValue = Kunde.spKundeInsert @daten = @kunde_daten;" "SELECT @returnValue AS kKunde"; std::vector ps = { @@ -360,7 +476,7 @@ inline std::pair create_customer(OdbcPool::Connection* c, const std::st {ParamType::NVarChar, a.value("fax", ""), 0}, {ParamType::NVarChar, a.value("email", ""), 0}, {ParamType::NVarChar, a.value("mobile", ""), 0}, - {ParamType::Double, "", 0, to_number(a["discount"], 0)}, + {ParamType::Double, "", 0, json_double(a, "discount", 0)}, {ParamType::NVarChar, a.value("addressAddition", ""), 0}, {ParamType::NVarChar, a.value("birthday", ""), 0}, {ParamType::Int, "", kKundengruppe}, @@ -445,17 +561,18 @@ inline void insert_order_address(OdbcPool::Connection* c, int kAuftrag, int kKun } inline int insert_order_item(OdbcPool::Connection* c, int kAuftrag, const nlohmann::json& item) { - double vat = to_number(item["vat"], 19); - double quantity = to_number(item["quantity"], 1); - double price_gross = to_number(item["priceGross"], 0); - double price_net = to_number(item["priceNet"], price_gross / (1 + vat / 100)); - double discount = to_number(item["discountPercent"], 0); + double vat = json_double(item, "vat", 19); + double quantity = json_double(item, "quantity", 1); + double price_gross = json_double(item, "priceGross", 0); + double price_net = json_double(item, "priceNet", price_gross / (1 + vat / 100)); + double discount = json_double(item, "discountPercent", 0); int kSteuerklasse = steuerklasse_for_vat(vat); std::string sku = item.value("sku", ""); + const int position_type = is_versandposition(item) ? VERSANDPOSITION_TYPE : json_int(item, "type", 0); int kArtikel = 0; bool has_artikel = false; - if (!sku.empty()) { + if (!sku.empty() && position_type != VERSANDPOSITION_TYPE) { std::vector ps = {{ParamType::NVarChar, sku, 0}}; ResultSet rs; get_pool().execute(c, "SELECT TOP 1 kArtikel FROM dbo.tArtikel WHERE cArtNr = ?", ps, rs); @@ -465,18 +582,20 @@ inline int insert_order_item(OdbcPool::Connection* c, int kAuftrag, const nlohma } } + const int nType = position_type == VERSANDPOSITION_TYPE ? VERSANDPOSITION_TYPE : (has_artikel ? 1 : 0); + const int nReserviert = nType == VERSANDPOSITION_TYPE ? 0 : 1; + const char* sql = "DECLARE @t TABLE ([kAuftragPosition] INT);" "INSERT INTO Verkauf.tAuftragPosition" " (kArtikel, kAuftrag, cArtNr, nReserviert, cName, cHinweis, fAnzahl, fVkNetto, fMwSt," " cNameStandard, kSteuerklasse, nType, cEinheit, fFaktor, kSteuerschluessel, fRabatt)" " OUTPUT inserted.kAuftragPosition INTO @t" - " VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?," + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?," " ?, ?, ?, ?, 1.0, 3, ?);" "SELECT kAuftragPosition FROM @t"; Param cartnr{ParamType::NVarChar, has_artikel ? sku : std::string(), 0, 0.0, !has_artikel}; - Param pk_art{ParamType::Int, "", kArtikel, 0.0, !has_artikel}; std::string name = item.value("name", sku.empty() ? std::string("Position") : sku); @@ -484,6 +603,7 @@ inline int insert_order_item(OdbcPool::Connection* c, int kAuftrag, const nlohma pk_art, {ParamType::Int, "", kAuftrag}, cartnr, + {ParamType::Int, "", nReserviert}, {ParamType::NVarChar, name, 0}, {ParamType::NVarChar, item.value("note", ""), 0}, {ParamType::Double, "", 0, quantity}, @@ -491,7 +611,7 @@ inline int insert_order_item(OdbcPool::Connection* c, int kAuftrag, const nlohma {ParamType::Double, "", 0, vat}, {ParamType::NVarChar, name, 0}, {ParamType::Int, "", kSteuerklasse}, - {ParamType::Int, "", has_artikel ? 1 : 0}, + {ParamType::Int, "", nType}, {ParamType::NVarChar, item.value("unit", ""), 0}, {ParamType::Double, "", 0, discount} }; @@ -608,6 +728,39 @@ inline bool is_order_delivered(const nlohmann::json& order) { return true; } +// ODBC maps ? placeholders to @p1, @p2, … — never DECLARE @p1 in the same batch. +inline void recalculate_auftrag_eckdaten(OdbcPool::Connection* c, int kAuftrag) { + const char* sql = + "DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;" + "INSERT INTO @eckdaten_calc VALUES (?);" + "EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc"; + std::vector ps = {{ParamType::Int, "", kAuftrag}}; + ResultSet rs; + if (!get_pool().execute(c, sql, ps, rs)) { + throw std::runtime_error("spAuftragEckdatenBerechnen failed for kAuftrag=" + + std::to_string(kAuftrag)); + } +} + +inline std::optional get_offener_auftragswert(OdbcPool::Connection* c, int kAuftrag) { + const char* sql = + "SELECT ROUND(tAuftragEckdaten.fOffenerWertOhneStorno, 2) AS fOffenerAuftragswert " + "FROM Verkauf.tAuftrag " + "LEFT JOIN Verkauf.tAuftragEckdaten ON tAuftragEckdaten.kAuftrag = tAuftrag.kAuftrag " + "WHERE tAuftrag.kAuftrag = ?"; + std::vector ps = {{ParamType::Int, "", kAuftrag}}; + ResultSet rs; + if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty() + || rs[0][0].type == CellType::Null) { + return std::nullopt; + } + return to_number(nlohmann::json(rs[0][0].str), 0); +} + +inline bool is_new_payment(const nlohmann::json& payment) { + return json_int(payment, "paymentId", 0) <= 0; +} + inline void insert_payment(OdbcPool::Connection* c, int kAuftrag, const nlohmann::json& payment, const nlohmann::json& order, const std::tm& order_date_tm) { std::string payment_name = payment.value("paymentMethodName", ""); @@ -615,25 +768,46 @@ inline void insert_payment(OdbcPool::Connection* c, int kAuftrag, const nlohmann nlohmann::json zahlungsart = resolve_zahlungsart(c, payment_name); int kZahlung = allocate_pk(c, "tZahlung"); + recalculate_auftrag_eckdaten(c, kAuftrag); + auto fOffenerWert = get_offener_auftragswert(c, kAuftrag); + if (!fOffenerWert) { + throw std::runtime_error("no open order amount for kAuftrag=" + std::to_string(kAuftrag)); + } + const char* sql = - "INSERT INTO dbo.tZahlung" - " (kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart," - " nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung)" - " VALUES (?, ?, ?, ?, ?, ?, 0, '', ?," - " 0, ?, 0, 0, '', 0)"; + "IF EXISTS (" + " SELECT 1 FROM Verkauf.tAuftragEckdaten" + " WHERE kAuftrag = ?" + " AND ROUND(fOffenerWertOhneStorno, 2) = ROUND(?, 2)" + ")" + "BEGIN" + " INSERT INTO dbo.tZahlung" + " (kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart," + " nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung)" + " VALUES (?, ?, ?, ?, ?, ?, 0, '', ?," + " 0, ?, 0, ?, '', 0);" + "END;" + "SELECT @@ROWCOUNT AS inserted"; std::vector ps = { + {ParamType::Int, "", kAuftrag}, + {ParamType::Double, "", 0, *fOffenerWert}, {ParamType::Int, "", kZahlung}, {ParamType::NVarChar, zahlungsart.value("cName", ""), 0}, {ParamType::NVarChar, order_date_sql(order_date_tm), 0}, - {ParamType::Double, "", 0, to_number(payment["amount"], 0)}, + {ParamType::Double, "", 0, json_double(payment, "amount", 0)}, {ParamType::Int, "", kAuftrag}, {ParamType::Int, "", g_config.kBenutzer}, {ParamType::Int, "", json_int(zahlungsart, "kZahlungsart", 0)}, - {ParamType::NVarChar, order.value("externalOrderNumber", ""), 0} + {ParamType::NVarChar, order.value("externalOrderNumber", ""), 0}, + {ParamType::Int, "", ZAHLUNG_TYPE_ZAHLUNG}, }; ResultSet rs; - get_pool().execute(c, sql, ps, rs); + if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty() + || std::stoi(rs[0][0].str) == 0) { + throw std::runtime_error("payment insert skipped: open amount changed for kAuftrag=" + + std::to_string(kAuftrag)); + } } inline nlohmann::json create_order(const nlohmann::json& order) { @@ -678,7 +852,18 @@ inline nlohmann::json create_order(const nlohmann::json& order) { nlohmann::json zahlungsart = resolve_zahlungsart(c, order.value("paymentMethodName", "Bar")); std::string cAuftragsNr = next_order_number(c, order_date_tm); + nlohmann::json order_items = order.contains("orderItems") ? order["orderItems"] : nlohmann::json::array(); + if (!order_items.is_array()) order_items = nlohmann::json::array(); + + auto versand_art = lookup_versand_art(c, order.value("shippingName", "")); + int kVersandArt = versand_art ? versand_art->kVersandArt : defaults.kVersandArt; + if (should_inject_selbstabholer_shipping(order_items) && versand_art) { + order_items.push_back(synthetic_shipping_item(*versand_art)); + } + int active_shop = get_active_shop_id(); + const int nIstReadOnly = resolve_n_ist_readonly(order); + const int nIstExterneRechnung = resolve_n_ist_externe_rechnung(order); const char* insert_auftrag = "DECLARE @t TABLE ([kAuftrag] INT);" @@ -700,7 +885,6 @@ inline nlohmann::json create_order(const nlohmann::json& order) { std::string currency_iso = order.value("currencyIso", "EUR"); std::string cKundenNr = resolve_auftrag_c_kunden_nr(order); - int nIstExterneRechnung = resolve_n_ist_externe_rechnung(order); std::vector ps = { {ParamType::NVarChar, cAuftragsNr, 0}, {ParamType::NVarChar, order_date_sql(order_date_tm), 0}, @@ -715,13 +899,13 @@ inline nlohmann::json create_order(const nlohmann::json& order) { {ParamType::Int, "", active_shop == 0 ? -1 : active_shop}, {ParamType::NVarChar, cKundenNr, 0}, {ParamType::NVarChar, shipping_iso, 0}, - {ParamType::Int, "", defaults.kVersandArt}, + {ParamType::Int, "", kVersandArt}, {ParamType::Int, "", json_int(zahlungsart, "kZahlungsart", 0)}, {ParamType::Int, "", kKundengruppe}, {ParamType::NVarChar, order.value("externalOrderNumber", ""), 0}, {ParamType::Int, "", nIstExterneRechnung}, {ParamType::NVarChar, "Y", 0}, - {ParamType::Int, "", 2}, + {ParamType::Int, "", nIstReadOnly}, }; if (active_shop == 0) { ps[11] = Param::null_int(); @@ -738,22 +922,25 @@ inline nlohmann::json create_order(const nlohmann::json& order) { insert_order_address(c, kAuftrag, kKunde, order.value("billingAddress", nlohmann::json::object()), 1); std::vector delivered_items; - const nlohmann::json& items = order.contains("orderItems") ? order["orderItems"] : nlohmann::json::array(); - for (const auto& item : items) { + for (const auto& item : order_items) { int kAuftragPosition = insert_order_item(c, kAuftrag, item); - insert_pos_order_position_mapping(c, kAuftragPosition, item.value("externalId", "")); - if (kAuftragPosition > 0) { - delivered_items.push_back({kAuftragPosition, to_number(item["quantity"], 1)}); + const std::string external_id = item.value("externalId", ""); + if (!external_id.empty()) { + insert_pos_order_position_mapping(c, kAuftragPosition, external_id); + } + if (kAuftragPosition > 0 && !is_versandposition(item)) { + delivered_items.push_back({kAuftragPosition, json_double(item, "quantity", 1)}); } } const nlohmann::json& payments = order.contains("payments") ? order["payments"] : nlohmann::json::array(); for (const auto& payment : payments) { + if (!is_new_payment(payment)) continue; insert_payment(c, kAuftrag, payment, order, order_date_tm); } if (is_order_delivered(order)) { - delivery::deliver_order(c, g_config.kBenutzer, kAuftrag, defaults.kVersandArt, delivered_items); + delivery::deliver_order(c, g_config.kBenutzer, kAuftrag, kVersandArt, delivered_items); } const char* calc = diff --git a/jtlsrv-cpp/src/queries/customer_groups.hpp b/jtlsrv-cpp/src/queries/customer_groups.hpp index e8aa9eb..6ab6397 100644 --- a/jtlsrv-cpp/src/queries/customer_groups.hpp +++ b/jtlsrv-cpp/src/queries/customer_groups.hpp @@ -1,6 +1,7 @@ #pragma once #include "../db/pool.hpp" #include "../log.hpp" +#include "../http.hpp" #include "nlohmann/json.hpp" static const char* CUSTOMER_GROUP_IDS_SQL = @@ -27,7 +28,7 @@ inline std::vector get_customer_group_ids() { return {}; } for (auto& row : rs) { - cached.push_back(std::stoll(row[0].str)); + cached.push_back(parse_int64(row[0].str, 0)); } loaded = true; return cached; @@ -43,7 +44,7 @@ inline nlohmann::json get_customer_group_list(int64_t cursor = 0) { {"customerGroupId", row[0].str}, {"name", row[1].str}, {"standard", row[2].str}, - {"discountPercent", std::to_string(std::stod(row[3].str))}, + {"discountPercent", std::to_string(parse_double(row[3].str, 0))}, {"lastChanged", row[4].str} }); } diff --git a/jtlsrv-cpp/src/queries/image.hpp b/jtlsrv-cpp/src/queries/image.hpp index 90bbe3d..d571ddd 100644 --- a/jtlsrv-cpp/src/queries/image.hpp +++ b/jtlsrv-cpp/src/queries/image.hpp @@ -1,6 +1,7 @@ #pragma once #include "../db/pool.hpp" #include "../log.hpp" +#include "../http.hpp" #include #include #include @@ -83,10 +84,10 @@ inline ImageResult get_image_by_hash(const std::string& hash, const std::string& auto& row = rs[0]; - int target = size.empty() ? 200 : std::stoi(size); + int target = parse_int(size, 200); std::string ct = content_type_for(row[6].str); - int preview_w = row[4].str.empty() ? 0 : std::stoi(row[4].str); - int preview_h = row[5].str.empty() ? 0 : std::stoi(row[5].str); + int preview_w = row[4].str.empty() ? 0 : parse_int(row[4].str, 0); + int preview_h = row[5].str.empty() ? 0 : parse_int(row[5].str, 0); int preview_max = std::max(preview_w, preview_h); bool has_full = !row[0].blob.empty(); diff --git a/jtlsrv-cpp/src/queries/product_list.hpp b/jtlsrv-cpp/src/queries/product_list.hpp index 57ad174..159d145 100644 --- a/jtlsrv-cpp/src/queries/product_list.hpp +++ b/jtlsrv-cpp/src/queries/product_list.hpp @@ -43,8 +43,8 @@ static const char* PRODUCT_LIST_SQL = "ORDER BY lastChanged ASC"; static std::string gross_price(const std::string& net, const std::string& tax) { - double n = std::stod(net.empty() ? "0" : net); - double t = std::stod(tax.empty() ? "0" : tax); + double n = parse_double(net, 0); + double t = parse_double(tax, 0); char buf[32]; std::snprintf(buf, sizeof(buf), "%.2f", n * (1.0 + t / 100.0)); return buf; @@ -107,7 +107,7 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) { {"imgsrc", row[7].str.empty() ? nullptr : nlohmann::json(row[7].str)}, {"sku", row[1].str}, {"name", row[2].str}, - {"tax_rate", std::to_string((int)std::round(std::stod(row[4].str.empty()?"0":row[4].str)))}, + {"tax_rate", std::to_string((int)std::round(parse_double(row[4].str, 0)))}, {"price", base_price}, {"created_at", row[5].str}, {"lastChanged", row[6].str}, @@ -115,7 +115,7 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) { {"categories", cats}, {"prices", prices}, {"is_parent", row[9].str == "1" ? "1" : "0"}, - {"parent", std::stoll(row[10].str) > 0 ? row[10].str : "0"}, + {"parent", parse_int64(row[10].str, 0) > 0 ? row[10].str : "0"}, {"variants", row[12].str}, {"isCompositeProduct", row[11].str}, {"attributes", nlohmann::json::array()}, diff --git a/jtlsrv-cpp/src/router.cpp b/jtlsrv-cpp/src/router.cpp index ff370d8..ca7599d 100644 --- a/jtlsrv-cpp/src/router.cpp +++ b/jtlsrv-cpp/src/router.cpp @@ -3,6 +3,7 @@ #include #include +#include void Router::add_route(const std::string& method, const std::string& path, Handler handler) { std::string key = method + " " + path; @@ -27,7 +28,21 @@ void Router::dispatch(tls_session* sess, PairingStore& pairing, const json& conf ctx.full_url = full_url; ctx.pairing_store = &pairing; ctx.config = config; - it->second(req, resp, ctx); + try { + it->second(req, resp, ctx); + } catch (const std::exception& ex) { + logc::error("request handler error: %s %s — %s", + req.method.c_str(), full_url.c_str(), ex.what()); + if (!resp.headers_sent) { + resp.send_json(500, {{"Message", "Internal server error"}}); + } + } catch (...) { + logc::error("request handler error: %s %s — unknown exception", + req.method.c_str(), full_url.c_str()); + if (!resp.headers_sent) { + resp.send_json(500, {{"Message", "Internal server error"}}); + } + } return; } diff --git a/package.json b/package.json index 3cd9978..113d35c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "cert": "node generate-cert.js", "backup:s3": "node scripts/s3-backup/backup.mjs", + "backup:s3:quick": "node scripts/s3-backup/backup.mjs --skip-trust", "start": "node --watch server.js", "test:client": "node test-client.js" }, diff --git a/scripts/s3-backup/README.md b/scripts/s3-backup/README.md new file mode 100644 index 0000000..feb1465 --- /dev/null +++ b/scripts/s3-backup/README.md @@ -0,0 +1,51 @@ +# MSSQL backup via local S3 endpoint + +Custom S3-compatible HTTPS server (no MinIO). SQL Server 2022+ backs up with `BACKUP TO URL`; files land on disk under `data/sqlbackups/`. + +## Quick start + +```bash +npm run backup:s3 +``` + +First run installs the CA into the `mssql` Docker container (`/var/opt/mssql/security/ca-certificates/`) and restarts SQL Server — required on Linux. + +## Commands + +| Command | Description | +|---------|-------------| +| `npm run backup:s3` | Start S3 endpoint + backup `MSSQL_DATABASE` from `.env` | +| `npm run backup:s3:quick` | Same, but skip PAL CA install/restart (after first setup) | +| `node scripts/s3-backup/backup.mjs --all` | Backup `eazybusiness` and `Mandant_3` | +| `node scripts/s3-backup/backup.mjs --server-only` | Run endpoint only | +| `npm run backup:s3 -- --skip-trust` | Skip CA install (npm needs `--` before script args) | + +## Layout + +``` +scripts/s3-backup/ + server.mjs S3-compatible HTTPS server (SigV4, multipart upload) + backup.mjs Orchestrator: trust CA → start server → sqlcmd BACKUP + config.mjs Host, port, credentials + ensure-certs.mjs TLS certs + Docker MSSQL PAL trust + sigv4.mjs AWS Signature V4 verification + data/sqlbackups/ Backup files written here +``` + +## Configuration + +Reads `MSSQL_*` from repo `.env`. Optional overrides: + +| Variable | Default | Description | +|----------|---------|-------------| +| `S3_BACKUP_HOST` | Docker bridge IP (`172.17.0.1`) | Host SQL Server uses in `s3://` URL | +| `S3_BACKUP_PORT` | `19443` | HTTPS port | +| `S3_BACKUP_ACCESS_KEY` | `jtlsrv-s3` | S3 access key | +| `S3_BACKUP_SECRET_KEY` | `jtlsrv-s3-secret` | S3 secret key | +| `MSSQL_DOCKER_CONTAINER` | `mssql` | Container name for CA install | + +## Notes + +- MSSQL runs in Docker: the endpoint binds `0.0.0.0` and uses the docker bridge IP so the container can reach it. +- SQL Server on Linux uses **SQLPAL** for outbound TLS — the CA must be in `/var/opt/mssql/security/ca-certificates/`, not only the OS trust store. +- Regenerating certs requires re-running without `--skip-trust` so PAL stays in sync. diff --git a/scripts/s3-backup/backup.mjs b/scripts/s3-backup/backup.mjs index 040e37c..e4aabe3 100644 --- a/scripts/s3-backup/backup.mjs +++ b/scripts/s3-backup/backup.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process'; +import { fork, spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import https from 'node:https'; import path from 'node:path'; @@ -14,10 +14,10 @@ import { HOST, PORT, SECRET_KEY, + SERVER_CERT, s3BaseUrl, } from './config.mjs'; -import { ensureCerts, installCaTrust } from './ensure-certs.mjs'; -import { startServer } from './server.mjs'; +import { caTrustStatus, ensureCerts, installCaTrust } from './ensure-certs.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); dotenv.config({ path: path.join(root, '.env') }); @@ -35,60 +35,114 @@ function sqlcmd(query) { const port = process.env.MSSQL_PORT || '1433'; const user = process.env.MSSQL_USER || 'sa'; const password = process.env.MSSQL_PASSWORD || ''; - const result = spawnSync( - 'sqlcmd', - ['-W', '-C', '-S', `${server},${port}`, '-U', user, '-P', password, '-Q', query], - { encoding: 'utf8' } - ); - const output = `${result.stdout || ''}${result.stderr || ''}`; - if (result.status !== 0 || /^\s*Msg \d+,/m.test(output)) { - throw new Error(output.trim() || 'sqlcmd failed'); - } - return result.stdout; + return new Promise((resolve, reject) => { + const child = spawn( + 'sqlcmd', + ['-W', '-C', '-S', `${server},${port}`, '-U', user, '-P', password, '-Q', query], + { encoding: 'utf8' } + ); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { + stdout += chunk; + process.stdout.write(chunk); + }); + child.stderr?.on('data', (chunk) => { + stderr += chunk; + process.stderr.write(chunk); + }); + child.on('close', (code) => { + const output = `${stdout}${stderr}`; + if (code !== 0 || /^\s*Msg \d+,/m.test(output)) { + reject(new Error(output.trim() || 'sqlcmd failed')); + } else { + resolve(stdout); + } + }); + child.on('error', reject); + }); } function timestamp() { return new Date().toISOString().replace(/[:.]/g, '-'); } +async function isServerUp() { + const probeHost = /^\d+\./.test(HOST) ? '127.0.0.1' : HOST; + return new Promise((resolve) => { + const opts = { + host: probeHost, + port: PORT, + path: '/', + method: 'GET', + rejectUnauthorized: false, + }; + if (!/^\d+\./.test(HOST)) { + opts.servername = HOST; + } + const req = https.request(opts, (res) => { + res.resume(); + resolve(res.statusCode === 403 || res.statusCode === 200); + }); + req.on('error', () => resolve(false)); + req.setTimeout(1000, () => { + req.destroy(); + resolve(false); + }); + req.end(); + }); +} + async function waitForServer() { for (let i = 0; i < 40; i++) { - const ok = await new Promise((resolve) => { - const req = https.request( - { host: HOST, port: PORT, path: '/', method: 'GET', rejectUnauthorized: false }, - (res) => { - res.resume(); - resolve(res.statusCode === 403 || res.statusCode === 200); - } - ); - req.on('error', () => resolve(false)); - req.end(); - }); - if (ok) return; + if (await isServerUp()) return; await new Promise((r) => setTimeout(r, 250)); } throw new Error(`S3 endpoint did not start on https://${HOST}:${PORT}`); } +function pidOnPort(port) { + const result = spawnSync('ss', ['-tlnp'], { encoding: 'utf8' }); + const match = result.stdout?.match(new RegExp(`:${port}\\s+.*?pid=(\\d+)`)); + return match ? Number(match[1]) : null; +} + +async function stopPortListener(port) { + const stale = pidOnPort(port); + if (!stale) return; + try { + process.kill(stale); + } catch { + spawnSync('fuser', ['-k', `${port}/tcp`], { stdio: 'pipe' }); + } + await new Promise((r) => setTimeout(r, 200)); +} + +async function ensureServerProcess() { + await stopPortListener(PORT); + return startServerProcess(); +} + function ensureCredential() { const cred = s3BaseUrl(); - sqlcmd(` -IF EXISTS (SELECT 1 FROM sys.credentials WHERE name = N'${cred}') - DROP CREDENTIAL [${cred}]; -CREATE CREDENTIAL [${cred}] -WITH IDENTITY = 'S3 Access Key', - SECRET = '${ACCESS_KEY}:${SECRET_KEY}'; + return sqlcmd(` +IF NOT EXISTS (SELECT 1 FROM sys.credentials WHERE name = N'${cred}') +BEGIN + CREATE CREDENTIAL [${cred}] + WITH IDENTITY = 'S3 Access Key', + SECRET = '${ACCESS_KEY}:${SECRET_KEY}'; +END `); } -function backupDatabase(db) { +async function backupDatabase(db) { const file = `${db}-${timestamp()}.bak`; const url = `${s3BaseUrl()}/${file}`; console.log(`Backing up ${db} -> ${url}`); - sqlcmd(` + await sqlcmd(` BACKUP DATABASE [${db}] TO URL = '${url}' -WITH FORMAT, COMPRESSION, STATS = 10; +WITH FORMAT, COMPRESSION, MAXTRANSFERSIZE = 20971520, STATS = 10; `); const onDisk = path.join(DATA_DIR, file); if (!fs.existsSync(onDisk)) { @@ -99,36 +153,95 @@ WITH FORMAT, COMPRESSION, STATS = 10; return onDisk; } +function waitForSql() { + for (let i = 0; i < 60; i++) { + const result = spawnSync( + 'sqlcmd', + [ + '-W', + '-C', + '-S', + `${process.env.MSSQL_SERVER || 'localhost'},${process.env.MSSQL_PORT || '1433'}`, + '-U', + process.env.MSSQL_USER || 'sa', + '-P', + process.env.MSSQL_PASSWORD || '', + '-Q', + 'SELECT 1', + ], + { encoding: 'utf8' } + ); + if (result.status === 0 && !/Msg \d+,/.test(result.stdout || '')) { + return; + } + spawnSync('sleep', ['2']); + } + throw new Error('MSSQL did not become ready'); +} + +function startServerProcess() { + const child = fork(new URL('./server.mjs', import.meta.url), { + env: { ...process.env, S3_BACKUP_CHILD: '1' }, + stdio: 'inherit', + }); + return child; +} + async function main() { - ensureCerts(HOST); - if (!skipTrust) { - const trusted = installCaTrust(); - if (!trusted) { + if (skipTrust) { + const status = caTrustStatus(); + if (!status.inSync) { + throw new Error( + 'PAL CA is out of sync with scripts/s3-backup/certs/ca.pem. Run: npm run backup:s3' + ); + } + console.log('Skipping PAL CA install (--skip-trust)'); + if (!fs.existsSync(SERVER_CERT)) { + throw new Error('No TLS certs found. Run: npm run backup:s3'); + } + } else { + ensureCerts(HOST); + const trust = installCaTrust(); + if (!trust.ok) { console.warn('Could not install CA into MSSQL container. Run:'); - console.warn(` docker cp scripts/s3-backup/certs/ca.pem mssql:/tmp/jtlsrv-s3-ca.pem`); - console.warn(' docker exec mssql bash -lc "cp /tmp/jtlsrv-s3-ca.pem /usr/local/share/ca-certificates/jtlsrv-s3.crt && update-ca-certificates"'); + console.warn(' docker exec -u root mssql mkdir -p /var/opt/mssql/security/ca-certificates'); + console.warn(' docker cp scripts/s3-backup/certs/ca.pem mssql:/var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem'); + console.warn(' docker exec -u root mssql chown mssql:mssql /var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem'); + console.warn(' docker restart mssql'); } else { - console.log('Installed S3 CA into MSSQL container'); + console.log('Installed S3 CA into MSSQL PAL trust store'); + if (trust.restarted) { + console.log('Waiting for MSSQL to restart...'); + waitForSql(); + } } } - await startServer(); - await waitForServer(); + const serverChild = await ensureServerProcess(); + try { + await waitForServer(); - if (serverOnly) { - console.log('Server running (--server-only). Ctrl+C to stop.'); - return; - } + if (serverOnly) { + console.log('Server running (--server-only). Ctrl+C to stop.'); + await new Promise((resolve) => serverChild.on('exit', resolve)); + return; + } - ensureCredential(); - const saved = []; - for (const db of databases) { - saved.push(backupDatabase(db)); - } + await ensureCredential(); + const saved = []; + for (const db of databases) { + saved.push(await backupDatabase(db)); + } - console.log('\nBackups on disk:'); - for (const file of saved) { - console.log(` ${file}`); + console.log('\nBackups on disk:'); + for (const file of saved) { + console.log(` ${file}`); + } + } finally { + if (!serverOnly) { + serverChild.kill(); + await stopPortListener(PORT); + } } } diff --git a/scripts/s3-backup/ensure-certs.mjs b/scripts/s3-backup/ensure-certs.mjs index 72fa9ef..a6de5ee 100644 --- a/scripts/s3-backup/ensure-certs.mjs +++ b/scripts/s3-backup/ensure-certs.mjs @@ -5,16 +5,18 @@ import path from 'node:path'; import { CA_CERT, CERTS_DIR, HOST, SERVER_CERT, SERVER_KEY } from './config.mjs'; +const SQL_CA_DIR = '/var/opt/mssql/security/ca-certificates'; + export function certHosts(host) { - const hosts = new Set([host, '127.0.0.1', 'localhost']); + const hosts = new Set([host, '127.0.0.1', 'localhost', 's3backup.local']); for (const iface of Object.values(os.networkInterfaces())) { for (const addr of iface || []) { - if (addr.family === 'IPv4' && !addr.internal) { + if (addr.family === 'IPv4' && !addr.internal && !addr.address.startsWith('169.254.')) { hosts.add(addr.address); } } } - return [...hosts]; + return [...hosts].sort(); } export function ensureCerts(host = HOST) { @@ -81,18 +83,99 @@ ${altNames} fs.writeFileSync(marker, hostList); } +export function caTrustStatus(container = process.env.MSSQL_DOCKER_CONTAINER || 'mssql') { + if (!fs.existsSync(CA_CERT)) { + return { ok: false, inSync: false, restarted: false }; + } + const localFp = execSync(`openssl x509 -in "${CA_CERT}" -noout -fingerprint -sha256`, { + encoding: 'utf8', + }).trim(); + const remoteFp = spawnSync( + 'docker', + [ + 'exec', + container, + 'bash', + '-lc', + `test -f ${SQL_CA_DIR}/jtlsrv-s3-ca.pem && openssl x509 -in ${SQL_CA_DIR}/jtlsrv-s3-ca.pem -noout -fingerprint -sha256`, + ], + { encoding: 'utf8' } + ); + const inSync = remoteFp.status === 0 && remoteFp.stdout.trim() === localFp; + return { ok: remoteFp.status === 0, inSync, restarted: false }; +} + export function installCaTrust(container = process.env.MSSQL_DOCKER_CONTAINER || 'mssql') { if (!fs.existsSync(CA_CERT)) { ensureCerts(); } - const copied = spawnSync('docker', ['cp', CA_CERT, `${container}:/tmp/jtlsrv-s3-ca.pem`], { + const localFp = execSync(`openssl x509 -in "${CA_CERT}" -noout -fingerprint -sha256`, { + encoding: 'utf8', + }).trim(); + const remoteFp = spawnSync( + 'docker', + [ + 'exec', + container, + 'bash', + '-lc', + `test -f ${SQL_CA_DIR}/jtlsrv-s3-ca.pem && openssl x509 -in ${SQL_CA_DIR}/jtlsrv-s3-ca.pem -noout -fingerprint -sha256`, + ], + { encoding: 'utf8' } + ); + if (remoteFp.status === 0 && remoteFp.stdout.trim() === localFp) { + spawnSync('docker', [ + 'exec', + '-u', + 'root', + container, + 'bash', + '-lc', + 'grep -q s3backup.local /etc/hosts || echo "172.17.0.1 s3backup.local" >> /etc/hosts', + ]); + return { ok: true, restarted: false }; + } + + spawnSync('docker', ['exec', '-u', 'root', container, 'mkdir', '-p', SQL_CA_DIR], { stdio: 'pipe', }); - if (copied.status !== 0) { - return false; + spawnSync('docker', [ + 'exec', + '-u', + 'root', + container, + 'bash', + '-lc', + `rm -f ${SQL_CA_DIR}/*.pem ${SQL_CA_DIR}/*.crt`, + ]); + spawnSync('docker', [ + 'exec', + '-u', + 'root', + container, + 'bash', + '-lc', + 'grep -q s3backup.local /etc/hosts || echo "172.17.0.1 s3backup.local" >> /etc/hosts', + ]); + try { + execSync('grep -q s3backup.local /etc/hosts || echo "172.17.0.1 s3backup.local" >> /etc/hosts', { + stdio: 'pipe', + }); + } catch { + // optional on host } - const installed = spawnSync( + + const copied = spawnSync( + 'docker', + ['cp', CA_CERT, `${container}:${SQL_CA_DIR}/jtlsrv-s3-ca.pem`], + { stdio: 'pipe' } + ); + if (copied.status !== 0) { + return { ok: false, restarted: false }; + } + + const perms = spawnSync( 'docker', [ 'exec', @@ -101,9 +184,14 @@ export function installCaTrust(container = process.env.MSSQL_DOCKER_CONTAINER || container, 'bash', '-lc', - 'cp /tmp/jtlsrv-s3-ca.pem /usr/local/share/ca-certificates/jtlsrv-s3.crt && update-ca-certificates', + `chown mssql:mssql ${SQL_CA_DIR}/jtlsrv-s3-ca.pem && chmod 644 ${SQL_CA_DIR}/jtlsrv-s3-ca.pem`, ], { stdio: 'pipe' } ); - return installed.status === 0; + if (perms.status !== 0) { + return { ok: false, restarted: false }; + } + + const restarted = spawnSync('docker', ['restart', container], { stdio: 'pipe' }); + return { ok: true, restarted: restarted.status === 0 }; } diff --git a/scripts/s3-backup/server.mjs b/scripts/s3-backup/server.mjs index b671c87..0f8372f 100644 --- a/scripts/s3-backup/server.mjs +++ b/scripts/s3-backup/server.mjs @@ -2,7 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import https from 'node:https'; import path from 'node:path'; -import { URL } from 'node:url'; +import { URL, pathToFileURL } from 'node:url'; import { ACCESS_KEY, @@ -32,6 +32,9 @@ function send(res, status, body = '', headers = {}) { } function readBody(req) { + if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'DELETE') { + return Promise.resolve(Buffer.alloc(0)); + } return new Promise((resolve, reject) => { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); @@ -169,10 +172,17 @@ function abortMultipart(uploadId) { return true; } +function debug(...args) { + if (process.env.S3_BACKUP_DEBUG) { + console.error(...args); + } +} + async function handle(req, res) { const body = await readBody(req); + debug(`${req.method} ${req.url} len=${body.length}`); if (!authOk(req, body)) { - console.error(`${req.method} ${req.url} -> 403 auth failed`); + debug(`auth failed ${req.method} ${req.url}`); return send(res, 403, xml('AccessDeniedAccess Denied')); } @@ -195,6 +205,10 @@ async function handle(req, res) { return send(res, 200, listBucketXml(query.get('prefix') || '')); } + if (req.method === 'HEAD' && !key) { + return send(res, 200, ''); + } + if (req.method === 'GET' && key) { const file = objectPath(key); if (!fs.existsSync(file)) { @@ -258,7 +272,6 @@ async function handle(req, res) { } export function startServer() { - ensureCerts(HOST); fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(TMP_DIR, { recursive: true }); @@ -272,7 +285,17 @@ export function startServer() { } ); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + reject(new Error(`Port ${PORT} is already in use`)); + return; + } + reject(err); + }); + server.on('secureConnection', (tlsSocket) => { + debug(`tls ${tlsSocket.remoteAddress}:${tlsSocket.remotePort}`); + }); server.listen(PORT, BIND, () => { console.log(`S3 endpoint https://${HOST}:${PORT}/${BUCKET} -> ${DATA_DIR}`); resolve(server); @@ -280,6 +303,9 @@ export function startServer() { }); } -if (import.meta.url === `file://${process.argv[1]}`) { - startServer(); +if (process.env.S3_BACKUP_CHILD || import.meta.url === pathToFileURL(process.argv[1]).href) { + startServer().catch((err) => { + console.error(err.message || err); + process.exit(1); + }); } diff --git a/scripts/s3-backup/sigv4.mjs b/scripts/s3-backup/sigv4.mjs index 318145b..c84066a 100644 --- a/scripts/s3-backup/sigv4.mjs +++ b/scripts/s3-backup/sigv4.mjs @@ -22,7 +22,6 @@ function parseAuthHeader(header) { accessKey: credential[0], date: credential[1], region: credential[2], - service: credential[3], signedHeaders: parts.SignedHeaders.split(';'), signature: parts.Signature, }; @@ -59,6 +58,7 @@ export function verifyRequest(req, body, { accessKey, secretKey, region = 'us-ea return false; } + const regionToUse = parsed.region || region; const amzDate = getHeader(req, 'x-amz-date'); const declaredPayload = getHeader(req, 'x-amz-content-sha256'); const payloadHash = @@ -72,16 +72,16 @@ export function verifyRequest(req, body, { accessKey, secretKey, region = 'us-ea payloadHash, ].join('\n'); - const scope = `${parsed.date}/${region}/s3/aws4_request`; + const scope = `${parsed.date}/${regionToUse}/s3/aws4_request`; const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, hash(canonical)].join('\n'); const signingKey = hmac( - hmac( - hmac(hmac(`AWS4${secretKey}`, parsed.date), region), - 's3' - ), + hmac(hmac(hmac(`AWS4${secretKey}`, parsed.date), regionToUse), 's3'), 'aws4_request' ); const expected = hmac(signingKey, stringToSign, 'hex'); + if (expected.length !== parsed.signature.length) { + return false; + } return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parsed.signature, 'hex')); } diff --git a/src/queries/create-order.js b/src/queries/create-order.js index ba65c04..4e71a0c 100644 --- a/src/queries/create-order.js +++ b/src/queries/create-order.js @@ -133,32 +133,134 @@ async function allocatePk(transaction, tableName) { return pk; } +const VERSANDPOSITION_TYPE = 2; +const ZAHLUNG_TYPE_ZAHLUNG = 10; +const NIST_READONLY_NICHT_AENDERBAR = 2; +const NIST_EXTERNE_RECHNUNG_KEINE = 2; + +function parseImportSetting(order) { + return toNumber(order.settings?.importSetting, 0); +} + +function parseInvoiceSetting(order) { + return toNumber(order.settings?.invoiceSetting, 0); +} + +/** PosOrderSettingsService: importSetting=0 → NichtAenderbar. */ +function resolveNIstReadOnly(order) { + return parseImportSetting(order) === 0 ? NIST_READONLY_NICHT_AENDERBAR : 0; +} + +/** + * PosOrderSettingsService: importSetting=0 → KeineRechnungKeineAusgabe; + * importSetting 2–5 or invoiceSetting Create flag → Wawi invoice. + */ +function resolveNIstExterneRechnung(order) { + const importSetting = parseImportSetting(order); + const invoiceSetting = parseInvoiceSetting(order); + if (importSetting >= 2 && importSetting <= 5) return 0; + if (invoiceSetting & 1) return 0; + if (importSetting === 0) return NIST_EXTERNE_RECHNUNG_KEINE; + return 0; +} + +function isVersandposition(item) { + return toNumber(item.type, 0) === VERSANDPOSITION_TYPE; +} + +function hasVersandposition(items) { + return items.some(isVersandposition); +} + +function hasNonReturnSaleItems(items) { + return items.some((item) => toNumber(item.isReturn, 0) === 0); +} + +function shouldInjectSelbstabholerShipping(items) { + if (hasVersandposition(items)) return false; + return hasNonReturnSaleItems(items); +} + +async function lookupVersandArt(transaction, shippingName) { + const name = String(shippingName ?? '').trim() || 'Selbstabholer'; + const tryLookup = async (cName) => { + const result = await new sql.Request(transaction) + .input('cName', sql.NVarChar, cName) + .query(` + SELECT TOP 1 kVersandArt, cName, fPrice, fMwSt + FROM dbo.tVersandArt + WHERE cName = @cName + `); + return result.recordset[0] ?? null; + }; + + let row = await tryLookup(name); + if (!row && name !== 'Selbstabholer') { + row = await tryLookup('Selbstabholer'); + } + return row; +} + +function syntheticShippingItem(versandArt) { + const vat = toNumber(versandArt.fMwSt, 19); + const gross = toNumber(versandArt.fPrice, 0); + const net = gross / (1 + vat / 100); + return { + type: String(VERSANDPOSITION_TYPE), + quantity: '1', + name: versandArt.cName, + priceGross: String(gross), + priceNet: String(net), + vat: String(vat), + isReturn: '0', + }; +} + /** * Resolves a Zahlungsart by name; creates it on the fly (including its PK * from dbo.tpk) when it does not exist yet. */ async function resolveZahlungsart(transaction, name, cache) { - const key = String(name || 'Bar'); - const cached = cache.get(key.toLowerCase()); + const lookupName = String(name || 'Bar'); + const cacheKey = lookupName.toLowerCase(); + const cached = cache.get(cacheKey); if (cached) { return cached; } - const existing = await new sql.Request(transaction) - .input('cName', sql.NVarChar, key) + let row = null; + const exact = await new sql.Request(transaction) + .input('cName', sql.NVarChar, lookupName) .query('SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE cName = @cName'); + row = exact.recordset[0] ?? null; + + if (!row) { + const lang = await new sql.Request(transaction) + .input('cName', sql.NVarChar, lookupName) + .query(` + SELECT TOP 1 z.kZahlungsart, z.cName + FROM dbo.tZahlungsArtSprache zs + INNER JOIN dbo.tZahlungsart z ON z.kZahlungsart = zs.kZahlungsart + WHERE zs.cName = @cName + `); + row = lang.recordset[0] ?? null; + } + + if (!row) { + const ci = await new sql.Request(transaction) + .input('cName', sql.NVarChar, lookupName) + .query('SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE UPPER(cName) = UPPER(@cName)'); + row = ci.recordset[0] ?? null; + } let zahlungsart; - if (existing.recordset[0]) { - zahlungsart = { - kZahlungsart: existing.recordset[0].kZahlungsart, - cName: existing.recordset[0].cName, - }; + if (row) { + zahlungsart = { kZahlungsart: row.kZahlungsart, cName: row.cName }; } else { const kZahlungsart = await allocatePk(transaction, 'tZahlungsart'); await new sql.Request(transaction) .input('kZahlungsart', sql.Int, kZahlungsart) - .input('cName', sql.NVarChar, key) + .input('cName', sql.NVarChar, lookupName) .query(` INSERT INTO dbo.tZahlungsart (kZahlungsart, cName, cPrtString, nLastschrift, cPrtStringVor, cPaymentOption, cKonto, @@ -166,10 +268,10 @@ async function resolveZahlungsart(transaction, name, cache) { nMatchingOptionen, nIstStandard, nAktiv) VALUES (@kZahlungsart, @cName, '', 0, '', '', '', 0, 0, 0, 0, 0, 0, 0, 1) `); - zahlungsart = { kZahlungsart, cName: key }; + zahlungsart = { kZahlungsart, cName: lookupName }; } - cache.set(key.toLowerCase(), zahlungsart); + cache.set(cacheKey, zahlungsart); return zahlungsart; } @@ -214,8 +316,8 @@ async function createCustomer(transaction, { customerNumber, address, defaults } .input('nDebitorennr', sql.Int, Number(a.debtorNumber) || 0) .query(` DECLARE @returnValue INT; - DECLARE @p1 dbo.TYPE_spkundeInsert; - INSERT INTO @p1 + DECLARE @kunde_daten dbo.TYPE_spkundeInsert; + INSERT INTO @kunde_daten (kInetKunde, kKundenKategorie, cKundenNr, cFirma, cAnrede, cTitel, cVorname, cName, cStrasse, cPLZ, cOrt, cLand, cTel, cFax, cEMail, dErstellt, cMobil, fRabatt, cUSTID, cNewsletter, cZusatz, cEbayName, kBuyer, cAdressZusatz, cGeburtstag, cWWW, cSperre, cPostID, kKundenGruppe, @@ -229,7 +331,7 @@ async function createCustomer(transaction, { customerNumber, address, defaults } 0, @kSprache, @cISO, @cBundesland, @cHerkunft, @cKassenKunde, N'', 0, @nDebitorennr, N'', 0, 0, 0, 0, 0, NULL, 0, 0, 0); - EXEC @returnValue = Kunde.spKundeInsert @daten = @p1; + EXEC @returnValue = Kunde.spKundeInsert @daten = @kunde_daten; SELECT @returnValue AS kKunde; `); @@ -252,14 +354,6 @@ function resolveAuftragCKundenNr(order) { return isWalkInOrder(order) ? '0' : String(order.customerNumber || ''); } -/** POS settings.invoiceSetting: 0 = external/POS invoice (nIstExterneRechnung=1). */ -function resolveNIstExterneRechnung(order) { - const inv = order.settings?.invoiceSetting; - if (inv === undefined || inv === null) return 1; - if (inv === true || inv === 1 || inv === '1' || String(inv).toLowerCase() === 'true') return 0; - return 1; -} - async function lookupKassenkunde(transaction, defaults) { const result = await new sql.Request(transaction).query( "SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKassenKunde = 'Y' ORDER BY kKunde" @@ -349,15 +443,19 @@ async function insertOrderItem(transaction, kAuftrag, item) { const discount = toNumber(item.discountPercent, 0); const kSteuerklasse = steuerklasseForVat(vat); const sku = String(item.sku || '').trim(); + const positionType = isVersandposition(item) ? VERSANDPOSITION_TYPE : toNumber(item.type, 0); let kArtikel = null; - if (sku) { + if (sku && positionType !== VERSANDPOSITION_TYPE) { const result = await new sql.Request(transaction) .input('cArtNr', sql.NVarChar, sku) .query('SELECT TOP 1 kArtikel FROM dbo.tArtikel WHERE cArtNr = @cArtNr'); kArtikel = result.recordset[0]?.kArtikel ?? null; } + const nType = positionType === VERSANDPOSITION_TYPE ? VERSANDPOSITION_TYPE : (kArtikel ? 1 : 0); + const nReserviert = nType === VERSANDPOSITION_TYPE ? 0 : 1; + const result = await new sql.Request(transaction) .input('kArtikel', sql.Int, kArtikel) .input('kAuftrag', sql.Int, kAuftrag) @@ -368,7 +466,8 @@ async function insertOrderItem(transaction, kAuftrag, item) { .input('fVkNetto', sql.Float, priceNet) .input('fMwSt', sql.Float, vat) .input('kSteuerklasse', sql.Int, kSteuerklasse) - .input('nType', sql.Int, kArtikel ? 1 : 0) + .input('nType', sql.Int, nType) + .input('nReserviert', sql.Int, nReserviert) .input('cEinheit', sql.NVarChar, item.unit || '') .input('fRabatt', sql.Float, discount) .query(` @@ -377,7 +476,7 @@ async function insertOrderItem(transaction, kAuftrag, item) { (kArtikel, kAuftrag, cArtNr, nReserviert, cName, cHinweis, fAnzahl, fVkNetto, fMwSt, cNameStandard, kSteuerklasse, nType, cEinheit, fFaktor, kSteuerschluessel, fRabatt) OUTPUT inserted.kAuftragPosition INTO @t - VALUES (@kArtikel, @kAuftrag, @cArtNr, 1, @cName, @cHinweis, @fAnzahl, @fVkNetto, @fMwSt, + VALUES (@kArtikel, @kAuftrag, @cArtNr, @nReserviert, @cName, @cHinweis, @fAnzahl, @fVkNetto, @fMwSt, @cName, @kSteuerklasse, @nType, @cEinheit, 1.0, 3, @fRabatt); SELECT kAuftragPosition FROM @t; `); @@ -486,11 +585,43 @@ function isOrderDelivered(order) { return deliver === true || String(deliver) === '1' || String(deliver).toLowerCase() === 'true'; } +async function recalculateAuftragEckdaten(transaction, kAuftrag) { + await new sql.Request(transaction).input('kAuftrag', sql.Int, kAuftrag).query(` + DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen; + INSERT INTO @eckdaten_calc VALUES (@kAuftrag); + EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc; + `); +} + +async function getOffenerAuftragswert(transaction, kAuftrag) { + const result = await new sql.Request(transaction) + .input('kAuftrag', sql.Int, kAuftrag) + .query(` + SELECT ROUND(tAuftragEckdaten.fOffenerWertOhneStorno, 2) AS fOffenerAuftragswert + FROM Verkauf.tAuftrag + LEFT JOIN Verkauf.tAuftragEckdaten ON tAuftragEckdaten.kAuftrag = tAuftrag.kAuftrag + WHERE tAuftrag.kAuftrag = @kAuftrag + `); + const value = result.recordset[0]?.fOffenerAuftragswert; + return value == null ? null : toNumber(value, 0); +} + +function isNewPayment(payment) { + const paymentId = toNumber(payment.paymentId, 0); + return paymentId <= 0; +} + async function insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache) { const zahlungsart = await resolveZahlungsart(transaction, payment.paymentMethodName || order.paymentMethodName, zahlungsartCache); const kZahlung = await allocatePk(transaction, 'tZahlung'); - await new sql.Request(transaction) + await recalculateAuftragEckdaten(transaction, kAuftrag); + const fOffenerWert = await getOffenerAuftragswert(transaction, kAuftrag); + if (fOffenerWert == null) { + throw new Error(`no open order amount for kAuftrag=${kAuftrag}`); + } + + const result = await new sql.Request(transaction) .input('kZahlung', sql.Int, kZahlung) .input('cName', sql.NVarChar, zahlungsart.cName) .input('dDatum', sql.DateTime, orderDate) @@ -499,13 +630,26 @@ async function insertPayment(transaction, kAuftrag, payment, order, orderDate, z .input('kBenutzer', sql.Int, config.kBenutzer) .input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart) .input('cExternalTransactionId', sql.NVarChar, order.externalOrderNumber || '') + .input('fOffenerWert', sql.Float, fOffenerWert) .query(` - INSERT INTO dbo.tZahlung - (kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart, - nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung) - VALUES (@kZahlung, @cName, @dDatum, @fBetrag, @kBestellung, @kBenutzer, 0, '', @kZahlungsart, - 0, @cExternalTransactionId, 0, 0, '', 0) + IF EXISTS ( + SELECT 1 FROM Verkauf.tAuftragEckdaten + WHERE kAuftrag = @kBestellung + AND ROUND(fOffenerWertOhneStorno, 2) = ROUND(@fOffenerWert, 2) + ) + BEGIN + INSERT INTO dbo.tZahlung + (kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart, + nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung) + VALUES (@kZahlung, @cName, @dDatum, @fBetrag, @kBestellung, @kBenutzer, 0, '', @kZahlungsart, + 0, @cExternalTransactionId, 0, ${ZAHLUNG_TYPE_ZAHLUNG}, '', 0); + END + SELECT @@ROWCOUNT AS inserted; `); + + if (!result.recordset[0]?.inserted) { + throw new Error(`payment insert skipped: open amount changed for kAuftrag=${kAuftrag}`); + } } export async function createOrder(order) { @@ -537,6 +681,16 @@ export async function createOrder(order) { const zahlungsart = await resolveZahlungsart(transaction, order.paymentMethodName, zahlungsartCache); const cAuftragsNr = await nextOrderNumber(transaction, orderDate); + const orderItems = [...(order.orderItems || [])]; + const versandArt = await lookupVersandArt(transaction, order.shippingName); + let kVersandArt = versandArt?.kVersandArt ?? defaults.kVersandArt; + if (shouldInjectSelbstabholerShipping(orderItems) && versandArt) { + orderItems.push(syntheticShippingItem(versandArt)); + } + + const nIstReadOnly = resolveNIstReadOnly(order); + const nIstExterneRechnung = resolveNIstExterneRechnung(order); + const resultAuftrag = await new sql.Request(transaction) .input('cAuftragsNr', sql.NVarChar, cAuftragsNr) .input('dErstellt', sql.DateTime, orderDate) @@ -549,11 +703,12 @@ export async function createOrder(order) { .input('kShop', sql.Int, getActiveShopId() || null) .input('cKundenNr', sql.NVarChar, resolveAuftragCKundenNr(order)) .input('cVersandlandISO', sql.NVarChar, (order.shippingAddress?.countryIso || 'DE').toUpperCase()) - .input('kVersandArt', sql.Int, defaults.kVersandArt) + .input('kVersandArt', sql.Int, kVersandArt) .input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart) .input('kKundengruppe', sql.Int, kKundengruppe) .input('cExterneAuftragsnummer', sql.NVarChar, order.externalOrderNumber || '') - .input('nIstExterneRechnung', sql.Int, resolveNIstExterneRechnung(order)) + .input('nIstExterneRechnung', sql.Int, nIstExterneRechnung) + .input('nIstReadOnly', sql.Int, nIstReadOnly) .query(` DECLARE @t TABLE ([kAuftrag] INT); INSERT INTO Verkauf.tAuftrag @@ -565,7 +720,7 @@ export async function createOrder(order) { VALUES (@cAuftragsNr, @dErstellt, 0, @kBenutzer, @kKunde, @kBenutzer, 1, 1.0, @kFirmaHistory, @kSprache, @cWaehrung, 1.0, 0.0, @cWaehrung, @kPlattform, @kShop, @cKundenNr, @cVersandlandISO, @kVersandArt, @kZahlungsart, @kKundengruppe, - @cExterneAuftragsnummer, @nIstExterneRechnung, 'Y', 2, 0, 10); + @cExterneAuftragsnummer, @nIstExterneRechnung, 'Y', @nIstReadOnly, 0, 10); SELECT kAuftrag FROM @t; `); @@ -578,27 +733,30 @@ export async function createOrder(order) { await insertOrderAddress(transaction, kAuftrag, kKunde, order.billingAddress, 1); const deliveredItems = []; - for (const item of order.orderItems || []) { + for (const item of orderItems) { const kAuftragPosition = await insertOrderItem(transaction, kAuftrag, item); const kPosAuftragPosition = Number.parseInt(item.externalId, 10); - await insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition); - if (kAuftragPosition != null) { + if (Number.isInteger(kPosAuftragPosition)) { + await insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition); + } + if (kAuftragPosition != null && !isVersandposition(item)) { deliveredItems.push({ kAuftragPosition, quantity: toNumber(item.quantity, 1) }); } } for (const payment of order.payments || []) { + if (!isNewPayment(payment)) continue; await insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache); } if (isOrderDelivered(order)) { - await deliverOrder(transaction, config.kBenutzer, kAuftrag, defaults.kVersandArt, deliveredItems); + await deliverOrder(transaction, config.kBenutzer, kAuftrag, kVersandArt, deliveredItems); } await new sql.Request(transaction).input('kAuftrag', sql.Int, kAuftrag).query(` - DECLARE @p1 Verkauf.TYPE_spAuftragEckdatenBerechnen; - INSERT INTO @p1 VALUES (@kAuftrag); - EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @p1; + DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen; + INSERT INTO @eckdaten_calc VALUES (@kAuftrag); + EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc; `); await transaction.commit();