This commit is contained in:
seb
2026-07-15 01:25:38 +02:00
parent 7fa839da4d
commit 056c9e18cd
21 changed files with 1181 additions and 64 deletions

5
.gitignore vendored
View File

@@ -3,4 +3,7 @@ node_modules/
certs/ certs/
logs/ logs/
capturedDataReference capturedDataReference
decompiledReference decompiledReference
scripts/s3-backup/data/
scripts/s3-backup/tmp/
scripts/s3-backup/certs/

View File

@@ -14,9 +14,11 @@ OdbcPool& get_pool() { return g_pool; }
OdbcPool::~OdbcPool() { disconnect(); } 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) { if (!handle) {
logc::warn("ODBC %s failed (no handle)", ctx); logc::warn("ODBC %s failed (no handle)", ctx);
if (capture && capture->empty()) *capture = std::string(ctx) + ": no handle";
return; return;
} }
SQLSMALLINT rec = 0; 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; if (diag_rc != SQL_SUCCESS && diag_rc != SQL_SUCCESS_WITH_INFO) break;
any = true; any = true;
logc::warn("ODBC %s: %s - %s (%d)", ctx, state, msg, (int)native); logc::warn("ODBC %s: %s - %s (%d)", ctx, state, msg, (int)native);
if (capture && capture->empty()) {
*capture = std::string(reinterpret_cast<char*>(state)) + " - "
+ std::string(reinterpret_cast<char*>(msg));
}
} }
if (!any) { if (!any) {
logc::warn("ODBC %s failed (no diag)", ctx); 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<Param>& params, ResultSet& out) { bool OdbcPool::execute(Connection* c, const std::string& sql, const std::vector<Param>& params, ResultSet& out) {
if (!c) { if (!c) {
last_error_ = "no connection";
logc::warn("ODBC execute: no connection"); logc::warn("ODBC execute: no connection");
return false; return false;
} }
SQLRETURN rc = SQLPrepare(c->hstmt, (SQLCHAR*)sql.c_str(), SQL_NTS); SQLRETURN rc = SQLPrepare(c->hstmt, (SQLCHAR*)sql.c_str(), SQL_NTS);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) { 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; return false;
} }
std::vector<SQLLEN> indicators; std::vector<SQLLEN> indicators;
if (!params.empty() && !bind_params(c->hstmt, params, 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; return false;
} }
rc = SQLExecute(c->hstmt); rc = SQLExecute(c->hstmt);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO && rc != SQL_NO_DATA) { 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; return false;
} }
last_error_.clear();
fetch_results(c->hstmt, out); fetch_results(c->hstmt, out);
SQLFreeStmt(c->hstmt, SQL_UNBIND); SQLFreeStmt(c->hstmt, SQL_UNBIND);

View File

@@ -85,10 +85,13 @@ public:
void release(Connection* c); void release(Connection* c);
const std::string& last_error() const { return last_error_; }
private: private:
SQLHENV henv_ = SQL_NULL_HENV; SQLHENV henv_ = SQL_NULL_HENV;
std::vector<Connection> conns_; std::vector<Connection> conns_;
std::mutex mutex_; std::mutex mutex_;
std::string last_error_;
Connection* checkout_raw(); Connection* checkout_raw();
}; };

View File

@@ -36,16 +36,30 @@ void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
try { try {
nlohmann::json created = order::create_order(order); nlohmann::json created = order::create_order(order);
logc::success("order %s (kAuftrag=%s) created for externalId=%s", if (created.value("alreadyExists", false)) {
created.value("orderNumber", "").c_str(), std::string msg = "order already mapped, skipped save (kAuftrag="
created.value("orderId", "").c_str(), + created.value("orderId", "") + ", "
externalOrderId.c_str()); + created.value("orderNumber", "") + ")";
results.push_back({ logc::error("order externalId=%s skipped: %s",
{"status", "OK"}, externalOrderId.c_str(), msg.c_str());
{"externalOrderId", externalOrderId}, results.push_back({
{"message", ""} {"status", "ERROR"},
}); {"externalOrderId", externalOrderId},
++successful; {"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) { } catch (const std::exception& ex) {
logc::error("order externalId=%s failed: %s", externalOrderId.c_str(), ex.what()); logc::error("order externalId=%s failed: %s", externalOrderId.c_str(), ex.what());
results.push_back({ 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()) { if (orders.empty()) {
logc::info("POST /v1/order: no orders in body"); logc::info("POST /v1/order: no orders in body");

View File

@@ -25,7 +25,10 @@ inline void commit_picklists(OdbcPool::Connection* c, int kBenutzer, int kSessio
}; };
ResultSet rs; ResultSet rs;
if (!get_pool().execute(c, sql, ps, 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);
} }
} }

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <algorithm> #include <algorithm>
#include <cctype>
#include <cmath> #include <cmath>
#include <ctime> #include <ctime>
#include <chrono> #include <chrono>
@@ -297,6 +298,26 @@ inline bool is_walk_in_order(const nlohmann::json& order) {
&& billing.value("firstName", "").empty(); && 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<bool>() ? 0 : 1;
if (inv.is_number_integer()) return inv.get<int>() == 1 ? 0 : 1;
if (inv.is_string()) {
std::string s = inv.get<std::string>();
return (s == "1" || s == "true") ? 0 : 1;
}
return 1;
}
inline std::pair<int,int> create_customer(OdbcPool::Connection* c, const std::string& customer_number, inline std::pair<int,int> create_customer(OdbcPool::Connection* c, const std::string& customer_number,
const nlohmann::json& address, const Defaults& defaults) { const nlohmann::json& address, const Defaults& defaults) {
const nlohmann::json a = address.is_null() ? nlohmann::json::object() : address; 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; } 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<unsigned char>(mapped[i]))
!= std::tolower(static_cast<unsigned char>(incoming[i]))) {
return false;
}
}
return true;
}
// PosOrderCreationService.CheckIfOrderExists — already-imported POS order. // PosOrderCreationService.CheckIfOrderExists — already-imported POS order.
inline std::optional<std::pair<int, std::string>> find_existing_pos_order( inline std::optional<std::pair<int, std::string>> 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(); int kShopSubShop = get_active_shop_subshop_id();
if (kPosAuftrag <= 0 || kShopSubShop <= 0) return std::nullopt; if (kPosAuftrag <= 0 || kShopSubShop <= 0) return std::nullopt;
const char* sql = 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 " "LEFT JOIN Verkauf.tAuftrag a ON a.kAuftrag = m.kAuftrag "
"WHERE m.kPosAuftrag = ? AND m.kShopSubShop = ? AND m.kAuftrag IS NOT NULL " "WHERE m.kPosAuftrag = ? AND m.kShopSubShop = ? AND m.kAuftrag IS NOT NULL "
"ORDER BY m.kAuftrag DESC"; "ORDER BY m.kAuftrag DESC";
@@ -509,6 +543,10 @@ inline std::optional<std::pair<int, std::string>> find_existing_pos_order(
int kAuftrag = std::stoi(rs[0][0].str); int kAuftrag = std::stoi(rs[0][0].str);
if (kAuftrag <= 0) return std::nullopt; if (kAuftrag <= 0) return std::nullopt;
std::string order_number = rs[0].size() > 1 ? rs[0][1].str : ""; 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); 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(); int kShopSubShop = get_active_shop_subshop_id();
if (kPosAuftrag <= 0 || kShopSubShop <= 0) return; if (kPosAuftrag <= 0 || kShopSubShop <= 0) return;
const char* sql = ResultSet rs;
"MERGE INTO Pos.tAuftragMapping WITH (HOLDLOCK) AS Target " std::vector<Param> del_ps = {
"USING (SELECT ? AS kAuftrag, ? AS kPosAuftrag, ? AS kShopSubShop) AS Source " {ParamType::Int, "", kPosAuftrag},
"ON Target.kPosAuftrag = Source.kPosAuftrag AND Target.kShopSubShop = Source.kShopSubShop " {ParamType::Int, "", kShopSubShop},
"WHEN MATCHED THEN UPDATE SET Target.kAuftrag = Source.kAuftrag " };
"WHEN NOT MATCHED BY TARGET THEN " get_pool().execute(c,
"INSERT (kAuftrag, kPosAuftrag, kShopSubShop) " "DELETE FROM Pos.tAuftragMapping WHERE kPosAuftrag = ? AND kShopSubShop = ?",
"VALUES (Source.kAuftrag, Source.kPosAuftrag, Source.kShopSubShop)"; del_ps, rs);
std::vector<Param> ps = { std::vector<Param> ins_ps = {
{ParamType::Int, "", kAuftrag}, {ParamType::Int, "", kAuftrag},
{ParamType::Int, "", kPosAuftrag}, {ParamType::Int, "", kPosAuftrag},
{ParamType::Int, "", kShopSubShop} {ParamType::Int, "", kShopSubShop},
}; };
ResultSet rs; get_pool().execute(c,
get_pool().execute(c, sql, ps, rs); "INSERT INTO Pos.tAuftragMapping (kAuftrag, kPosAuftrag, kShopSubShop) VALUES (?, ?, ?)",
ins_ps, rs);
} }
inline void insert_pos_order_position_mapping(OdbcPool::Connection* c, int kAuftragPosition, 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(); auto* c = guard.get();
int kPosAuftrag = parse_pos_auftrag_id(order.value("externalId", "")); int kPosAuftrag = parse_pos_auftrag_id(order.value("externalId", ""));
std::string external_order_number = order.value("externalOrderNumber", "");
if (kPosAuftrag > 0) { 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 { return {
{"orderId", std::to_string(existing->first)}, {"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," " (cAuftragsNr, dErstellt, nKomplettAusgeliefert, kBenutzer, kKunde, kBenutzerErstellt, nType, fFaktor,"
" kFirmaHistory, kSprache, cVersandlandWaehrung, fVersandlandWaehrungFaktor, fFinanzierungskosten," " kFirmaHistory, kSprache, cVersandlandWaehrung, fVersandlandWaehrungFaktor, fFinanzierungskosten,"
" cWaehrung, kPlattform, kShop, cKundenNr, cVersandlandISO, kVersandArt, kZahlungsart, kKundengruppe," " cWaehrung, kPlattform, kShop, cKundenNr, cVersandlandISO, kVersandArt, kZahlungsart, kKundengruppe,"
" cExterneAuftragsnummer)" " cExterneAuftragsnummer, nIstExterneRechnung, cInet, nIstReadOnly, kShopauftrag, nLieferPrioritaet)"
" OUTPUT inserted.kAuftrag INTO @t" " OUTPUT inserted.kAuftrag INTO @t"
" VALUES (?, ?, 0, ?, ?, ?, 1, 1.0," " VALUES (?, ?, 0, ?, ?, ?, 1, 1.0,"
" ?, ?, ?, 1.0, 0.0," " ?, ?, ?, 1.0, 0.0,"
" ?, ?, ?, ?, ?, ?, ?, ?," " ?, ?, ?, ?, ?, ?, ?, ?,"
" ?);" " ?, ?, ?, ?, 0, 10);"
"SELECT kAuftrag FROM @t"; "SELECT kAuftrag FROM @t";
std::string shipping_iso = order.value("shippingAddress", nlohmann::json::object()).value("countryIso", "DE"); 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); }); [](unsigned char ch) { return std::toupper(ch); });
std::string currency_iso = order.value("currencyIso", "EUR"); 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<Param> ps = { std::vector<Param> ps = {
{ParamType::NVarChar, cAuftragsNr, 0}, {ParamType::NVarChar, cAuftragsNr, 0},
{ParamType::NVarChar, order_date_sql(order_date_tm), 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::NVarChar, currency_iso, 0},
{ParamType::Int, "", defaults.kPlattform}, {ParamType::Int, "", defaults.kPlattform},
{ParamType::Int, "", active_shop == 0 ? -1 : active_shop}, {ParamType::Int, "", active_shop == 0 ? -1 : active_shop},
{ParamType::NVarChar, order.value("customerNumber", ""), 0}, {ParamType::NVarChar, cKundenNr, 0},
{ParamType::NVarChar, shipping_iso, 0}, {ParamType::NVarChar, shipping_iso, 0},
{ParamType::Int, "", defaults.kVersandArt}, {ParamType::Int, "", defaults.kVersandArt},
{ParamType::Int, "", json_int(zahlungsart, "kZahlungsart", 0)}, {ParamType::Int, "", json_int(zahlungsart, "kZahlungsart", 0)},
{ParamType::Int, "", kKundengruppe}, {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) { if (active_shop == 0) {
ps[11] = Param::null_int(); 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"); 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&) { } catch (const std::exception&) {
try { get_pool().rollback(c); } catch (...) {} try { get_pool().rollback(c); } catch (...) {}
throw; throw;

View File

@@ -3,6 +3,7 @@
#include "session.hpp" #include "session.hpp"
#include "warehouse.hpp" #include "warehouse.hpp"
#include "reserve.hpp" #include "reserve.hpp"
#include "stock_shortage.hpp"
#include "commit.hpp" #include "commit.hpp"
#include "deliver.hpp" #include "deliver.hpp"
#include "../db/pool.hpp" #include "../db/pool.hpp"
@@ -14,10 +15,13 @@ inline void deliver_order(OdbcPool::Connection* c, int kBenutzer, int kAuftrag,
if (items.empty()) return; if (items.empty()) return;
int kWarenLager = resolve_outgoing_warehouse(c); int kWarenLager = resolve_outgoing_warehouse(c);
int kWarenLagerPlatz = resolve_warehouse_place(c, kWarenLager);
int kSessionId = open_session(c, kBenutzer); int kSessionId = open_session(c, kBenutzer);
try { try {
reserve_positions(c, kBenutzer, kSessionId, kWarenLager, items); reserve_positions(c, kBenutzer, kSessionId, kWarenLager, items);
book_stock_shortfalls_and_rereserve(c, kBenutzer, kSessionId, kWarenLager,
kWarenLagerPlatz, items);
commit_picklists(c, kBenutzer, kSessionId, kAuftrag); commit_picklists(c, kBenutzer, kSessionId, kAuftrag);
deliver_picklists(c, kBenutzer, kSessionId, kAuftrag, kVersandArt); deliver_picklists(c, kBenutzer, kSessionId, kAuftrag, kVersandArt);
} catch (...) { } catch (...) {

View File

@@ -50,7 +50,10 @@ inline void reserve_positions(OdbcPool::Connection* c, int kBenutzer, int kSessi
}; };
ResultSet rs; ResultSet rs;
if (!get_pool().execute(c, sql, ps, 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);
} }
} }

View File

@@ -0,0 +1,137 @@
#pragma once
#include <cmath>
#include <stdexcept>
#include <string>
#include <vector>
#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<Param> 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<Param> 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<Param> 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<DeliveredItem>& items) {
std::vector<DeliveredItem> 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

View File

@@ -18,4 +18,20 @@ inline int resolve_outgoing_warehouse(OdbcPool::Connection* c) {
return std::stoi(rs[0][0].str); 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<Param> 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 } // namespace delivery

View File

@@ -8,6 +8,7 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"cert": "node generate-cert.js", "cert": "node generate-cert.js",
"backup:s3": "node scripts/s3-backup/backup.mjs",
"start": "node --watch server.js", "start": "node --watch server.js",
"test:client": "node test-client.js" "test:client": "node test-client.js"
}, },

View File

@@ -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);
});

View File

@@ -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}`;
}

View File

@@ -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;
}

View File

@@ -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 `<?xml version="1.0" encoding="UTF-8"?>\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(`<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Owner><ID>jtlsrv</ID><DisplayName>jtlsrv</DisplayName></Owner>
<Buckets>
<Bucket>
<Name>${BUCKET}</Name>
<CreationDate>2026-01-01T00:00:00.000Z</CreationDate>
</Bucket>
</Buckets>
</ListAllMyBucketsResult>`);
}
function listBucketXml(prefix) {
const items = listObjects(prefix).map((item) => {
const etag = etagFor(fs.readFileSync(path.join(DATA_DIR, item.key)));
return `<Contents>
<Key>${item.key}</Key>
<LastModified>${item.mtime.toISOString()}</LastModified>
<ETag>${etag}</ETag>
<Size>${item.size}</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>`;
}).join('\n');
return xml(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>${BUCKET}</Name>
<Prefix>${prefix}</Prefix>
<MaxKeys>1000</MaxKeys>
<IsTruncated>false</IsTruncated>
${items}
</ListBucketResult>`);
}
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(`<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>${BUCKET}</Bucket>
<Key>${key}</Key>
<UploadId>${uploadId}</UploadId>
</InitiateMultipartUploadResult>`);
}
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(`<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Location>https://${HOST}:${PORT}/${BUCKET}/${upload.key}</Location>
<Bucket>${BUCKET}</Bucket>
<Key>${upload.key}</Key>
<ETag>${etag}</ETag>
</CompleteMultipartUploadResult>`);
}
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('<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>'));
}
const route = parseRoute(req.url);
if (route.type === 'missing') {
return send(res, 404, xml('<Error><Code>NoSuchBucket</Code><Message>Not Found</Message></Error>'));
}
if (route.type === 'root' && req.method === 'GET') {
return send(res, 200, listBucketsXml());
}
if (route.type !== 'object') {
return send(res, 404, xml('<Error><Code>NoSuchKey</Code><Message>Not Found</Message></Error>'));
}
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('<Error><Code>NoSuchKey</Code><Message>Not Found</Message></Error>'));
}
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('<Error><Code>NoSuchUpload</Code><Message>Not Found</Message></Error>'));
}
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('<Error><Code>NoSuchUpload</Code><Message>Not Found</Message></Error>'));
}
return send(res, 200, result);
}
if (req.method === 'DELETE' && key && query.has('uploadId')) {
if (!abortMultipart(query.get('uploadId'))) {
return send(res, 404, xml('<Error><Code>NoSuchUpload</Code><Message>Not Found</Message></Error>'));
}
return send(res, 204, '');
}
return send(res, 405, xml('<Error><Code>MethodNotAllowed</Code><Message>Not allowed</Message></Error>'));
}
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('<Error><Code>InternalError</Code><Message>Server error</Message></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();
}

View File

@@ -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')}"`;
}

View File

@@ -43,12 +43,22 @@ export async function handle(req, res) {
try { try {
const created = await createOrder(order); const created = await createOrder(order);
logger.success(`order ${created.orderNumber} (kAuftrag=${created.orderId}) created for externalId=${externalOrderId}`); if (created.alreadyExists) {
results.push({ const msg = `order already mapped, skipped save (kAuftrag=${created.orderId}, ${created.orderNumber})`;
status: 'OK', logger.error(`order externalId=${externalOrderId} skipped: ${msg}`);
externalOrderId, results.push({
message: '', 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) { } catch (err) {
logger.error(`order externalId=${externalOrderId} failed: ${err.message}`); logger.error(`order externalId=${externalOrderId} failed: ${err.message}`);
results.push({ 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);
} }

View File

@@ -248,6 +248,18 @@ function isWalkInOrder(order) {
return (billing.lastName || '') === 'Laufkunde' && !(billing.firstName || '').trim(); 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) { async function lookupKassenkunde(transaction, defaults) {
const result = await new sql.Request(transaction).query( const result = await new sql.Request(transaction).query(
"SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKassenKunde = 'Y' ORDER BY kKunde" "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 * 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(); const kShopSubShop = getActiveShopSubshopId();
if (!Number.isInteger(kPosAuftrag) || !kShopSubShop) { if (!Number.isInteger(kPosAuftrag) || !kShopSubShop) {
return null; return null;
@@ -387,7 +405,7 @@ async function findExistingPosOrderMapping(kPosAuftrag) {
.input('kPosAuftrag', sql.Int, kPosAuftrag) .input('kPosAuftrag', sql.Int, kPosAuftrag)
.input('kShopSubShop', sql.Int, kShopSubShop) .input('kShopSubShop', sql.Int, kShopSubShop)
.query(` .query(`
SELECT TOP 1 m.kAuftrag, a.cAuftragsNr SELECT TOP 1 m.kAuftrag, a.cAuftragsNr, ISNULL(a.cExterneAuftragsnummer, '') AS cExterneAuftragsnummer
FROM Pos.tAuftragMapping m FROM Pos.tAuftragMapping m
LEFT JOIN Verkauf.tAuftrag a ON a.kAuftrag = m.kAuftrag LEFT JOIN Verkauf.tAuftrag a ON a.kAuftrag = m.kAuftrag
WHERE m.kPosAuftrag = @kPosAuftrag WHERE m.kPosAuftrag = @kPosAuftrag
@@ -400,6 +418,9 @@ async function findExistingPosOrderMapping(kPosAuftrag) {
if (!row?.kAuftrag) { if (!row?.kAuftrag) {
return null; return null;
} }
if (!externalOrderNumbersMatch(row.cExterneAuftragsnummer, externalOrderNumber)) {
return null;
}
return row; return row;
} }
@@ -412,20 +433,21 @@ async function upsertPosOrderMapping(transaction, kAuftrag, kPosAuftrag) {
if (!Number.isInteger(kPosAuftrag) || !kShopSubShop) { if (!Number.isInteger(kPosAuftrag) || !kShopSubShop) {
return; 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) await new sql.Request(transaction)
.input('kAuftrag', sql.Int, kAuftrag) .input('kAuftrag', sql.Int, kAuftrag)
.input('kPosAuftrag', sql.Int, kPosAuftrag) .input('kPosAuftrag', sql.Int, kPosAuftrag)
.input('kShopSubShop', sql.Int, kShopSubShop) .input('kShopSubShop', sql.Int, kShopSubShop)
.query(` .query(`
MERGE INTO Pos.tAuftragMapping WITH (HOLDLOCK) AS Target INSERT INTO Pos.tAuftragMapping (kAuftrag, kPosAuftrag, kShopSubShop)
USING (SELECT @kAuftrag AS kAuftrag, @kPosAuftrag AS kPosAuftrag, @kShopSubShop AS kShopSubShop) AS Source VALUES (@kAuftrag, @kPosAuftrag, @kShopSubShop)
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);
`); `);
} }
@@ -488,12 +510,14 @@ async function insertPayment(transaction, kAuftrag, payment, order, orderDate, z
export async function createOrder(order) { export async function createOrder(order) {
const kPosAuftrag = Number.parseInt(order.externalId, 10); const kPosAuftrag = Number.parseInt(order.externalId, 10);
const externalOrderNumber = order.externalOrderNumber || '';
if (Number.isInteger(kPosAuftrag) && kPosAuftrag > 0) { if (Number.isInteger(kPosAuftrag) && kPosAuftrag > 0) {
const existing = await findExistingPosOrderMapping(kPosAuftrag); const existing = await findExistingPosOrderMapping(kPosAuftrag, externalOrderNumber);
if (existing) { if (existing) {
return { return {
orderId: String(existing.kAuftrag), orderId: String(existing.kAuftrag),
orderNumber: existing.cAuftragsNr || '', orderNumber: existing.cAuftragsNr || '',
alreadyExists: true,
}; };
} }
} }
@@ -523,24 +547,25 @@ export async function createOrder(order) {
.input('cWaehrung', sql.NVarChar, order.currencyIso || 'EUR') .input('cWaehrung', sql.NVarChar, order.currencyIso || 'EUR')
.input('kPlattform', sql.Int, defaults.kPlattform) .input('kPlattform', sql.Int, defaults.kPlattform)
.input('kShop', sql.Int, getActiveShopId() || null) .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('cVersandlandISO', sql.NVarChar, (order.shippingAddress?.countryIso || 'DE').toUpperCase())
.input('kVersandArt', sql.Int, defaults.kVersandArt) .input('kVersandArt', sql.Int, defaults.kVersandArt)
.input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart) .input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart)
.input('kKundengruppe', sql.Int, kKundengruppe) .input('kKundengruppe', sql.Int, kKundengruppe)
.input('cExterneAuftragsnummer', sql.NVarChar, order.externalOrderNumber || '') .input('cExterneAuftragsnummer', sql.NVarChar, order.externalOrderNumber || '')
.input('nIstExterneRechnung', sql.Int, resolveNIstExterneRechnung(order))
.query(` .query(`
DECLARE @t TABLE ([kAuftrag] INT); DECLARE @t TABLE ([kAuftrag] INT);
INSERT INTO Verkauf.tAuftrag INSERT INTO Verkauf.tAuftrag
(cAuftragsNr, dErstellt, nKomplettAusgeliefert, kBenutzer, kKunde, kBenutzerErstellt, nType, fFaktor, (cAuftragsNr, dErstellt, nKomplettAusgeliefert, kBenutzer, kKunde, kBenutzerErstellt, nType, fFaktor,
kFirmaHistory, kSprache, cVersandlandWaehrung, fVersandlandWaehrungFaktor, fFinanzierungskosten, kFirmaHistory, kSprache, cVersandlandWaehrung, fVersandlandWaehrungFaktor, fFinanzierungskosten,
cWaehrung, kPlattform, kShop, cKundenNr, cVersandlandISO, kVersandArt, kZahlungsart, kKundengruppe, cWaehrung, kPlattform, kShop, cKundenNr, cVersandlandISO, kVersandArt, kZahlungsart, kKundengruppe,
cExterneAuftragsnummer) cExterneAuftragsnummer, nIstExterneRechnung, cInet, nIstReadOnly, kShopauftrag, nLieferPrioritaet)
OUTPUT inserted.kAuftrag INTO @t OUTPUT inserted.kAuftrag INTO @t
VALUES (@cAuftragsNr, @dErstellt, 0, @kBenutzer, @kKunde, @kBenutzer, 1, 1.0, VALUES (@cAuftragsNr, @dErstellt, 0, @kBenutzer, @kKunde, @kBenutzer, 1, 1.0,
@kFirmaHistory, @kSprache, @cWaehrung, 1.0, 0.0, @kFirmaHistory, @kSprache, @cWaehrung, 1.0, 0.0,
@cWaehrung, @kPlattform, @kShop, @cKundenNr, @cVersandlandISO, @kVersandArt, @kZahlungsart, @kKundengruppe, @cWaehrung, @kPlattform, @kShop, @cKundenNr, @cVersandlandISO, @kVersandArt, @kZahlungsart, @kKundengruppe,
@cExterneAuftragsnummer); @cExterneAuftragsnummer, @nIstExterneRechnung, 'Y', 2, 0, 10);
SELECT kAuftrag FROM @t; SELECT kAuftrag FROM @t;
`); `);
@@ -578,7 +603,7 @@ export async function createOrder(order) {
await transaction.commit(); await transaction.commit();
return { orderId: String(kAuftrag), orderNumber: cAuftragsNr }; return { orderId: String(kAuftrag), orderNumber: cAuftragsNr, alreadyExists: false };
} catch (err) { } catch (err) {
try { try {
await transaction.rollback(); await transaction.rollback();

View File

@@ -1,6 +1,7 @@
import { openSession, discardSession, closeSession } from './session.js'; import { openSession, discardSession, closeSession } from './session.js';
import { getOutgoingWarehouse } from './warehouse.js'; import { getOutgoingWarehouse, getWarehousePlace } from './warehouse.js';
import { reservePositions } from './reserve.js'; import { reservePositions } from './reserve.js';
import { bookStockShortfallsAndRereserve } from './stock-shortage.js';
import { commitPicklists } from './commit.js'; import { commitPicklists } from './commit.js';
import { deliverPicklists } from './deliver.js'; import { deliverPicklists } from './deliver.js';
@@ -23,10 +24,19 @@ export async function deliverOrder(transaction, kBenutzer, kAuftrag, kVersandArt
} }
const kWarenLager = await getOutgoingWarehouse(transaction); const kWarenLager = await getOutgoingWarehouse(transaction);
const kWarenLagerPlatz = await getWarehousePlace(transaction, kWarenLager);
const kSessionId = await openSession(transaction, kBenutzer); const kSessionId = await openSession(transaction, kBenutzer);
try { try {
await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, deliveredItems); await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, deliveredItems);
await bookStockShortfallsAndRereserve(
transaction,
kBenutzer,
kSessionId,
kWarenLager,
kWarenLagerPlatz,
deliveredItems,
);
await commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag); await commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag);
await deliverPicklists(transaction, kBenutzer, kSessionId, kAuftrag, kVersandArt); await deliverPicklists(transaction, kBenutzer, kSessionId, kAuftrag, kVersandArt);
} finally { } finally {

View File

@@ -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}`);
}
}
}

View File

@@ -2,6 +2,7 @@ import sql from 'mssql';
/** DB-resolved (or env-overridden) outgoing warehouse, cached after first lookup. */ /** DB-resolved (or env-overridden) outgoing warehouse, cached after first lookup. */
let cachedWarenLager = null; let cachedWarenLager = null;
let cachedWarenLagerPlatz = null;
/** /**
* Resolves the local warehouse (dbo.tWarenLager.nFulfillment = 0) to book * Resolves the local warehouse (dbo.tWarenLager.nFulfillment = 0) to book
@@ -36,3 +37,34 @@ export async function getOutgoingWarehouse(transaction) {
cachedWarenLager = row.kWarenLager; cachedWarenLager = row.kWarenLager;
return cachedWarenLager; 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;
}