From 056c9e18cd80a1795cf49ca2f83de073bab9a8d9 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 15 Jul 2026 01:25:38 +0200 Subject: [PATCH] u --- .gitignore | 5 +- jtlsrv-cpp/src/db/pool.cpp | 21 +- jtlsrv-cpp/src/db/pool.hpp | 3 + jtlsrv-cpp/src/endpoints/order.cpp | 37 ++- jtlsrv-cpp/src/queries/commit.hpp | 5 +- jtlsrv-cpp/src/queries/create_order.hpp | 92 +++++-- jtlsrv-cpp/src/queries/delivery.hpp | 4 + jtlsrv-cpp/src/queries/reserve.hpp | 5 +- jtlsrv-cpp/src/queries/stock_shortage.hpp | 137 +++++++++++ jtlsrv-cpp/src/queries/warehouse.hpp | 16 ++ package.json | 1 + scripts/s3-backup/backup.mjs | 154 ++++++++++++ scripts/s3-backup/config.mjs | 40 +++ scripts/s3-backup/ensure-certs.mjs | 109 +++++++++ scripts/s3-backup/server.mjs | 285 ++++++++++++++++++++++ scripts/s3-backup/sigv4.mjs | 90 +++++++ src/endpoints/order.js | 26 +- src/queries/create-order.js | 59 +++-- src/queries/delivery/index.js | 12 +- src/queries/delivery/stock-shortage.js | 112 +++++++++ src/queries/delivery/warehouse.js | 32 +++ 21 files changed, 1181 insertions(+), 64 deletions(-) create mode 100644 jtlsrv-cpp/src/queries/stock_shortage.hpp create mode 100644 scripts/s3-backup/backup.mjs create mode 100644 scripts/s3-backup/config.mjs create mode 100644 scripts/s3-backup/ensure-certs.mjs create mode 100644 scripts/s3-backup/server.mjs create mode 100644 scripts/s3-backup/sigv4.mjs create mode 100644 src/queries/delivery/stock-shortage.js diff --git a/.gitignore b/.gitignore index 656342c..990c78a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ node_modules/ certs/ logs/ capturedDataReference -decompiledReference \ No newline at end of file +decompiledReference +scripts/s3-backup/data/ +scripts/s3-backup/tmp/ +scripts/s3-backup/certs/ \ No newline at end of file diff --git a/jtlsrv-cpp/src/db/pool.cpp b/jtlsrv-cpp/src/db/pool.cpp index edc4e36..2646d27 100644 --- a/jtlsrv-cpp/src/db/pool.cpp +++ b/jtlsrv-cpp/src/db/pool.cpp @@ -14,9 +14,11 @@ OdbcPool& get_pool() { return g_pool; } OdbcPool::~OdbcPool() { disconnect(); } -static void odbc_log_diag(SQLSMALLINT handle_type, SQLHANDLE handle, const char* ctx) { +static void odbc_log_diag(SQLSMALLINT handle_type, SQLHANDLE handle, const char* ctx, + std::string* capture = nullptr) { if (!handle) { logc::warn("ODBC %s failed (no handle)", ctx); + if (capture && capture->empty()) *capture = std::string(ctx) + ": no handle"; return; } SQLSMALLINT rec = 0; @@ -30,9 +32,14 @@ static void odbc_log_diag(SQLSMALLINT handle_type, SQLHANDLE handle, const char* if (diag_rc != SQL_SUCCESS && diag_rc != SQL_SUCCESS_WITH_INFO) break; any = true; logc::warn("ODBC %s: %s - %s (%d)", ctx, state, msg, (int)native); + if (capture && capture->empty()) { + *capture = std::string(reinterpret_cast(state)) + " - " + + std::string(reinterpret_cast(msg)); + } } if (!any) { logc::warn("ODBC %s failed (no diag)", ctx); + if (capture && capture->empty()) *capture = std::string(ctx) + ": no diagnostic"; } } @@ -287,28 +294,34 @@ static bool fetch_results(SQLHSTMT hstmt, ResultSet& out) { bool OdbcPool::execute(Connection* c, const std::string& sql, const std::vector& params, ResultSet& out) { if (!c) { + last_error_ = "no connection"; logc::warn("ODBC execute: no connection"); return false; } SQLRETURN rc = SQLPrepare(c->hstmt, (SQLCHAR*)sql.c_str(), SQL_NTS); if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) { - odbc_log_diag(SQL_HANDLE_STMT, c->hstmt, "prepare"); + last_error_.clear(); + odbc_log_diag(SQL_HANDLE_STMT, c->hstmt, "prepare", &last_error_); return false; } std::vector indicators; if (!params.empty() && !bind_params(c->hstmt, params, indicators)) { - odbc_log_diag(SQL_HANDLE_STMT, c->hstmt, "bind"); + last_error_.clear(); + odbc_log_diag(SQL_HANDLE_STMT, c->hstmt, "bind", &last_error_); return false; } rc = SQLExecute(c->hstmt); if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO && rc != SQL_NO_DATA) { - odbc_log_diag(SQL_HANDLE_STMT, c->hstmt, "execute"); + last_error_.clear(); + odbc_log_diag(SQL_HANDLE_STMT, c->hstmt, "execute", &last_error_); return false; } + last_error_.clear(); + fetch_results(c->hstmt, out); SQLFreeStmt(c->hstmt, SQL_UNBIND); diff --git a/jtlsrv-cpp/src/db/pool.hpp b/jtlsrv-cpp/src/db/pool.hpp index 9c7b7bf..65a07a2 100644 --- a/jtlsrv-cpp/src/db/pool.hpp +++ b/jtlsrv-cpp/src/db/pool.hpp @@ -85,10 +85,13 @@ public: void release(Connection* c); + const std::string& last_error() const { return last_error_; } + private: SQLHENV henv_ = SQL_NULL_HENV; std::vector conns_; std::mutex mutex_; + std::string last_error_; Connection* checkout_raw(); }; diff --git a/jtlsrv-cpp/src/endpoints/order.cpp b/jtlsrv-cpp/src/endpoints/order.cpp index 733c877..4459a9f 100644 --- a/jtlsrv-cpp/src/endpoints/order.cpp +++ b/jtlsrv-cpp/src/endpoints/order.cpp @@ -36,16 +36,30 @@ void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) { try { nlohmann::json created = order::create_order(order); - logc::success("order %s (kAuftrag=%s) created for externalId=%s", - created.value("orderNumber", "").c_str(), - created.value("orderId", "").c_str(), - externalOrderId.c_str()); - results.push_back({ - {"status", "OK"}, - {"externalOrderId", externalOrderId}, - {"message", ""} - }); - ++successful; + if (created.value("alreadyExists", false)) { + std::string msg = "order already mapped, skipped save (kAuftrag=" + + created.value("orderId", "") + ", " + + created.value("orderNumber", "") + ")"; + logc::error("order externalId=%s skipped: %s", + externalOrderId.c_str(), msg.c_str()); + results.push_back({ + {"status", "ERROR"}, + {"externalOrderId", externalOrderId}, + {"message", msg} + }); + ++failed; + } else { + logc::success("order %s (kAuftrag=%s) created for externalId=%s", + created.value("orderNumber", "").c_str(), + created.value("orderId", "").c_str(), + externalOrderId.c_str()); + results.push_back({ + {"status", "OK"}, + {"externalOrderId", externalOrderId}, + {"message", ""} + }); + ++successful; + } } catch (const std::exception& ex) { logc::error("order externalId=%s failed: %s", externalOrderId.c_str(), ex.what()); results.push_back({ @@ -57,7 +71,8 @@ void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) { } } - resp.send_json(200, results); + int http_status = (failed > 0) ? 500 : 200; + resp.send_json(http_status, results); if (orders.empty()) { logc::info("POST /v1/order: no orders in body"); diff --git a/jtlsrv-cpp/src/queries/commit.hpp b/jtlsrv-cpp/src/queries/commit.hpp index 29f0ae4..24f256c 100644 --- a/jtlsrv-cpp/src/queries/commit.hpp +++ b/jtlsrv-cpp/src/queries/commit.hpp @@ -25,7 +25,10 @@ inline void commit_picklists(OdbcPool::Connection* c, int kBenutzer, int kSessio }; ResultSet rs; if (!get_pool().execute(c, sql, ps, rs)) { - throw std::runtime_error("commit_picklists failed"); + const std::string& detail = get_pool().last_error(); + throw std::runtime_error(detail.empty() + ? "commit_picklists failed" + : "commit_picklists failed: " + detail); } } diff --git a/jtlsrv-cpp/src/queries/create_order.hpp b/jtlsrv-cpp/src/queries/create_order.hpp index d1c6626..caa673f 100644 --- a/jtlsrv-cpp/src/queries/create_order.hpp +++ b/jtlsrv-cpp/src/queries/create_order.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include #include @@ -297,6 +298,26 @@ inline bool is_walk_in_order(const nlohmann::json& order) { && billing.value("firstName", "").empty(); } +inline std::string resolve_auftrag_c_kunden_nr(const nlohmann::json& order) { + if (is_walk_in_order(order)) return "0"; + 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; @@ -484,14 +505,27 @@ inline int parse_pos_auftrag_id(const std::string& external_id) { try { return std::stoi(external_id); } catch (...) { return 0; } } +inline bool external_order_numbers_match(const std::string& mapped, const std::string& incoming) { + if (incoming.empty()) return true; + if (mapped.size() != incoming.size()) return false; + for (size_t i = 0; i < mapped.size(); ++i) { + if (std::tolower(static_cast(mapped[i])) + != std::tolower(static_cast(incoming[i]))) { + return false; + } + } + return true; +} + // PosOrderCreationService.CheckIfOrderExists — already-imported POS order. inline std::optional> find_existing_pos_order( - OdbcPool::Connection* c, int kPosAuftrag) { + OdbcPool::Connection* c, int kPosAuftrag, const std::string& external_order_number) { int kShopSubShop = get_active_shop_subshop_id(); if (kPosAuftrag <= 0 || kShopSubShop <= 0) return std::nullopt; const char* sql = - "SELECT TOP 1 m.kAuftrag, a.cAuftragsNr FROM Pos.tAuftragMapping m " + "SELECT TOP 1 m.kAuftrag, a.cAuftragsNr, ISNULL(a.cExterneAuftragsnummer, '') AS cExterneAuftragsnummer " + "FROM Pos.tAuftragMapping m " "LEFT JOIN Verkauf.tAuftrag a ON a.kAuftrag = m.kAuftrag " "WHERE m.kPosAuftrag = ? AND m.kShopSubShop = ? AND m.kAuftrag IS NOT NULL " "ORDER BY m.kAuftrag DESC"; @@ -509,6 +543,10 @@ inline std::optional> find_existing_pos_order( int kAuftrag = std::stoi(rs[0][0].str); if (kAuftrag <= 0) return std::nullopt; std::string order_number = rs[0].size() > 1 ? rs[0][1].str : ""; + std::string mapped_external = rs[0].size() > 2 ? rs[0][2].str : ""; + if (!external_order_numbers_match(mapped_external, external_order_number)) { + return std::nullopt; + } return std::make_pair(kAuftrag, order_number); } @@ -516,22 +554,23 @@ inline void upsert_pos_order_mapping(OdbcPool::Connection* c, int kAuftrag, int int kShopSubShop = get_active_shop_subshop_id(); if (kPosAuftrag <= 0 || kShopSubShop <= 0) return; - const char* sql = - "MERGE INTO Pos.tAuftragMapping WITH (HOLDLOCK) AS Target " - "USING (SELECT ? AS kAuftrag, ? AS kPosAuftrag, ? AS kShopSubShop) AS Source " - "ON Target.kPosAuftrag = Source.kPosAuftrag AND Target.kShopSubShop = Source.kShopSubShop " - "WHEN MATCHED THEN UPDATE SET Target.kAuftrag = Source.kAuftrag " - "WHEN NOT MATCHED BY TARGET THEN " - "INSERT (kAuftrag, kPosAuftrag, kShopSubShop) " - "VALUES (Source.kAuftrag, Source.kPosAuftrag, Source.kShopSubShop)"; + ResultSet rs; + std::vector del_ps = { + {ParamType::Int, "", kPosAuftrag}, + {ParamType::Int, "", kShopSubShop}, + }; + get_pool().execute(c, + "DELETE FROM Pos.tAuftragMapping WHERE kPosAuftrag = ? AND kShopSubShop = ?", + del_ps, rs); - std::vector ps = { + std::vector ins_ps = { {ParamType::Int, "", kAuftrag}, {ParamType::Int, "", kPosAuftrag}, - {ParamType::Int, "", kShopSubShop} + {ParamType::Int, "", kShopSubShop}, }; - ResultSet rs; - get_pool().execute(c, sql, ps, rs); + get_pool().execute(c, + "INSERT INTO Pos.tAuftragMapping (kAuftrag, kPosAuftrag, kShopSubShop) VALUES (?, ?, ?)", + ins_ps, rs); } inline void insert_pos_order_position_mapping(OdbcPool::Connection* c, int kAuftragPosition, @@ -606,11 +645,13 @@ inline nlohmann::json create_order(const nlohmann::json& order) { auto* c = guard.get(); int kPosAuftrag = parse_pos_auftrag_id(order.value("externalId", "")); + std::string external_order_number = order.value("externalOrderNumber", ""); if (kPosAuftrag > 0) { - if (auto existing = find_existing_pos_order(c, kPosAuftrag)) { + if (auto existing = find_existing_pos_order(c, kPosAuftrag, external_order_number)) { return { {"orderId", std::to_string(existing->first)}, - {"orderNumber", existing->second} + {"orderNumber", existing->second}, + {"alreadyExists", true} }; } } @@ -645,12 +686,12 @@ inline nlohmann::json create_order(const nlohmann::json& order) { " (cAuftragsNr, dErstellt, nKomplettAusgeliefert, kBenutzer, kKunde, kBenutzerErstellt, nType, fFaktor," " kFirmaHistory, kSprache, cVersandlandWaehrung, fVersandlandWaehrungFaktor, fFinanzierungskosten," " cWaehrung, kPlattform, kShop, cKundenNr, cVersandlandISO, kVersandArt, kZahlungsart, kKundengruppe," - " cExterneAuftragsnummer)" + " cExterneAuftragsnummer, nIstExterneRechnung, cInet, nIstReadOnly, kShopauftrag, nLieferPrioritaet)" " OUTPUT inserted.kAuftrag INTO @t" " VALUES (?, ?, 0, ?, ?, ?, 1, 1.0," " ?, ?, ?, 1.0, 0.0," " ?, ?, ?, ?, ?, ?, ?, ?," - " ?);" + " ?, ?, ?, ?, 0, 10);" "SELECT kAuftrag FROM @t"; std::string shipping_iso = order.value("shippingAddress", nlohmann::json::object()).value("countryIso", "DE"); @@ -658,6 +699,8 @@ inline nlohmann::json create_order(const nlohmann::json& order) { [](unsigned char ch) { return std::toupper(ch); }); 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}, @@ -670,12 +713,15 @@ inline nlohmann::json create_order(const nlohmann::json& order) { {ParamType::NVarChar, currency_iso, 0}, {ParamType::Int, "", defaults.kPlattform}, {ParamType::Int, "", active_shop == 0 ? -1 : active_shop}, - {ParamType::NVarChar, order.value("customerNumber", ""), 0}, + {ParamType::NVarChar, cKundenNr, 0}, {ParamType::NVarChar, shipping_iso, 0}, {ParamType::Int, "", defaults.kVersandArt}, {ParamType::Int, "", json_int(zahlungsart, "kZahlungsart", 0)}, {ParamType::Int, "", kKundengruppe}, - {ParamType::NVarChar, order.value("externalOrderNumber", ""), 0} + {ParamType::NVarChar, order.value("externalOrderNumber", ""), 0}, + {ParamType::Int, "", nIstExterneRechnung}, + {ParamType::NVarChar, "Y", 0}, + {ParamType::Int, "", 2}, }; if (active_shop == 0) { ps[11] = Param::null_int(); @@ -719,7 +765,11 @@ inline nlohmann::json create_order(const nlohmann::json& order) { if (!get_pool().commit(c)) throw std::runtime_error("failed to commit transaction"); - return { {"orderId", std::to_string(kAuftrag)}, {"orderNumber", cAuftragsNr} }; + return { + {"orderId", std::to_string(kAuftrag)}, + {"orderNumber", cAuftragsNr}, + {"alreadyExists", false} + }; } catch (const std::exception&) { try { get_pool().rollback(c); } catch (...) {} throw; diff --git a/jtlsrv-cpp/src/queries/delivery.hpp b/jtlsrv-cpp/src/queries/delivery.hpp index 346c4fc..7db4133 100644 --- a/jtlsrv-cpp/src/queries/delivery.hpp +++ b/jtlsrv-cpp/src/queries/delivery.hpp @@ -3,6 +3,7 @@ #include "session.hpp" #include "warehouse.hpp" #include "reserve.hpp" +#include "stock_shortage.hpp" #include "commit.hpp" #include "deliver.hpp" #include "../db/pool.hpp" @@ -14,10 +15,13 @@ inline void deliver_order(OdbcPool::Connection* c, int kBenutzer, int kAuftrag, if (items.empty()) return; int kWarenLager = resolve_outgoing_warehouse(c); + int kWarenLagerPlatz = resolve_warehouse_place(c, kWarenLager); int kSessionId = open_session(c, kBenutzer); try { reserve_positions(c, kBenutzer, kSessionId, kWarenLager, items); + book_stock_shortfalls_and_rereserve(c, kBenutzer, kSessionId, kWarenLager, + kWarenLagerPlatz, items); commit_picklists(c, kBenutzer, kSessionId, kAuftrag); deliver_picklists(c, kBenutzer, kSessionId, kAuftrag, kVersandArt); } catch (...) { diff --git a/jtlsrv-cpp/src/queries/reserve.hpp b/jtlsrv-cpp/src/queries/reserve.hpp index ac2ac11..dde5a07 100644 --- a/jtlsrv-cpp/src/queries/reserve.hpp +++ b/jtlsrv-cpp/src/queries/reserve.hpp @@ -50,7 +50,10 @@ inline void reserve_positions(OdbcPool::Connection* c, int kBenutzer, int kSessi }; ResultSet rs; if (!get_pool().execute(c, sql, ps, rs)) { - throw std::runtime_error("reserve_positions failed"); + const std::string& detail = get_pool().last_error(); + throw std::runtime_error(detail.empty() + ? "reserve_positions failed" + : "reserve_positions failed: " + detail); } } diff --git a/jtlsrv-cpp/src/queries/stock_shortage.hpp b/jtlsrv-cpp/src/queries/stock_shortage.hpp new file mode 100644 index 0000000..c8750d2 --- /dev/null +++ b/jtlsrv-cpp/src/queries/stock_shortage.hpp @@ -0,0 +1,137 @@ +#pragma once +#include +#include +#include +#include +#include "reserve.hpp" +#include "warehouse.hpp" +#include "../db/pool.hpp" +#include "../log.hpp" + +namespace delivery { + +namespace { + +constexpr const char* POS_SHORTAGE_COMMENT = "Korrekturbuchung erstellt durch POS-Abgleich"; +constexpr int BUCHUNGSART_WARENEINGANG = 10; + +inline double get_reserved_quantity(OdbcPool::Connection* c, int kSessionId, int kAuftragPosition) { + const char* sql = + "SELECT ISNULL(SUM(pp.fAnzahl), 0) FROM dbo.tPicklistePos pp " + "INNER JOIN dbo.tPickliste p ON p.kPickliste = pp.kPickliste " + "WHERE p.kSessionId = ? AND pp.kBestellPos = ?"; + + std::vector ps = { + {ParamType::Int, "", kSessionId}, + {ParamType::Int, "", kAuftragPosition}, + }; + ResultSet rs; + if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) { + return 0.0; + } + try { + return std::stod(rs[0][0].str); + } catch (...) { + return 0.0; + } +} + +inline int get_position_artikel(OdbcPool::Connection* c, int kAuftragPosition) { + std::vector ps = {{ParamType::Int, "", kAuftragPosition}}; + ResultSet rs; + if (!get_pool().execute(c, + "SELECT kArtikel FROM Verkauf.tAuftragPosition WHERE kAuftragPosition = ?", + ps, rs) || rs.empty() || rs[0].empty() || rs[0][0].type == CellType::Null) { + return 0; + } + try { + return std::stoi(rs[0][0].str); + } catch (...) { + return 0; + } +} + +inline void book_wareneingang(OdbcPool::Connection* c, int kBenutzer, + int kWarenLagerPlatz, int kArtikel, double fehlmenge) { + const char* sql = + "DECLARE @kWarenlagerEingang INT;" + "EXEC dbo.spWarenlagerEingangSchreiben" + " @kArtikel = ?," + " @kWarenLagerPlatz = ?," + " @kLieferantenBestellungPos = 0," + " @kBenutzer = ?," + " @fAnzahl = ?," + " @fEkEinzel = 0," + " @cLieferscheinNr = ''," + " @cChargenNr = NULL," + " @dMHD = NULL," + " @dGeliefertAm = NULL," + " @cKommentar = ?," + " @kGutschriftPos = 0," + " @kLHM = 0," + " @kSessionId = 0," + " @kBuchungsart = ?," + " @kBestellPosUmlagerung = 0," + " @kRMRetourePos = 0," + " @nHistorieNichtSchreiben = 0," + " @kWarenlagerEingang = @kWarenlagerEingang OUTPUT;" + "SELECT @kWarenlagerEingang AS kWarenlagerEingang;"; + + std::vector ps = { + {ParamType::Int, "", kArtikel}, + {ParamType::Int, "", kWarenLagerPlatz}, + {ParamType::Int, "", kBenutzer}, + {ParamType::Double, "", 0, fehlmenge}, + {ParamType::NVarChar, POS_SHORTAGE_COMMENT, 0}, + {ParamType::Int, "", BUCHUNGSART_WARENEINGANG}, + }; + ResultSet rs; + if (!get_pool().execute(c, sql, ps, rs)) { + const std::string& detail = get_pool().last_error(); + throw std::runtime_error(detail.empty() + ? "spWarenlagerEingangSchreiben failed" + : "spWarenlagerEingangSchreiben failed: " + detail); + } +} + +} // namespace + +// PosStockPositionService.FehlbestandEinbuchen — book missing stock, then re-reserve. +inline void book_stock_shortfalls_and_rereserve(OdbcPool::Connection* c, int kBenutzer, + int kSessionId, int kWarenLager, + int kWarenLagerPlatz, + const std::vector& items) { + std::vector rereserve; + + for (const auto& item : items) { + if (item.kAuftragPosition <= 0 || item.quantity <= 0.0) continue; + + double reserved = get_reserved_quantity(c, kSessionId, item.kAuftragPosition); + double shortage = item.quantity - reserved; + if (shortage <= 0.0001) continue; + + int kArtikel = get_position_artikel(c, item.kAuftragPosition); + if (kArtikel <= 0) continue; + + logc::info("POS shortage booking: kBestellPos=%d kArtikel=%d reserved=%.4f need=%.4f book=%.4f", + item.kAuftragPosition, kArtikel, reserved, item.quantity, shortage); + book_wareneingang(c, kBenutzer, kWarenLagerPlatz, kArtikel, shortage); + rereserve.push_back({item.kAuftragPosition, shortage}); + } + + if (!rereserve.empty()) { + reserve_positions(c, kBenutzer, kSessionId, kWarenLager, rereserve); + } + + for (const auto& item : items) { + if (item.kAuftragPosition <= 0 || item.quantity <= 0.0) continue; + double reserved = get_reserved_quantity(c, kSessionId, item.kAuftragPosition); + if (reserved + 0.0001 < item.quantity) { + throw std::runtime_error( + "insufficient stock after POS shortage booking for kBestellPos=" + + std::to_string(item.kAuftragPosition)); + } + } +} + +} // namespace delivery diff --git a/jtlsrv-cpp/src/queries/warehouse.hpp b/jtlsrv-cpp/src/queries/warehouse.hpp index 12fca9f..ca4384a 100644 --- a/jtlsrv-cpp/src/queries/warehouse.hpp +++ b/jtlsrv-cpp/src/queries/warehouse.hpp @@ -18,4 +18,20 @@ inline int resolve_outgoing_warehouse(OdbcPool::Connection* c) { return std::stoi(rs[0][0].str); } +inline int resolve_warehouse_place(OdbcPool::Connection* c, int kWarenLager) { + int configured = config::get_int("JTL_KWARENLAGERPLATZ", 0); + if (configured > 0) return configured; + + std::vector ps = {{ParamType::Int, "", kWarenLager}}; + ResultSet rs; + if (!get_pool().execute(c, + "SELECT TOP 1 kWarenLagerPlatz FROM dbo.tWarenLagerPlatz " + "WHERE kWarenLager = ? AND ISNULL(nGesperrt, 0) = 0 " + "ORDER BY nPrio, kWarenLagerPlatz", ps, rs) || rs.empty() || rs[0].empty()) { + throw std::runtime_error("No warehouse place found for kWarenLager=" + + std::to_string(kWarenLager) + "; set JTL_KWARENLAGERPLATZ explicitly."); + } + return std::stoi(rs[0][0].str); +} + } // namespace delivery diff --git a/package.json b/package.json index 768a18f..3cd9978 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "main": "server.js", "scripts": { "cert": "node generate-cert.js", + "backup:s3": "node scripts/s3-backup/backup.mjs", "start": "node --watch server.js", "test:client": "node test-client.js" }, diff --git a/scripts/s3-backup/backup.mjs b/scripts/s3-backup/backup.mjs new file mode 100644 index 0000000..040e37c --- /dev/null +++ b/scripts/s3-backup/backup.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import https from 'node:https'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import dotenv from 'dotenv'; + +import { + ACCESS_KEY, + BUCKET, + DATA_DIR, + HOST, + PORT, + SECRET_KEY, + s3BaseUrl, +} from './config.mjs'; +import { ensureCerts, installCaTrust } from './ensure-certs.mjs'; +import { startServer } from './server.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +dotenv.config({ path: path.join(root, '.env') }); + +const args = new Set(process.argv.slice(2)); +const databases = args.has('--all') + ? ['eazybusiness', 'Mandant_3'] + : [process.env.MSSQL_DATABASE || 'eazybusiness']; + +const serverOnly = args.has('--server-only'); +const skipTrust = args.has('--skip-trust'); + +function sqlcmd(query) { + const server = process.env.MSSQL_SERVER || 'localhost'; + 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; +} + +function timestamp() { + return new Date().toISOString().replace(/[:.]/g, '-'); +} + +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; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`S3 endpoint did not start on https://${HOST}:${PORT}`); +} + +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}'; +`); +} + +function backupDatabase(db) { + const file = `${db}-${timestamp()}.bak`; + const url = `${s3BaseUrl()}/${file}`; + console.log(`Backing up ${db} -> ${url}`); + sqlcmd(` +BACKUP DATABASE [${db}] +TO URL = '${url}' +WITH FORMAT, COMPRESSION, STATS = 10; +`); + const onDisk = path.join(DATA_DIR, file); + if (!fs.existsSync(onDisk)) { + throw new Error(`Backup finished but file missing on disk: ${onDisk}`); + } + const mb = (fs.statSync(onDisk).size / (1024 * 1024)).toFixed(1); + console.log(`Saved ${onDisk} (${mb} MB)`); + return onDisk; +} + +async function main() { + ensureCerts(HOST); + if (!skipTrust) { + const trusted = installCaTrust(); + if (!trusted) { + 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"'); + } else { + console.log('Installed S3 CA into MSSQL container'); + } + } + + await startServer(); + await waitForServer(); + + if (serverOnly) { + console.log('Server running (--server-only). Ctrl+C to stop.'); + return; + } + + ensureCredential(); + const saved = []; + for (const db of databases) { + saved.push(backupDatabase(db)); + } + + console.log('\nBackups on disk:'); + for (const file of saved) { + console.log(` ${file}`); + } +} + +if (args.has('--help')) { + console.log(`Usage: node scripts/s3-backup/backup.mjs [options] + +Starts a local S3-compatible HTTPS endpoint and backs up MSSQL to disk. + +Options: + --all Backup eazybusiness and Mandant_3 + --server-only Start endpoint only, no backup + --skip-trust Skip installing CA cert into system trust store + +Reads MSSQL_* from .env in repo root. +Backups land in scripts/s3-backup/data/${BUCKET}/ +`); + process.exit(0); +} + +main().catch((err) => { + console.error(err.message || err); + process.exit(1); +}); diff --git a/scripts/s3-backup/config.mjs b/scripts/s3-backup/config.mjs new file mode 100644 index 0000000..6d48600 --- /dev/null +++ b/scripts/s3-backup/config.mjs @@ -0,0 +1,40 @@ +import { execSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(fileURLToPath(import.meta.url)); + +function dockerGateway() { + try { + const out = execSync("ip -4 route show default dev docker0 2>/dev/null | awk '{print $3}'", { + encoding: 'utf8', + }).trim(); + if (out) return out; + } catch { + // fall through + } + return '172.17.0.1'; +} + +export const HOST = process.env.S3_BACKUP_HOST || dockerGateway(); +export const BIND = process.env.S3_BACKUP_BIND || '0.0.0.0'; +export const PORT = Number(process.env.S3_BACKUP_PORT || 19443); +export const BUCKET = process.env.S3_BACKUP_BUCKET || 'sqlbackups'; +export const ACCESS_KEY = process.env.S3_BACKUP_ACCESS_KEY || 'jtlsrv-s3'; +export const SECRET_KEY = process.env.S3_BACKUP_SECRET_KEY || 'jtlsrv-s3-secret'; +export const REGION = process.env.S3_BACKUP_REGION || 'us-east-1'; + +export const DATA_DIR = path.join(root, 'data', BUCKET); +export const TMP_DIR = path.join(root, 'tmp'); +export const CERTS_DIR = path.join(root, 'certs'); +export const CA_CERT = path.join(CERTS_DIR, 'ca.pem'); +export const SERVER_KEY = path.join(CERTS_DIR, 'server-key.pem'); +export const SERVER_CERT = path.join(CERTS_DIR, 'server-cert.pem'); + +export function s3BaseUrl() { + return `s3://${HOST}:${PORT}/${BUCKET}`; +} + +export function httpsBaseUrl() { + return `https://${HOST}:${PORT}`; +} diff --git a/scripts/s3-backup/ensure-certs.mjs b/scripts/s3-backup/ensure-certs.mjs new file mode 100644 index 0000000..72fa9ef --- /dev/null +++ b/scripts/s3-backup/ensure-certs.mjs @@ -0,0 +1,109 @@ +import { execSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { CA_CERT, CERTS_DIR, HOST, SERVER_CERT, SERVER_KEY } from './config.mjs'; + +export function certHosts(host) { + const hosts = new Set([host, '127.0.0.1', 'localhost']); + for (const iface of Object.values(os.networkInterfaces())) { + for (const addr of iface || []) { + if (addr.family === 'IPv4' && !addr.internal) { + hosts.add(addr.address); + } + } + } + return [...hosts]; +} + +export function ensureCerts(host = HOST) { + fs.mkdirSync(CERTS_DIR, { recursive: true }); + const hosts = certHosts(host); + const marker = path.join(CERTS_DIR, 'hosts.txt'); + const hostList = hosts.join('\n'); + if ( + fs.existsSync(SERVER_CERT) && + fs.existsSync(SERVER_KEY) && + fs.existsSync(CA_CERT) && + fs.existsSync(marker) && + fs.readFileSync(marker, 'utf8') === hostList + ) { + return; + } + + const cnf = `${CERTS_DIR}/openssl.cnf`; + const altNames = hosts + .map((h, i) => (/^\d+\./.test(h) ? `IP.${i + 1} = ${h}` : `DNS.${i + 1} = ${h}`)) + .join('\n'); + + fs.writeFileSync( + cnf, + `[req] +distinguished_name = req_distinguished_name +x509_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = ${host} + +[v3_req] +subjectAltName = @alt_names +basicConstraints = CA:FALSE +keyUsage = digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth + +[alt_names] +${altNames} +` + ); + + execSync( + `openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout "${CERTS_DIR}/ca-key.pem" -out "${CA_CERT}" \ + -subj "/CN=JTL S3 Backup CA/O=JTL/C=DE"`, + { stdio: 'pipe' } + ); + + execSync( + `openssl req -newkey rsa:2048 -nodes \ + -keyout "${SERVER_KEY}" -out "${CERTS_DIR}/server.csr" \ + -config "${cnf}"`, + { stdio: 'pipe' } + ); + + execSync( + `openssl x509 -req -in "${CERTS_DIR}/server.csr" \ + -CA "${CA_CERT}" -CAkey "${CERTS_DIR}/ca-key.pem" -CAcreateserial \ + -out "${SERVER_CERT}" -days 3650 -extensions v3_req -extfile "${cnf}"`, + { stdio: 'pipe' } + ); + fs.writeFileSync(marker, hostList); +} + +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`], { + stdio: 'pipe', + }); + if (copied.status !== 0) { + return false; + } + const installed = spawnSync( + 'docker', + [ + 'exec', + '-u', + 'root', + container, + 'bash', + '-lc', + 'cp /tmp/jtlsrv-s3-ca.pem /usr/local/share/ca-certificates/jtlsrv-s3.crt && update-ca-certificates', + ], + { stdio: 'pipe' } + ); + return installed.status === 0; +} diff --git a/scripts/s3-backup/server.mjs b/scripts/s3-backup/server.mjs new file mode 100644 index 0000000..b671c87 --- /dev/null +++ b/scripts/s3-backup/server.mjs @@ -0,0 +1,285 @@ +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 { + ACCESS_KEY, + BIND, + BUCKET, + DATA_DIR, + HOST, + PORT, + REGION, + SECRET_KEY, + SERVER_CERT, + SERVER_KEY, + TMP_DIR, +} from './config.mjs'; +import { ensureCerts } from './ensure-certs.mjs'; +import { etagFor, verifyRequest } from './sigv4.mjs'; + +const uploads = new Map(); + +function xml(body) { + return `\n${body}`; +} + +function send(res, status, body = '', headers = {}) { + res.writeHead(status, { 'Content-Type': 'application/xml', ...headers }); + res.end(body); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function objectPath(key) { + return path.join(DATA_DIR, key); +} + +function ensureObjectDir(key) { + fs.mkdirSync(path.dirname(objectPath(key)), { recursive: true }); +} + +function listObjects(prefix = '') { + if (!fs.existsSync(DATA_DIR)) return []; + const out = []; + const walk = (dir, rel = '') => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const relPath = rel ? `${rel}/${entry.name}` : entry.name; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, relPath); + } else if (!prefix || relPath.startsWith(prefix)) { + const stat = fs.statSync(full); + out.push({ key: relPath, size: stat.size, mtime: stat.mtime }); + } + } + }; + walk(DATA_DIR); + return out; +} + +function parseRoute(urlString) { + const url = new URL(urlString, 'https://local'); + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length === 0) { + return { type: 'root' }; + } + if (parts[0] !== BUCKET) { + return { type: 'missing' }; + } + const key = parts.slice(1).join('/'); + return { type: 'object', key, query: url.searchParams }; +} + +function authOk(req, body) { + if (!req.headers.authorization) return false; + return verifyRequest(req, body, { + accessKey: ACCESS_KEY, + secretKey: SECRET_KEY, + region: REGION, + }); +} + +function listBucketsXml() { + return xml(` + jtlsrvjtlsrv + + + ${BUCKET} + 2026-01-01T00:00:00.000Z + + +`); +} + +function listBucketXml(prefix) { + const items = listObjects(prefix).map((item) => { + const etag = etagFor(fs.readFileSync(path.join(DATA_DIR, item.key))); + return ` + ${item.key} + ${item.mtime.toISOString()} + ${etag} + ${item.size} + STANDARD + `; + }).join('\n'); + return xml(` + ${BUCKET} + ${prefix} + 1000 + false + ${items} +`); +} + +function initiateMultipart(key) { + const uploadId = crypto.randomUUID(); + const dir = path.join(TMP_DIR, uploadId); + fs.mkdirSync(dir, { recursive: true }); + uploads.set(uploadId, { key, dir, parts: new Map() }); + return xml(` + ${BUCKET} + ${key} + ${uploadId} +`); +} + +function savePart(uploadId, partNumber, body) { + const upload = uploads.get(uploadId); + if (!upload) return null; + const partPath = path.join(upload.dir, String(partNumber).padStart(5, '0')); + fs.writeFileSync(partPath, body); + upload.parts.set(partNumber, partPath); + return etagFor(body); +} + +function completeMultipart(uploadId) { + const upload = uploads.get(uploadId); + if (!upload) return null; + ensureObjectDir(upload.key); + const partNumbers = [...upload.parts.keys()].sort((a, b) => a - b); + const chunks = partNumbers.map((n) => fs.readFileSync(upload.parts.get(n))); + const finalData = Buffer.concat(chunks); + fs.writeFileSync(objectPath(upload.key), finalData); + fs.rmSync(upload.dir, { recursive: true, force: true }); + uploads.delete(uploadId); + const etag = etagFor(finalData); + return xml(` + https://${HOST}:${PORT}/${BUCKET}/${upload.key} + ${BUCKET} + ${upload.key} + ${etag} +`); +} + +function abortMultipart(uploadId) { + const upload = uploads.get(uploadId); + if (!upload) return false; + fs.rmSync(upload.dir, { recursive: true, force: true }); + uploads.delete(uploadId); + return true; +} + +async function handle(req, res) { + const body = await readBody(req); + if (!authOk(req, body)) { + console.error(`${req.method} ${req.url} -> 403 auth failed`); + return send(res, 403, xml('AccessDeniedAccess Denied')); + } + + const route = parseRoute(req.url); + if (route.type === 'missing') { + return send(res, 404, xml('NoSuchBucketNot Found')); + } + + if (route.type === 'root' && req.method === 'GET') { + return send(res, 200, listBucketsXml()); + } + + if (route.type !== 'object') { + return send(res, 404, xml('NoSuchKeyNot Found')); + } + + const { key, query } = route; + + if (req.method === 'GET' && !key) { + return send(res, 200, listBucketXml(query.get('prefix') || '')); + } + + if (req.method === 'GET' && key) { + const file = objectPath(key); + if (!fs.existsSync(file)) { + return send(res, 404, xml('NoSuchKeyNot Found')); + } + const data = fs.readFileSync(file); + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Length': data.length, + ETag: etagFor(data), + }); + return res.end(data); + } + + if (req.method === 'HEAD' && key) { + const file = objectPath(key); + if (!fs.existsSync(file)) { + return send(res, 404, ''); + } + const stat = fs.statSync(file); + return send(res, 200, '', { + 'Content-Length': stat.size, + ETag: etagFor(fs.readFileSync(file)), + }); + } + + if (req.method === 'PUT' && key && query.has('uploadId') && query.has('partNumber')) { + const etag = savePart(query.get('uploadId'), Number(query.get('partNumber')), body); + if (!etag) { + return send(res, 404, xml('NoSuchUploadNot Found')); + } + return send(res, 200, '', { ETag: etag }); + } + + if (req.method === 'PUT' && key) { + ensureObjectDir(key); + fs.writeFileSync(objectPath(key), body); + return send(res, 200, '', { ETag: etagFor(body) }); + } + + if (req.method === 'POST' && key && query.has('uploads')) { + return send(res, 200, initiateMultipart(key)); + } + + if (req.method === 'POST' && key && query.has('uploadId')) { + const result = completeMultipart(query.get('uploadId')); + if (!result) { + return send(res, 404, xml('NoSuchUploadNot Found')); + } + return send(res, 200, result); + } + + if (req.method === 'DELETE' && key && query.has('uploadId')) { + if (!abortMultipart(query.get('uploadId'))) { + return send(res, 404, xml('NoSuchUploadNot Found')); + } + return send(res, 204, ''); + } + + return send(res, 405, xml('MethodNotAllowedNot allowed')); +} + +export function startServer() { + ensureCerts(HOST); + fs.mkdirSync(DATA_DIR, { recursive: true }); + fs.mkdirSync(TMP_DIR, { recursive: true }); + + const server = https.createServer( + { key: fs.readFileSync(SERVER_KEY), cert: fs.readFileSync(SERVER_CERT) }, + (req, res) => { + handle(req, res).catch((err) => { + console.error(err); + send(res, 500, xml('InternalErrorServer error')); + }); + } + ); + + return new Promise((resolve) => { + server.listen(PORT, BIND, () => { + console.log(`S3 endpoint https://${HOST}:${PORT}/${BUCKET} -> ${DATA_DIR}`); + resolve(server); + }); + }); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + startServer(); +} diff --git a/scripts/s3-backup/sigv4.mjs b/scripts/s3-backup/sigv4.mjs new file mode 100644 index 0000000..318145b --- /dev/null +++ b/scripts/s3-backup/sigv4.mjs @@ -0,0 +1,90 @@ +import crypto from 'node:crypto'; + +function hmac(key, data, encoding) { + return crypto.createHmac('sha256', key).update(data, 'utf8').digest(encoding); +} + +function hash(data) { + return crypto.createHash('sha256').update(data, 'utf8').digest('hex'); +} + +function parseAuthHeader(header) { + const parts = Object.fromEntries( + header.replace(/^AWS4-HMAC-SHA256\s+/, '').split(',').map((part) => { + const idx = part.indexOf('='); + const key = part.slice(0, idx).trim(); + const value = part.slice(idx + 1).trim().replace(/^"|"$/g, ''); + return [key, value]; + }) + ); + const credential = parts.Credential.split('/'); + return { + accessKey: credential[0], + date: credential[1], + region: credential[2], + service: credential[3], + signedHeaders: parts.SignedHeaders.split(';'), + signature: parts.Signature, + }; +} + +function getHeader(req, name) { + return req.headers[name.toLowerCase()] || ''; +} + +function canonicalQuery(query) { + if (!query) return ''; + const params = new URLSearchParams(query.startsWith('?') ? query.slice(1) : query); + return [...params.entries()] + .map(([k, v]) => [encodeURIComponent(k), encodeURIComponent(v)]) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join('&'); +} + +function canonicalHeaders(req, signedHeaders) { + return signedHeaders + .map((name) => `${name}:${getHeader(req, name).trim().replace(/\s+/g, ' ')}`) + .join('\n'); +} + +export function verifyRequest(req, body, { accessKey, secretKey, region = 'us-east-1' }) { + const auth = getHeader(req, 'authorization'); + if (!auth.startsWith('AWS4-HMAC-SHA256')) { + return false; + } + + const parsed = parseAuthHeader(auth); + if (parsed.accessKey !== accessKey) { + return false; + } + + const amzDate = getHeader(req, 'x-amz-date'); + const declaredPayload = getHeader(req, 'x-amz-content-sha256'); + const payloadHash = + declaredPayload === 'UNSIGNED-PAYLOAD' ? 'UNSIGNED-PAYLOAD' : declaredPayload || hash(body); + const canonical = [ + req.method, + req.url.split('?')[0] || '/', + canonicalQuery(req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''), + `${canonicalHeaders(req, parsed.signedHeaders)}\n`, + parsed.signedHeaders.join(';'), + payloadHash, + ].join('\n'); + + const scope = `${parsed.date}/${region}/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' + ), + 'aws4_request' + ); + const expected = hmac(signingKey, stringToSign, 'hex'); + return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parsed.signature, 'hex')); +} + +export function etagFor(data) { + return `"${crypto.createHash('md5').update(data).digest('hex')}"`; +} diff --git a/src/endpoints/order.js b/src/endpoints/order.js index 126331f..c8181d6 100644 --- a/src/endpoints/order.js +++ b/src/endpoints/order.js @@ -43,12 +43,22 @@ export async function handle(req, res) { try { const created = await createOrder(order); - logger.success(`order ${created.orderNumber} (kAuftrag=${created.orderId}) created for externalId=${externalOrderId}`); - results.push({ - status: 'OK', - externalOrderId, - message: '', - }); + if (created.alreadyExists) { + const msg = `order already mapped, skipped save (kAuftrag=${created.orderId}, ${created.orderNumber})`; + logger.error(`order externalId=${externalOrderId} skipped: ${msg}`); + results.push({ + status: 'ERROR', + externalOrderId, + message: msg, + }); + } else { + logger.success(`order ${created.orderNumber} (kAuftrag=${created.orderId}) created for externalId=${externalOrderId}`); + results.push({ + status: 'OK', + externalOrderId, + message: '', + }); + } } catch (err) { logger.error(`order externalId=${externalOrderId} failed: ${err.message}`); results.push({ @@ -59,5 +69,7 @@ export async function handle(req, res) { } } - return sendJson(res, 200, results); + const failed = results.filter((r) => r.status === 'ERROR').length; + const httpStatus = failed > 0 ? 500 : 200; + return sendJson(res, httpStatus, results); } diff --git a/src/queries/create-order.js b/src/queries/create-order.js index 32aa008..ba65c04 100644 --- a/src/queries/create-order.js +++ b/src/queries/create-order.js @@ -248,6 +248,18 @@ function isWalkInOrder(order) { return (billing.lastName || '') === 'Laufkunde' && !(billing.firstName || '').trim(); } +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" @@ -374,9 +386,15 @@ async function insertOrderItem(transaction, kAuftrag, item) { /** * JTL PosOrderCreationService.CheckIfOrderExists — skip re-import when the POS - * order id is already mapped to a Wawi order for this subshop. + * order id is already mapped to a Wawi order for this subshop with the same + * external receipt number. */ -async function findExistingPosOrderMapping(kPosAuftrag) { +function externalOrderNumbersMatch(mapped, incoming) { + if (!incoming) return true; + return String(mapped || '').toLowerCase() === String(incoming).toLowerCase(); +} + +async function findExistingPosOrderMapping(kPosAuftrag, externalOrderNumber) { const kShopSubShop = getActiveShopSubshopId(); if (!Number.isInteger(kPosAuftrag) || !kShopSubShop) { return null; @@ -387,7 +405,7 @@ async function findExistingPosOrderMapping(kPosAuftrag) { .input('kPosAuftrag', sql.Int, kPosAuftrag) .input('kShopSubShop', sql.Int, kShopSubShop) .query(` - SELECT TOP 1 m.kAuftrag, a.cAuftragsNr + SELECT TOP 1 m.kAuftrag, a.cAuftragsNr, ISNULL(a.cExterneAuftragsnummer, '') AS cExterneAuftragsnummer FROM Pos.tAuftragMapping m LEFT JOIN Verkauf.tAuftrag a ON a.kAuftrag = m.kAuftrag WHERE m.kPosAuftrag = @kPosAuftrag @@ -400,6 +418,9 @@ async function findExistingPosOrderMapping(kPosAuftrag) { if (!row?.kAuftrag) { return null; } + if (!externalOrderNumbersMatch(row.cExterneAuftragsnummer, externalOrderNumber)) { + return null; + } return row; } @@ -412,20 +433,21 @@ async function upsertPosOrderMapping(transaction, kAuftrag, kPosAuftrag) { if (!Number.isInteger(kPosAuftrag) || !kShopSubShop) { return; } + await new sql.Request(transaction) + .input('kPosAuftrag', sql.Int, kPosAuftrag) + .input('kShopSubShop', sql.Int, kShopSubShop) + .query(` + DELETE FROM Pos.tAuftragMapping + WHERE kPosAuftrag = @kPosAuftrag + AND kShopSubShop = @kShopSubShop + `); await new sql.Request(transaction) .input('kAuftrag', sql.Int, kAuftrag) .input('kPosAuftrag', sql.Int, kPosAuftrag) .input('kShopSubShop', sql.Int, kShopSubShop) .query(` - MERGE INTO Pos.tAuftragMapping WITH (HOLDLOCK) AS Target - USING (SELECT @kAuftrag AS kAuftrag, @kPosAuftrag AS kPosAuftrag, @kShopSubShop AS kShopSubShop) AS Source - ON Target.kPosAuftrag = Source.kPosAuftrag - AND Target.kShopSubShop = Source.kShopSubShop - WHEN MATCHED THEN - UPDATE SET Target.kAuftrag = Source.kAuftrag - WHEN NOT MATCHED BY TARGET THEN - INSERT (kAuftrag, kPosAuftrag, kShopSubShop) - VALUES (Source.kAuftrag, Source.kPosAuftrag, Source.kShopSubShop); + INSERT INTO Pos.tAuftragMapping (kAuftrag, kPosAuftrag, kShopSubShop) + VALUES (@kAuftrag, @kPosAuftrag, @kShopSubShop) `); } @@ -488,12 +510,14 @@ async function insertPayment(transaction, kAuftrag, payment, order, orderDate, z export async function createOrder(order) { const kPosAuftrag = Number.parseInt(order.externalId, 10); + const externalOrderNumber = order.externalOrderNumber || ''; if (Number.isInteger(kPosAuftrag) && kPosAuftrag > 0) { - const existing = await findExistingPosOrderMapping(kPosAuftrag); + const existing = await findExistingPosOrderMapping(kPosAuftrag, externalOrderNumber); if (existing) { return { orderId: String(existing.kAuftrag), orderNumber: existing.cAuftragsNr || '', + alreadyExists: true, }; } } @@ -523,24 +547,25 @@ export async function createOrder(order) { .input('cWaehrung', sql.NVarChar, order.currencyIso || 'EUR') .input('kPlattform', sql.Int, defaults.kPlattform) .input('kShop', sql.Int, getActiveShopId() || null) - .input('cKundenNr', sql.NVarChar, String(order.customerNumber || '')) + .input('cKundenNr', sql.NVarChar, resolveAuftragCKundenNr(order)) .input('cVersandlandISO', sql.NVarChar, (order.shippingAddress?.countryIso || 'DE').toUpperCase()) .input('kVersandArt', sql.Int, defaults.kVersandArt) .input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart) .input('kKundengruppe', sql.Int, kKundengruppe) .input('cExterneAuftragsnummer', sql.NVarChar, order.externalOrderNumber || '') + .input('nIstExterneRechnung', sql.Int, resolveNIstExterneRechnung(order)) .query(` DECLARE @t TABLE ([kAuftrag] INT); INSERT INTO Verkauf.tAuftrag (cAuftragsNr, dErstellt, nKomplettAusgeliefert, kBenutzer, kKunde, kBenutzerErstellt, nType, fFaktor, kFirmaHistory, kSprache, cVersandlandWaehrung, fVersandlandWaehrungFaktor, fFinanzierungskosten, cWaehrung, kPlattform, kShop, cKundenNr, cVersandlandISO, kVersandArt, kZahlungsart, kKundengruppe, - cExterneAuftragsnummer) + cExterneAuftragsnummer, nIstExterneRechnung, cInet, nIstReadOnly, kShopauftrag, nLieferPrioritaet) OUTPUT inserted.kAuftrag INTO @t 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); + @cExterneAuftragsnummer, @nIstExterneRechnung, 'Y', 2, 0, 10); SELECT kAuftrag FROM @t; `); @@ -578,7 +603,7 @@ export async function createOrder(order) { await transaction.commit(); - return { orderId: String(kAuftrag), orderNumber: cAuftragsNr }; + return { orderId: String(kAuftrag), orderNumber: cAuftragsNr, alreadyExists: false }; } catch (err) { try { await transaction.rollback(); diff --git a/src/queries/delivery/index.js b/src/queries/delivery/index.js index a010446..c2aa333 100644 --- a/src/queries/delivery/index.js +++ b/src/queries/delivery/index.js @@ -1,6 +1,7 @@ import { openSession, discardSession, closeSession } from './session.js'; -import { getOutgoingWarehouse } from './warehouse.js'; +import { getOutgoingWarehouse, getWarehousePlace } from './warehouse.js'; import { reservePositions } from './reserve.js'; +import { bookStockShortfallsAndRereserve } from './stock-shortage.js'; import { commitPicklists } from './commit.js'; import { deliverPicklists } from './deliver.js'; @@ -23,10 +24,19 @@ export async function deliverOrder(transaction, kBenutzer, kAuftrag, kVersandArt } const kWarenLager = await getOutgoingWarehouse(transaction); + const kWarenLagerPlatz = await getWarehousePlace(transaction, kWarenLager); const kSessionId = await openSession(transaction, kBenutzer); try { await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, deliveredItems); + await bookStockShortfallsAndRereserve( + transaction, + kBenutzer, + kSessionId, + kWarenLager, + kWarenLagerPlatz, + deliveredItems, + ); await commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag); await deliverPicklists(transaction, kBenutzer, kSessionId, kAuftrag, kVersandArt); } finally { diff --git a/src/queries/delivery/stock-shortage.js b/src/queries/delivery/stock-shortage.js new file mode 100644 index 0000000..d28bab3 --- /dev/null +++ b/src/queries/delivery/stock-shortage.js @@ -0,0 +1,112 @@ +import sql from 'mssql'; +import { reservePositions } from './reserve.js'; + +const POS_SHORTAGE_COMMENT = 'Korrekturbuchung erstellt durch POS-Abgleich'; +const BUCHUNGSART_WARENEINGANG = 10; + +async function getReservedQuantity(transaction, kSessionId, kAuftragPosition) { + const result = await new sql.Request(transaction) + .input('kSessionId', sql.Int, kSessionId) + .input('kBestellPos', sql.Int, kAuftragPosition) + .query(` + SELECT ISNULL(SUM(pp.fAnzahl), 0) AS reserved + FROM dbo.tPicklistePos pp + INNER JOIN dbo.tPickliste p ON p.kPickliste = pp.kPickliste + WHERE p.kSessionId = @kSessionId + AND pp.kBestellPos = @kBestellPos + `); + return Number(result.recordset[0]?.reserved ?? 0); +} + +async function getPositionArtikel(transaction, kAuftragPosition) { + const result = await new sql.Request(transaction) + .input('kAuftragPosition', sql.Int, kAuftragPosition) + .query(` + SELECT kArtikel + FROM Verkauf.tAuftragPosition + WHERE kAuftragPosition = @kAuftragPosition + `); + return result.recordset[0]?.kArtikel ?? 0; +} + +async function bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtikel, fehlmenge) { + await new sql.Request(transaction) + .input('kArtikel', sql.Int, kArtikel) + .input('kWarenLagerPlatz', sql.Int, kWarenLagerPlatz) + .input('kBenutzer', sql.Int, kBenutzer) + .input('fAnzahl', sql.Float, fehlmenge) + .input('cKommentar', sql.NVarChar, POS_SHORTAGE_COMMENT) + .input('kBuchungsart', sql.Int, BUCHUNGSART_WARENEINGANG) + .query(` + DECLARE @kWarenlagerEingang INT; + EXEC dbo.spWarenlagerEingangSchreiben + @kArtikel = @kArtikel, + @kWarenLagerPlatz = @kWarenLagerPlatz, + @kLieferantenBestellungPos = 0, + @kBenutzer = @kBenutzer, + @fAnzahl = @fAnzahl, + @fEkEinzel = 0, + @cLieferscheinNr = '', + @cChargenNr = NULL, + @dMHD = NULL, + @dGeliefertAm = NULL, + @cKommentar = @cKommentar, + @kGutschriftPos = 0, + @kLHM = 0, + @kSessionId = 0, + @kBuchungsart = @kBuchungsart, + @kBestellPosUmlagerung = 0, + @kRMRetourePos = 0, + @nHistorieNichtSchreiben = 0, + @kWarenlagerEingang = @kWarenlagerEingang OUTPUT; + SELECT @kWarenlagerEingang AS kWarenlagerEingang; + `); +} + +/** + * PosStockPositionService.FehlbestandEinbuchen — book missing stock, then re-reserve. + */ +export async function bookStockShortfallsAndRereserve( + transaction, + kBenutzer, + kSessionId, + kWarenLager, + kWarenLagerPlatz, + positions, +) { + const rereserve = []; + + for (const { kAuftragPosition, quantity } of positions) { + if (!kAuftragPosition || quantity <= 0) { + continue; + } + + const reserved = await getReservedQuantity(transaction, kSessionId, kAuftragPosition); + const shortage = quantity - reserved; + if (shortage <= 0.0001) { + continue; + } + + const kArtikel = await getPositionArtikel(transaction, kAuftragPosition); + if (!kArtikel) { + continue; + } + + await bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtikel, shortage); + rereserve.push({ kAuftragPosition, quantity: shortage }); + } + + if (rereserve.length) { + await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, rereserve); + } + + for (const { kAuftragPosition, quantity } of positions) { + if (!kAuftragPosition || quantity <= 0) { + continue; + } + const reserved = await getReservedQuantity(transaction, kSessionId, kAuftragPosition); + if (reserved + 0.0001 < quantity) { + throw new Error(`insufficient stock after POS shortage booking for kBestellPos=${kAuftragPosition}`); + } + } +} diff --git a/src/queries/delivery/warehouse.js b/src/queries/delivery/warehouse.js index 6c4a70e..f83ed2a 100644 --- a/src/queries/delivery/warehouse.js +++ b/src/queries/delivery/warehouse.js @@ -2,6 +2,7 @@ import sql from 'mssql'; /** DB-resolved (or env-overridden) outgoing warehouse, cached after first lookup. */ let cachedWarenLager = null; +let cachedWarenLagerPlatz = null; /** * Resolves the local warehouse (dbo.tWarenLager.nFulfillment = 0) to book @@ -36,3 +37,34 @@ export async function getOutgoingWarehouse(transaction) { cachedWarenLager = row.kWarenLager; return cachedWarenLager; } + +/** + * Default pick place for the outgoing warehouse (dbo.tWarenLagerPlatz). + */ +export async function getWarehousePlace(transaction, kWarenLager) { + if (cachedWarenLagerPlatz != null) { + return cachedWarenLagerPlatz; + } + + const configured = Number(process.env.JTL_KWARENLAGERPLATZ); + if (Number.isInteger(configured) && configured > 0) { + cachedWarenLagerPlatz = configured; + return cachedWarenLagerPlatz; + } + + const result = await new sql.Request(transaction) + .input('kWarenLager', sql.Int, kWarenLager) + .query(` + SELECT TOP 1 kWarenLagerPlatz + FROM dbo.tWarenLagerPlatz + WHERE kWarenLager = @kWarenLager + AND ISNULL(nGesperrt, 0) = 0 + ORDER BY nPrio, kWarenLagerPlatz + `); + const row = result.recordset[0]; + if (!row) { + throw new Error(`No warehouse place found for kWarenLager=${kWarenLager}; set JTL_KWARENLAGERPLATZ explicitly.`); + } + cachedWarenLagerPlatz = row.kWarenLagerPlatz; + return cachedWarenLagerPlatz; +}