u
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@ certs/
|
||||
logs/
|
||||
capturedDataReference
|
||||
decompiledReference
|
||||
scripts/s3-backup/data/
|
||||
scripts/s3-backup/tmp/
|
||||
scripts/s3-backup/certs/
|
||||
@@ -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<char*>(state)) + " - "
|
||||
+ std::string(reinterpret_cast<char*>(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<Param>& 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<SQLLEN> 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);
|
||||
|
||||
@@ -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<Connection> conns_;
|
||||
std::mutex mutex_;
|
||||
std::string last_error_;
|
||||
Connection* checkout_raw();
|
||||
};
|
||||
|
||||
|
||||
@@ -36,6 +36,19 @@ void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
|
||||
|
||||
try {
|
||||
nlohmann::json created = order::create_order(order);
|
||||
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(),
|
||||
@@ -46,6 +59,7 @@ void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
|
||||
{"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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <ctime>
|
||||
#include <chrono>
|
||||
@@ -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<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,
|
||||
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<unsigned char>(mapped[i]))
|
||||
!= std::tolower(static_cast<unsigned char>(incoming[i]))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// PosOrderCreationService.CheckIfOrderExists — already-imported 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();
|
||||
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<std::pair<int, std::string>> 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<Param> 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<Param> ps = {
|
||||
std::vector<Param> 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<Param> 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;
|
||||
|
||||
@@ -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 (...) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
137
jtlsrv-cpp/src/queries/stock_shortage.hpp
Normal file
137
jtlsrv-cpp/src/queries/stock_shortage.hpp
Normal 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
|
||||
@@ -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<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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
154
scripts/s3-backup/backup.mjs
Normal file
154
scripts/s3-backup/backup.mjs
Normal 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);
|
||||
});
|
||||
40
scripts/s3-backup/config.mjs
Normal file
40
scripts/s3-backup/config.mjs
Normal 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}`;
|
||||
}
|
||||
109
scripts/s3-backup/ensure-certs.mjs
Normal file
109
scripts/s3-backup/ensure-certs.mjs
Normal 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;
|
||||
}
|
||||
285
scripts/s3-backup/server.mjs
Normal file
285
scripts/s3-backup/server.mjs
Normal 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();
|
||||
}
|
||||
90
scripts/s3-backup/sigv4.mjs
Normal file
90
scripts/s3-backup/sigv4.mjs
Normal 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')}"`;
|
||||
}
|
||||
@@ -43,12 +43,22 @@ export async function handle(req, res) {
|
||||
|
||||
try {
|
||||
const created = await createOrder(order);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
112
src/queries/delivery/stock-shortage.js
Normal file
112
src/queries/delivery/stock-shortage.js
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user