Compare commits

...

8 Commits

Author SHA1 Message Date
seb
72b74f5037 u 2026-07-15 23:11:32 +02:00
seb
1de4144ae2 u 2026-07-15 03:11:30 +02:00
seb
056c9e18cd u 2026-07-15 01:25:38 +02:00
seb
7fa839da4d u 2026-07-14 23:32:19 +02:00
seb
ca7c4af973 u 2026-07-14 23:31:34 +02:00
seb
2c76b401b5 u 2026-07-14 23:30:36 +02:00
seb
262fa63f1b u 2026-07-14 23:30:16 +02:00
seb
245fb047d7 u 2026-07-13 07:41:45 +02:00
62 changed files with 3743 additions and 629 deletions

3
.gitignore vendored
View File

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

View File

@@ -1,101 +0,0 @@
---
name: C++ port of jtlsrv
overview: Port the Node.js JTL-POS sync server (~1,400 LOC) to C++17 on Linux, using libuv for the event loop, OpenSSL for HTTPS, unixODBC + msodbcsql18 for MSSQL, and libvips for image resizing — with all blocking work (ODBC, vips) dispatched to uv_queue_work threads.
todos:
- id: skeleton
content: CMake project, .env config loader, logger, libuv TCP skeleton
status: done
- id: tls-http
content: TLS layer (OpenSSL memory-BIO over uv_tcp) + llhttp request parsing
status: done
- id: router
content: Router, pairing store, client/init endpoints (no DB)
status: done
- id: odbc
content: ODBC connection pool + uv_queue_work plumbing + simple queries
status: done
- id: queries
content: Port remaining list queries and JSON shaping (product, category, composite, deleted, attributes)
status: done
- id: images
content: "Image endpoints: blob fetch + libvips thumbnail in worker threads (stub ready, needs libvips + msodbcsql18 driver)"
status: pending
- id: polish
content: Request/order logging, init suppression, graceful shutdown, side-by-side verification vs Node server
status: done
isProject: false
---
# Port jtlsrv to C++ (libuv + ODBC + libvips)
## Answers to your questions
- **libuv?** Yes. Single event-loop thread owns all sockets; libuv's built-in thread pool (`uv_queue_work`) handles blocking work. Set `UV_THREADPOOL_SIZE=8` or so.
- **MSSQL via ODBC?** Yes — unixODBC + Microsoft's `msodbcsql18` driver. It supports named parameters' equivalent (`?` placeholders via `SQLBindParameter`), reading `varbinary(max)` image blobs with `SQLGetData` in chunks, and TLS with `TrustServerCertificate=yes` matching the current `.env` options.
- **ODBC in `uv_queue_work()`?** Yes, mandatory — ODBC calls are fully blocking. Maintain a small connection pool (e.g. 4 `SQLHDBC` handles guarded by a mutex/semaphore); each work item checks out a connection, runs the query, marshals rows into plain structs, and the after-work callback (back on the loop thread) builds JSON and writes the response.
- **Image processing in `uv_queue_work()`?** Yes — same work item as the DB fetch: fetch blob via ODBC, then `vips_thumbnail_buffer()` (200px, fit-inside, no enlargement — exact equivalent of the sharp call in [src/image-resize.js](src/image-resize.js)), return the encoded buffer to the loop thread for sending.
## Caveat worth knowing upfront
libuv has **no TLS support**. The one genuinely new piece of work in this port is an HTTPS layer: OpenSSL memory-BIOs pumped over `uv_tcp_t` (~300 lines, well-trodden pattern), plus **llhttp** (Node's own HTTP parser, plain C) for request parsing. Everything else is a mechanical translation. If you'd rather skip that, `libhv` gives you a libuv-style loop with HTTPS built in — but the plan below assumes plain libuv as requested.
## Dependencies (all in Ubuntu/Debian repos except the MS driver)
- `libuv1-dev`, `libssl-dev`, `libvips-dev`, `unixodbc-dev`, `msodbcsql18` (MS apt repo)
- Vendored single-header/small: `llhttp` (HTTP parser), `nlohmann/json` (or `yyjson` if you want max speed; nlohmann is fine at this scale)
- Build: CMake ≥ 3.16
## Project layout
```
jtlsrv-cpp/
CMakeLists.txt
src/
main.cpp <- server.js: config, signals, startup
config.hpp <- .env loader (tiny hand-rolled parser, ~40 lines)
log.{hpp,cpp} <- logger.js + request-log.js + order-log.js
tls_server.{hpp,cpp}<- uv_tcp + OpenSSL BIO pump (the new part)
http.{hpp,cpp} <- llhttp glue; Request/Response structs; send_json/send_binary
router.{hpp,cpp} <- jtl-server.js: method+path map, init-log suppression
pairing.hpp <- pairing.js (trivial in-memory map)
db/
pool.{hpp,cpp} <- ODBC env + connection pool, work-item helpers
row.hpp <- variant-ish cell type (int64/double/string/blob/null)
endpoints/ <- one .cpp per file in src/endpoints/
queries/ <- one .cpp per file in src/queries/ (SQL strings copied verbatim,
@name params -> ? placeholders)
image.{hpp,cpp} <- image-handler.js + image-resize.js via vips_thumbnail_buffer
```
## Concurrency model
```mermaid
flowchart LR
Client -->|TLS| LoopThread["libuv loop thread: uv_tcp + OpenSSL BIO + llhttp + routing"]
LoopThread -->|uv_queue_work| Workers["uv threadpool workers"]
Workers -->|checkout SQLHDBC| OdbcPool["ODBC connection pool (4 conns)"]
Workers -->|"vips_thumbnail_buffer (image endpoints)"| Vips[libvips]
Workers -->|after_work: rows/buffer| LoopThread
LoopThread -->|JSON / binary response| Client
```
Rules: no libuv handle touched off-loop; work items own their input/output structs (heap-allocated, freed in after-work); JSON serialization happens on the loop thread (cheap at these payload sizes); errors in workers carried back as a status + message field, mapped to the same 500/404 JSON bodies as [src/jtl-server.js](src/jtl-server.js).
## Translation notes per area
- **HTTPS/TLS**: load `certs/key.pem`/`cert.pem` as now; `SSL_CTX` with TLS ≥ 1.2. Keep-alive supported via llhttp message-complete callbacks.
- **Router**: same normalization as [src/http.js](src/http.js) (strip leading `/api` before `/v1/`), `"METHOD path"` key into an `unordered_map`.
- **Queries**: the 12 files in [src/queries](src/queries) port 1:1. `STRING_AGG`, CTEs etc. stay server-side, untouched. The dynamic `IN (...)` in `product-list.js` price overrides stays string-built from integer IDs (safe, they come from the DB). Post-processing (price/gross calc, attribute maps) becomes plain C++ over row structs.
- **Images**: `getImageByHash` reads `bBild`/`bVorschauBild` blobs (chunked `SQLGetData` into `std::vector<uint8_t>`), picks preview vs full exactly as [src/queries/image.js](src/queries/image.js), then vips thumbnail keeping the source format's encoder (jpg/png/gif/webp — libvips handles all).
- **Logging**: `requests.log` / `orders.log` appended via `uv_fs_write` or plain buffered `FILE*` on the loop thread (writes are tiny); replicate the init-suppression window logic from [server.js](server.js) verbatim.
- **Shutdown**: SIGINT/SIGTERM via `uv_signal_t` → stop accepting, drain pool, `uv_stop`.
## Milestones (each independently testable)
1. Skeleton: CMake, config/env, logger, plain-TCP libuv echo — builds and runs.
2. TLS + llhttp layer serving a hardcoded 404 JSON — verify with `curl -k`.
3. Router + pairing + the no-DB endpoints (`client`, `init`) — the existing [test-client.js](test-client.js) should pass against it.
4. ODBC pool + `uv_queue_work` plumbing + `shop`/`customer-groups`/counts queries.
5. Remaining list queries (product, category, composite, deleted-entity, attributes).
6. Image endpoints with libvips; byte-compare output against the Node server using [check-image-rv.mjs](check-image-rv.mjs)-style spot checks.
7. Request/order logs, init-log suppression, graceful shutdown; side-by-side diff of responses vs the Node server on a real POS sync.

View File

@@ -0,0 +1,29 @@
---
description: Server testing must use isolated bind address and non-default ports
alwaysApply: true
---
# Server testing — isolated bind only
## Never use the user's ports
Do NOT start servers on:
- Ports from the project's `.env` (e.g. `PORT=4447`)
- Well-known or project defaults (`4443`, `8080`, `3000`, etc.)
- `0.0.0.0` when the user's server may already be running there
Do NOT leave test servers running in the background (`&`, `nohup`, etc.). Stop them before ending the turn.
## OK for agent runtime tests
When a live test is truly needed:
- Bind to **`127.0.0.2`** (or another non-default loopback alias), not `127.0.0.1` / `0.0.0.0`
- Use a **high ephemeral port** (e.g. `19443`, `38447`) — never the project's configured port
- Override via env, e.g. `BIND_ADDRESS=127.0.0.2 PORT=19443 ./build/jtlsrv`
- Kill the test process when done
Prefer compile-only verification (`make`) when that is enough.
If the user is already running the real server, ask them to test and share logs rather than starting another listener.

View File

@@ -1,4 +0,0 @@
build/
odbc-driver/
logs/
*.log

View File

@@ -5,9 +5,6 @@ build/
jtlsrv jtlsrv
jtlsrv-debug jtlsrv-debug
# Compiled commands
compile_commands.json
# ODBC driver (downloaded, not source) # ODBC driver (downloaded, not source)
odbc-driver/ odbc-driver/

View File

@@ -1,75 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project(jtlsrv-cpp LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# --- Dependencies -----------------------------------------------------------
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBUV REQUIRED libuv)
pkg_check_modules(OPENSSL REQUIRED openssl)
pkg_check_modules(VIPS REQUIRED vips)
find_library(ODBC_LIBRARY odbc)
find_path(ODBC_INCLUDE_DIR sql.h)
# llhttp (vendored)
add_library(llhttp STATIC
vendor/llhttp.c
vendor/api.c
vendor/http.c
)
target_include_directories(llhttp PUBLIC vendor)
# nlohmann/json (vendored, header-only)
add_library(json INTERFACE)
target_include_directories(json INTERFACE vendor)
# --- jtlsrv -----------------------------------------------------------------
add_executable(jtlsrv
src/main.cpp
src/log.cpp
src/http.cpp
src/tls_server.cpp
src/pairing.cpp
src/router.cpp
src/endpoints/client.cpp
src/endpoints/init.cpp
src/endpoints/category.cpp
src/endpoints/product.cpp
src/endpoints/productcomposite.cpp
src/endpoints/deleted_entity.cpp
src/endpoints/customergroup.cpp
src/endpoints/order.cpp
src/endpoints/pimage.cpp
src/endpoints/cimage.cpp
src/db/pool.cpp
src/request_log.cpp
src/order_log.cpp
)
target_include_directories(jtlsrv PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${LIBUV_INCLUDE_DIRS}
${OPENSSL_INCLUDE_DIRS}
${ODBC_INCLUDE_DIR}
${VIPS_INCLUDE_DIRS}
)
target_link_libraries(jtlsrv PRIVATE
llhttp
json
${LIBUV_LIBRARIES}
${OPENSSL_LIBRARIES}
${ODBC_LIBRARY}
${VIPS_LIBRARIES}
pthread
)
target_link_directories(jtlsrv PRIVATE
${LIBUV_LIBRARY_DIRS}
${OPENSSL_LIBRARY_DIRS}
${VIPS_LIBRARY_DIRS}
)

View File

@@ -1,39 +0,0 @@
FROM ubuntu:24.04
# Install build deps
RUN apt-get update && apt-get install -y \
build-essential cmake pkg-config \
libuv1-dev libssl-dev \
libodbc2 unixodbc-dev \
libvips-dev \
curl gnupg2 gdbserver \
&& rm -rf /var/lib/apt/lists/*
# Install Microsoft ODBC Driver 18
RUN curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/24.04/prod noble main" > /etc/apt/sources.list.d/mssql-release.list \
&& apt-get update \
&& ACCEPT_EULA=Y apt-get install -y msodbcsql18 mssql-tools18 \
&& rm -rf /var/lib/apt/lists/*
# Copy source
WORKDIR /build
COPY CMakeLists.txt .
COPY vendor/ vendor/
COPY src/ src/
# Build (default Debug for gdb, override with --build-arg CMAKE_BUILD_TYPE=Release)
ARG CMAKE_BUILD_TYPE=Debug
RUN mkdir -p build && cd build \
&& cmake .. -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} \
&& cmake --build . -j$(nproc) \
&& cp jtlsrv /usr/local/bin/
# Copy certs and env
WORKDIR /app
COPY certs/ certs/
COPY .env.docker .env
EXPOSE 4443
CMD ["jtlsrv"]

89
jtlsrv-cpp/Makefile Normal file
View File

@@ -0,0 +1,89 @@
CXX := g++
CC := gcc
PKGFLAGS := $(shell pkg-config --cflags libuv openssl vips)
PKGLIBS := $(shell pkg-config --libs libuv openssl vips)
BUILD_DIR := build
BINARY := jtlsrv
CXXFLAGS := -std=c++17 -Wall -Wextra -Isrc -Ivendor $(PKGFLAGS)
CFLAGS := -Wall -Ivendor
LDFLAGS := $(PKGLIBS) -lodbc -pthread
ifeq ($(DEBUG),1)
CXXFLAGS := -std=c++17 -Wall -Wextra -g -O0 -Isrc -Ivendor $(PKGFLAGS)
CFLAGS := -Wall -g -O0 -Ivendor
else
CXXFLAGS += -O2
CFLAGS += -O2
endif
CPP_SRCS := \
src/main.cpp \
src/log.cpp \
src/http.cpp \
src/tls_server.cpp \
src/pairing.cpp \
src/router.cpp \
src/endpoints/client.cpp \
src/endpoints/init.cpp \
src/endpoints/category.cpp \
src/endpoints/product.cpp \
src/endpoints/productcomposite.cpp \
src/endpoints/deleted_entity.cpp \
src/endpoints/customergroup.cpp \
src/endpoints/order.cpp \
src/endpoints/pimage.cpp \
src/endpoints/cimage.cpp \
src/db/pool.cpp \
src/queries/shop.cpp \
src/request_log.cpp \
src/order_log.cpp
C_SRCS := \
vendor/llhttp.c \
vendor/api.c \
vendor/http.c
CPP_OBJS := $(patsubst src/%.cpp,$(BUILD_DIR)/src/%.o,$(CPP_SRCS))
C_OBJS := $(patsubst vendor/%.c,$(BUILD_DIR)/vendor/%.o,$(C_SRCS))
OBJS := $(CPP_OBJS) $(C_OBJS)
.PHONY: all debug clean rebuild run help
all: $(BINARY)
debug:
$(MAKE) DEBUG=1 all
$(BINARY): $(OBJS)
@mkdir -p $(BUILD_DIR)
$(CXX) $(OBJS) -o $@ $(LDFLAGS)
$(BUILD_DIR)/src/%.o: src/%.cpp
@mkdir -p $(dir $@)
$(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/vendor/%.o: vendor/%.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -rf $(BUILD_DIR) $(BINARY)
rebuild: clean all
run: all
@mkdir -p logs
./$(BINARY)
help:
@echo "Usage: make [target]"
@echo ""
@echo " all Build $(BINARY) (default)"
@echo " debug Build with -g -O0"
@echo " clean Remove $(BUILD_DIR)/"
@echo " rebuild clean + all"
@echo " run Build and start the server"
@echo ""
@echo "Variables: DEBUG=1"

124
jtlsrv-cpp/README.md Normal file
View File

@@ -0,0 +1,124 @@
# jtlsrv
Built on **libuv** (event loop), **OpenSSL** (TLS), **llhttp** (HTTP parsing), **unixODBC** + [**ODBC Driver 18 for SQL Server**](https://learn.microsoft.com/en-us/sql/connect/odbc/microsoft-odbc-driver-for-sql-server) (database), and **libvips** (image thumbnails). Blocking work (ODBC queries, image processing) runs on libuv worker threads.
## Requirements
| Dependency | Purpose |
|---|---|
| C++17 compiler | gcc or clang |
| GNU Make | Build |
| pkg-config | Dependency flags |
| libuv | Async I/O and thread pool |
| OpenSSL | TLS |
| libvips | Product/category image resizing |
| unixODBC + dev headers | ODBC runtime |
| [ODBC Driver 18 for SQL Server](https://learn.microsoft.com/en-us/sql/connect/odbc/microsoft-odbc-driver-for-sql-server) | Connect to JTL-Wawi (MSSQL) |
Vendored in `vendor/`: **llhttp**, **nlohmann/json** (no install needed).
### Install on Ubuntu / Debian
```bash
sudo apt install \
build-essential make pkg-config \
libuv1-dev libssl-dev libvips-dev unixodbc-dev
```
Install [Microsoft's ODBC driver](https://learn.microsoft.com/en-us/sql/connect/odbc/microsoft-odbc-driver-for-sql-server) (required for MSSQL):
```bash
curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | \
sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] \
https://packages.microsoft.com/ubuntu/$(lsb_release -rs)/prod $(lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/mssql-release.list
sudo apt update
sudo ACCEPT_EULA=Y apt install msodbcsql18
```
## Build
```bash
make
```
The binary is written to `./jtlsrv`. After code changes always run `make` before starting the server.
Debug build:
```bash
make debug
```
## Configuration
Copy the example env file from the repo root and edit as needed:
```bash
cp ../.env.example .env
```
Key settings:
| Variable | Default | Description |
|---|---|---|
| `PORT` | `4443` | HTTPS listen port |
| `PAIRING_CODE` | `307018` | Code shown to the POS during pairing |
| `AUTH_TOKEN` | (built-in default) | Device auth token after pairing |
| `MSSQL_*` | — | JTL-Wawi database connection |
| `MANDANT_*` | — | Mandant metadata returned to the client |
| `ROOT_CATEGORY_ID` | `1` | Root category for sync |
| `TAX_ZONE_NAME` | `Zone-EU` | Tax zone for product prices |
| `LOG_FILE` | `logs/requests.log` | Request log path |
| `ORDER_LOG_FILE` | `logs/orders.log` | Order log path |
The server starts without MSSQL if `MSSQL_USER` is unset or the connection fails — pairing and handshake still work, but catalog sync from the database is unavailable.
## TLS certificates
Place a certificate and key at `certs/cert.pem` and `certs/key.pem` (relative to the working directory when you run the binary).
```bash
mkdir -p certs
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout certs/key.pem -out certs/cert.pem -days 3650 \
-subj '/CN=localhost/O=JTL POS Sync/C=DE' \
-addext 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0'
```
On startup the server prints the pairing code and whether MSSQL connected.
## API endpoints
| Method | Path | Description |
|---|---|---|
| GET | `/v1/client` | Client discovery / pairing |
| GET | `/v1/init` | Init handshake |
| GET | `/v1/category` | Category sync |
| GET | `/v1/product` | Product sync |
| GET | `/v1/productcomposite` | Composite product sync |
| GET | `/v1/deletedentity` | Deleted entity sync |
| GET | `/v1/customergroup` | Customer group sync |
| POST | `/v1/order` | Submit orders |
| GET | `/v1/pimage` | Product image (resized) |
| GET | `/v1/cimage` | Category image (resized) |
## Project layout
```
Makefile
src/
main.cpp Entry point, route registration, startup
config.hpp .env loader
tls_server.{hpp,cpp} HTTPS over libuv + OpenSSL
http.{hpp,cpp} llhttp request/response handling
router.{hpp,cpp} Route dispatch
pairing.{hpp,cpp} In-memory pairing store
endpoints/ HTTP handlers
queries/ SQL query builders (header-only)
db/pool.{hpp,cpp} ODBC connection pool
vendor/ llhttp, nlohmann/json
```

View File

@@ -3,7 +3,10 @@
#include "../log.hpp" #include "../log.hpp"
#include <cstring> #include <cstring>
#include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <thread>
#include <chrono>
static OdbcPool g_pool; static OdbcPool g_pool;
@@ -11,6 +14,35 @@ 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,
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;
bool any = false;
while (true) {
SQLCHAR state[6], msg[SQL_MAX_MESSAGE_LENGTH];
SQLINTEGER native;
SQLSMALLINT msg_len;
SQLRETURN diag_rc = SQLGetDiagRec(handle_type, handle, ++rec,
state, &native, msg, sizeof(msg), &msg_len);
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";
}
}
int OdbcPool::connect() { int OdbcPool::connect() {
SQLRETURN rc; SQLRETURN rc;
@@ -31,7 +63,6 @@ int OdbcPool::connect() {
return -1; return -1;
} }
// Connection string
std::string conn_str = std::string conn_str =
"DRIVER={ODBC Driver 18 for SQL Server};" "DRIVER={ODBC Driver 18 for SQL Server};"
"SERVER=" + server + "," + std::to_string(port) + ";" "SERVER=" + server + "," + std::to_string(port) + ";"
@@ -43,8 +74,7 @@ int OdbcPool::connect() {
logc::info("ODBC connecting to %s:%d/%s as %s", server.c_str(), port, database.c_str(), user.c_str()); logc::info("ODBC connecting to %s:%d/%s as %s", server.c_str(), port, database.c_str(), user.c_str());
// Create pool of 4 connections const int POOL_SIZE = config::get_int("MSSQL_POOL_SIZE", 1);
const int POOL_SIZE = 4;
conns_.resize(POOL_SIZE); conns_.resize(POOL_SIZE);
int connected = 0; int connected = 0;
@@ -58,14 +88,14 @@ int OdbcPool::connect() {
if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) { if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
SQLAllocHandle(SQL_HANDLE_STMT, conns_[i].hdbc, &conns_[i].hstmt); SQLAllocHandle(SQL_HANDLE_STMT, conns_[i].hdbc, &conns_[i].hstmt);
SQLSetConnectAttr(conns_[i].hdbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0);
connected++; connected++;
} else { } else {
SQLCHAR state[6], msg[SQL_MAX_MESSAGE_LENGTH]; char ctx[32];
SQLINTEGER native; std::snprintf(ctx, sizeof(ctx), "connect[%d]", i);
SQLSMALLINT msg_len; odbc_log_diag(SQL_HANDLE_DBC, conns_[i].hdbc, ctx);
SQLGetDiagRec(SQL_HANDLE_DBC, conns_[i].hdbc, 1, SQLFreeHandle(SQL_HANDLE_DBC, conns_[i].hdbc);
state, &native, msg, sizeof(msg), &msg_len); conns_[i].hdbc = SQL_NULL_HDBC;
logc::warn("ODBC connect[%d] failed: %s - %s", i, state, msg);
} }
} }
@@ -87,44 +117,52 @@ void OdbcPool::disconnect() {
if (henv_ != SQL_NULL_HENV) { SQLFreeHandle(SQL_HANDLE_ENV, henv_); henv_ = SQL_NULL_HENV; } if (henv_ != SQL_NULL_HENV) { SQLFreeHandle(SQL_HANDLE_ENV, henv_); henv_ = SQL_NULL_HENV; }
} }
OdbcPool::Connection* OdbcPool::checkout() { OdbcPool::Connection* OdbcPool::checkout_raw() {
for (int attempt = 0; attempt < 300; attempt++) {
{
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
for (auto& c : conns_) { for (auto& c : conns_) {
if (!c.in_use) { c.in_use = true; return &c; } if (!c.in_use && c.hdbc != SQL_NULL_HDBC) {
c.in_use = true;
return &c;
}
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
} }
return nullptr; return nullptr;
} }
OdbcPool::ConnGuard OdbcPool::checkout() {
return ConnGuard(checkout_raw(), this);
}
void OdbcPool::release(Connection* c) { void OdbcPool::release(Connection* c) {
if (!c) return;
if (c->in_transaction) {
SQLEndTran(SQL_HANDLE_DBC, c->hdbc, SQL_ROLLBACK);
SQLSetConnectAttr(c->hdbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0);
c->in_transaction = false;
logc::warn("ODBC: rolled back uncommitted transaction on connection release");
}
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
c->in_use = false; c->in_use = false;
} }
bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params, ResultSet& out) { static bool bind_params(SQLHSTMT hstmt, const std::vector<Param>& params, std::vector<SQLLEN>& indicators) {
Connection* c = checkout(); indicators.assign(params.size(), 0);
if (!c) return false;
SQLRETURN rc; // Stable placeholder for null numeric parameters.
static const int64_t null_placeholder = 0;
// Prepare statement
rc = SQLPrepare(c->hstmt, (SQLCHAR*)sql.c_str(), SQL_NTS);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
release(c);
return false;
}
// Wide buffer storage for NVarChar parameters (must outlive SQLExecute)
std::vector<std::vector<SQLWCHAR>> wbufs;
// Bind parameters
for (size_t i = 0; i < params.size(); i++) { for (size_t i = 0; i < params.size(); i++) {
const auto& p = params[i]; const auto& p = params[i];
SQLUSMALLINT param_num = static_cast<SQLUSMALLINT>(i + 1); SQLUSMALLINT param_num = static_cast<SQLUSMALLINT>(i + 1);
SQLPOINTER val_ptr = nullptr; SQLPOINTER val_ptr = (SQLPOINTER)&null_placeholder;
SQLLEN buf_len = 0; SQLLEN buf_len = 0;
SQLLEN indicator = 0;
SQLSMALLINT c_type = SQL_C_CHAR; SQLSMALLINT c_type = SQL_C_CHAR;
SQLSMALLINT sql_type = SQL_VARCHAR; SQLSMALLINT sql_type = SQL_VARCHAR;
SQLULEN column_size = 1;
switch (p.type) { switch (p.type) {
case ParamType::Int: case ParamType::Int:
@@ -132,12 +170,16 @@ bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params,
sql_type = SQL_INTEGER; sql_type = SQL_INTEGER;
val_ptr = (SQLPOINTER)&p.int_val; val_ptr = (SQLPOINTER)&p.int_val;
buf_len = sizeof(SQLINTEGER); buf_len = sizeof(SQLINTEGER);
column_size = sizeof(SQLINTEGER);
indicators[i] = buf_len;
break; break;
case ParamType::BigInt: case ParamType::BigInt:
c_type = SQL_C_SBIGINT; c_type = SQL_C_SBIGINT;
sql_type = SQL_BIGINT; sql_type = SQL_BIGINT;
val_ptr = (SQLPOINTER)&p.int_val; val_ptr = (SQLPOINTER)&p.int_val;
buf_len = sizeof(SQLBIGINT); buf_len = sizeof(SQLBIGINT);
column_size = sizeof(SQLBIGINT);
indicators[i] = buf_len;
break; break;
case ParamType::Float: case ParamType::Float:
case ParamType::Double: case ParamType::Double:
@@ -145,64 +187,61 @@ bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params,
sql_type = SQL_DOUBLE; sql_type = SQL_DOUBLE;
val_ptr = (SQLPOINTER)&p.dbl_val; val_ptr = (SQLPOINTER)&p.dbl_val;
buf_len = sizeof(SQLDOUBLE); buf_len = sizeof(SQLDOUBLE);
column_size = sizeof(SQLDOUBLE);
indicators[i] = buf_len;
break; break;
case ParamType::NVarChar: { case ParamType::NVarChar:
// Convert narrow string to SQLWCHAR (unsigned short = UTF-16) case ParamType::DateTime:
// SQLWCHAR is unsigned short (2 bytes) on this platform // UTF-8 SQL_C_CHAR + SQL_WVARCHAR works with msodbcsql18 for nvarchar columns.
std::vector<SQLWCHAR> wbuf(p.str_val.size()); c_type = SQL_C_CHAR;
for (size_t k = 0; k < p.str_val.size(); k++)
wbuf[k] = static_cast<SQLWCHAR>((unsigned char)p.str_val[k]);
wbufs.push_back(std::move(wbuf));
auto& wb = wbufs.back();
c_type = SQL_C_WCHAR;
sql_type = SQL_WVARCHAR; sql_type = SQL_WVARCHAR;
val_ptr = (SQLPOINTER)wb.data(); column_size = p.str_val.empty() ? 1 : p.str_val.size();
buf_len = (SQLLEN)(wb.size() * sizeof(SQLWCHAR)); if (!p.is_null) {
indicator = wb.empty() ? SQL_NULL_DATA : (SQLLEN)(wb.size() * sizeof(SQLWCHAR)); val_ptr = (SQLPOINTER)p.str_val.c_str();
break; buf_len = static_cast<SQLLEN>(p.str_val.size());
indicators[i] = SQL_NTS;
} }
break;
case ParamType::Bit: case ParamType::Bit:
c_type = SQL_C_BIT; c_type = SQL_C_BIT;
sql_type = SQL_BIT; sql_type = SQL_BIT;
val_ptr = (SQLPOINTER)&p.int_val; val_ptr = (SQLPOINTER)&p.int_val;
buf_len = 1; buf_len = 1;
column_size = 1;
indicators[i] = 1;
break; break;
} }
rc = SQLBindParameter(c->hstmt, param_num, SQL_PARAM_INPUT, if (p.is_null) {
c_type, sql_type, p.str_val.size() + 1, 0, val_ptr, buf_len, &indicator); indicators[i] = SQL_NULL_DATA;
}
SQLRETURN rc = SQLBindParameter(hstmt, param_num, SQL_PARAM_INPUT,
c_type, sql_type, column_size, 0, val_ptr, buf_len, &indicators[i]);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) { if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
release(c);
return false; return false;
} }
} }
return true;
}
// Execute static bool fetch_results(SQLHSTMT hstmt, ResultSet& out) {
rc = SQLExecute(c->hstmt);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO && rc != SQL_NO_DATA) {
release(c);
return false;
}
// Fetch results if it's a SELECT
out.clear(); out.clear();
while (true) {
SQLSMALLINT col_count = 0; SQLSMALLINT col_count = 0;
SQLNumResultCols(c->hstmt, &col_count); SQLNumResultCols(hstmt, &col_count);
if (col_count > 0) { if (col_count > 0) {
// Discover column types
std::vector<SQLSMALLINT> col_types(col_count); std::vector<SQLSMALLINT> col_types(col_count);
bool has_blobs = false;
for (SQLSMALLINT col = 0; col < col_count; col++) { for (SQLSMALLINT col = 0; col < col_count; col++) {
SQLSMALLINT data_type; SQLSMALLINT data_type;
SQLDescribeCol(c->hstmt, col + 1, nullptr, 0, nullptr, &data_type, nullptr, nullptr, nullptr); SQLDescribeCol(hstmt, col + 1, nullptr, 0, nullptr, &data_type, nullptr, nullptr, nullptr);
col_types[col] = data_type; col_types[col] = data_type;
if (data_type == SQL_BINARY || data_type == SQL_VARBINARY || data_type == SQL_LONGVARBINARY)
has_blobs = true;
} }
while (true) { while (true) {
rc = SQLFetch(c->hstmt); SQLRETURN rc = SQLFetch(hstmt);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) break; if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) break;
Row row; Row row;
@@ -211,35 +250,27 @@ bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params,
SQLLEN ind; SQLLEN ind;
SQLSMALLINT sql_type = col_types[col]; SQLSMALLINT sql_type = col_types[col];
// Binary columns: read as binary
if (sql_type == SQL_BINARY || sql_type == SQL_VARBINARY || if (sql_type == SQL_BINARY || sql_type == SQL_VARBINARY ||
sql_type == SQL_LONGVARBINARY) { sql_type == SQL_LONGVARBINARY) {
std::vector<uint8_t> blob_data; std::vector<uint8_t> blob_data;
unsigned char chunk[8192]; unsigned char chunk[8192];
int chunk_count = 0;
while (true) { while (true) {
rc = SQLGetData(c->hstmt, col + 1, SQL_C_BINARY, chunk, sizeof(chunk), &ind); rc = SQLGetData(hstmt, col + 1, SQL_C_BINARY, chunk, sizeof(chunk), &ind);
if (ind == SQL_NULL_DATA) { break; } if (ind == SQL_NULL_DATA) { break; }
if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) { if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
SQLLEN copy_len = std::min(ind, (SQLLEN)sizeof(chunk)); SQLLEN copy_len = std::min(ind, (SQLLEN)sizeof(chunk));
if (has_blobs && chunk_count == 0)
logc::info(" blob col=%d rc=%d ind=%d copy=%d", col, rc, (int)ind, (int)copy_len);
blob_data.insert(blob_data.end(), chunk, chunk + copy_len); blob_data.insert(blob_data.end(), chunk, chunk + copy_len);
chunk_count++;
} }
if (rc == SQL_SUCCESS) break; if (rc == SQL_SUCCESS) break;
if (rc != SQL_SUCCESS_WITH_INFO) break; if (rc != SQL_SUCCESS_WITH_INFO) break;
} }
if (chunk_count > 0) { if (!blob_data.empty()) {
cell.type = CellType::Blob; cell.type = CellType::Blob;
cell.blob = std::move(blob_data); cell.blob = std::move(blob_data);
if (has_blobs)
logc::info(" blob col=%d total=%zu chunks=%d", col, cell.blob.size(), chunk_count);
} }
} else { } else {
// String/numeric columns: read as char
char buf[4096]; char buf[4096];
rc = SQLGetData(c->hstmt, col + 1, SQL_C_CHAR, buf, sizeof(buf) - 1, &ind); rc = SQLGetData(hstmt, col + 1, SQL_C_CHAR, buf, sizeof(buf) - 1, &ind);
if (ind == SQL_NULL_DATA) { if (ind == SQL_NULL_DATA) {
cell.type = CellType::Null; cell.type = CellType::Null;
} else if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) { } else if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
@@ -251,22 +282,78 @@ bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params,
} }
out.push_back(std::move(row)); out.push_back(std::move(row));
} }
if (!out.empty()) return true;
} }
// Reset statement for reuse SQLRETURN rc = SQLMoreResults(hstmt);
if (rc == SQL_NO_DATA) break;
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) break;
}
return true;
}
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) {
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)) {
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) {
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); SQLFreeStmt(c->hstmt, SQL_UNBIND);
SQLFreeStmt(c->hstmt, SQL_CLOSE); SQLFreeStmt(c->hstmt, SQL_CLOSE);
release(c);
return true; return true;
} }
bool OdbcPool::execute(Connection* c, const std::string& sql, ResultSet& out) {
return execute(c, sql, {}, out);
}
bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params, ResultSet& out) {
ConnGuard g = checkout();
if (!g) {
logc::warn("ODBC execute: pool exhausted");
return false;
}
bool ok = execute(g.get(), sql, params, out);
return ok;
}
bool OdbcPool::execute(const std::string& sql, ResultSet& out) { bool OdbcPool::execute(const std::string& sql, ResultSet& out) {
return execute(sql, {}, out); return execute(sql, {}, out);
} }
int64_t OdbcPool::execute_scalar(const std::string& sql, const std::vector<Param>& params, int64_t fallback) { int64_t OdbcPool::execute_scalar(const std::string& sql, const std::vector<Param>& params, int64_t fallback) {
ResultSet rs; ResultSet rs;
if (!execute(sql, params, rs) || rs.empty() || rs[0].empty()) return fallback; if (!execute(sql, params, rs)) {
logc::warn("ODBC scalar query failed");
return fallback;
}
if (rs.empty() || rs[0].empty()) return fallback;
const auto& cell = rs[0][0]; const auto& cell = rs[0][0];
if (cell.type == CellType::Int64) return cell.i64; if (cell.type == CellType::Int64) return cell.i64;
if (cell.type == CellType::String) { if (cell.type == CellType::String) {
@@ -274,3 +361,42 @@ int64_t OdbcPool::execute_scalar(const std::string& sql, const std::vector<Param
} }
return fallback; return fallback;
} }
bool OdbcPool::begin(Connection* c) {
if (!c) return false;
if (c->in_transaction) {
logc::warn("ODBC begin: connection already in transaction");
return false;
}
SQLRETURN rc = SQLSetConnectAttr(c->hdbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
odbc_log_diag(SQL_HANDLE_DBC, c->hdbc, "begin");
return false;
}
c->in_transaction = true;
return true;
}
bool OdbcPool::commit(Connection* c) {
if (!c || !c->in_transaction) return false;
SQLRETURN rc = SQLEndTran(SQL_HANDLE_DBC, c->hdbc, SQL_COMMIT);
SQLSetConnectAttr(c->hdbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0);
c->in_transaction = false;
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
odbc_log_diag(SQL_HANDLE_DBC, c->hdbc, "commit");
return false;
}
return true;
}
bool OdbcPool::rollback(Connection* c) {
if (!c || !c->in_transaction) return true;
SQLRETURN rc = SQLEndTran(SQL_HANDLE_DBC, c->hdbc, SQL_ROLLBACK);
SQLSetConnectAttr(c->hdbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0);
c->in_transaction = false;
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
odbc_log_diag(SQL_HANDLE_DBC, c->hdbc, "rollback");
return false;
}
return true;
}

View File

@@ -21,35 +21,78 @@ struct Cell {
using Row = std::vector<Cell>; using Row = std::vector<Cell>;
using ResultSet = std::vector<Row>; using ResultSet = std::vector<Row>;
enum class ParamType { Int, BigInt, Float, Double, NVarChar, Bit }; enum class ParamType { Int, BigInt, Float, Double, NVarChar, Bit, DateTime };
struct Param { struct Param {
ParamType type; ParamType type;
std::string str_val; std::string str_val;
int64_t int_val = 0; int64_t int_val = 0;
double dbl_val = 0.0; double dbl_val = 0.0;
bool is_null = false;
static Param null_int() { Param p; p.type = ParamType::Int; p.is_null = true; return p; }
static Param null_bigint() { Param p; p.type = ParamType::BigInt; p.is_null = true; return p; }
static Param null_nvarchar() { Param p; p.type = ParamType::NVarChar; p.is_null = true; return p; }
}; };
class OdbcPool { class OdbcPool {
public: public:
~OdbcPool();
int connect();
void disconnect();
bool execute(const std::string& sql, const std::vector<Param>& params, ResultSet& out);
bool execute(const std::string& sql, ResultSet& out);
int64_t execute_scalar(const std::string& sql, const std::vector<Param>& params = {}, int64_t fallback = 0);
private:
struct Connection { struct Connection {
SQLHDBC hdbc = SQL_NULL_HDBC; SQLHDBC hdbc = SQL_NULL_HDBC;
SQLHSTMT hstmt = SQL_NULL_HSTMT; SQLHSTMT hstmt = SQL_NULL_HSTMT;
bool in_use = false; bool in_use = false;
bool in_transaction = false;
}; };
// RAII checkout guard. Returned by checkout().
struct ConnGuard {
Connection* conn = nullptr;
OdbcPool* pool = nullptr;
ConnGuard() = default;
ConnGuard(Connection* c, OdbcPool* p) : conn(c), pool(p) {}
~ConnGuard() { if (conn && pool) pool->release(conn); }
ConnGuard(const ConnGuard&) = delete;
ConnGuard(ConnGuard&& other) noexcept : conn(other.conn), pool(other.pool) { other.conn = nullptr; }
ConnGuard& operator=(ConnGuard&& other) noexcept {
if (this != &other) {
if (conn && pool) pool->release(conn);
conn = other.conn;
pool = other.pool;
other.conn = nullptr;
}
return *this;
}
Connection* operator->() const { return conn; }
Connection* get() const { return conn; }
explicit operator bool() const { return conn != nullptr; }
};
~OdbcPool();
int connect();
void disconnect();
bool execute(const std::string& sql, const std::vector<Param>& params, ResultSet& out);
bool execute(const std::string& sql, ResultSet& out);
int64_t execute_scalar(const std::string& sql, const std::vector<Param>& params = {}, int64_t fallback = 0);
// Transactional execution on an explicitly checked-out connection.
ConnGuard checkout();
bool execute(Connection* conn, const std::string& sql, const std::vector<Param>& params, ResultSet& out);
bool execute(Connection* conn, const std::string& sql, ResultSet& out);
bool begin(Connection* conn);
bool commit(Connection* conn);
bool rollback(Connection* conn);
void release(Connection* c);
const std::string& last_error() const { return last_error_; }
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_;
Connection* checkout(); std::string last_error_;
void release(Connection* c); Connection* checkout_raw();
}; };
OdbcPool& get_pool(); OdbcPool& get_pool();

View File

@@ -3,9 +3,9 @@
#include "../router.hpp" #include "../router.hpp"
#include "../queries/category_list.hpp" #include "../queries/category_list.hpp"
void handle_category(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) { void handle_category(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedCategory", "0")); int64_t cursor = req.get_query_int64("lastChangedCategory");
int limit = std::stoi(req.get_query_param("limit", "20")); int limit = req.get_query_int("limit", 20);
auto categories = get_category_list(cursor, limit); auto categories = get_category_list(cursor, limit);
resp.send_json(200, categories); resp.send_json(200, categories);
} }

View File

@@ -4,7 +4,7 @@
#include "../log.hpp" #include "../log.hpp"
#include "../queries/image.hpp" #include "../queries/image.hpp"
void handle_cimage(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) { void handle_cimage(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
std::string path = req.get_query_param("path"); std::string path = req.get_query_param("path");
if (path.empty()) { if (path.empty()) {
resp.send_json(400, {{"Message", "Missing required query parameter 'path'."}}); resp.send_json(400, {{"Message", "Missing required query parameter 'path'."}});

View File

@@ -6,16 +6,27 @@
#include "../tls_server.hpp" #include "../tls_server.hpp"
#include "../router.hpp" #include "../router.hpp"
static std::string config_string(const json& config, const char* key,
const std::string& def = "") {
if (!config.is_object()) return def;
json v = config.value(key, json());
if (v.is_null()) return def;
if (v.is_string()) return v.get<std::string>();
if (v.is_number_integer()) return std::to_string(v.get<int64_t>());
if (v.is_number_unsigned()) return std::to_string(v.get<uint64_t>());
return def;
}
static json build_client_step1(const json& config) { static json build_client_step1(const json& config) {
return { return {
{"authCode", nullptr}, {"authCode", nullptr},
{"authToken", config["authToken"]}, {"authToken", config_string(config, "authToken")},
{"certificateFingerprint", config["certificateFingerprint"]}, {"certificateFingerprint", config_string(config, "certificateFingerprint")},
{"certificateSerialNumber", config["certificateSerialNumber"]}, {"certificateSerialNumber", config_string(config, "certificateSerialNumber")},
{"mandantId", config["mandantId"]}, {"mandantId", config_string(config, "mandantId")},
{"mandantName", nullptr}, {"mandantName", nullptr},
{"mandantDatabase", nullptr}, {"mandantDatabase", nullptr},
{"serverFingerprint", config["serverFingerprint"]}, {"serverFingerprint", config_string(config, "serverFingerprint")},
{"name", nullptr}, {"name", nullptr},
{"serverTimestamp", server_timestamp()}, {"serverTimestamp", server_timestamp()},
}; };
@@ -24,12 +35,12 @@ static json build_client_step1(const json& config) {
static json build_client_step2(const std::string& auth_code, const json& config) { static json build_client_step2(const std::string& auth_code, const json& config) {
return { return {
{"authCode", auth_code}, {"authCode", auth_code},
{"authToken", config["authToken"]}, {"authToken", config_string(config, "authToken")},
{"certificateFingerprint", config["certificateFingerprint"]}, {"certificateFingerprint", config_string(config, "certificateFingerprint")},
{"certificateSerialNumber", config["certificateSerialNumber"]}, {"certificateSerialNumber", config_string(config, "certificateSerialNumber")},
{"mandantId", config["mandantId"]}, {"mandantId", config_string(config, "mandantId")},
{"mandantName", config["mandantName"]}, {"mandantName", config_string(config, "mandantName")},
{"mandantDatabase", config["mandantDatabase"]}, {"mandantDatabase", config_string(config, "mandantDatabase")},
{"serverFingerprint", nullptr}, {"serverFingerprint", nullptr},
{"name", nullptr}, {"name", nullptr},
{"serverTimestamp", server_timestamp()}, {"serverTimestamp", server_timestamp()},
@@ -47,7 +58,7 @@ void handle_client(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
if (auth_code.size() == 6) { if (auth_code.size() == 6) {
if (ctx.pairing_store->has_pairing_code(auth_code)) { if (ctx.pairing_store->has_pairing_code(auth_code)) {
ctx.pairing_store->revoke_pairing_code(auth_code); ctx.pairing_store->revoke_pairing_code(auth_code);
ctx.pairing_store->register_device(ctx.config["authToken"].get<std::string>(), name); ctx.pairing_store->register_device(config_string(ctx.config, "authToken"), name);
return resp.send_json(200, build_client_step2(auth_code, ctx.config)); return resp.send_json(200, build_client_step2(auth_code, ctx.config));
} }
return resp.send_json(400, { return resp.send_json(400, {

View File

@@ -3,8 +3,8 @@
#include "../router.hpp" #include "../router.hpp"
#include "../queries/customer_groups.hpp" #include "../queries/customer_groups.hpp"
void handle_customergroup(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) { void handle_customergroup(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedCustomerGroup", "0")); int64_t cursor = req.get_query_int64("lastChangedCustomerGroup");
auto groups = get_customer_group_list(cursor); auto groups = get_customer_group_list(cursor);
resp.send_json(200, groups); resp.send_json(200, groups);
} }

View File

@@ -3,9 +3,9 @@
#include "../router.hpp" #include "../router.hpp"
#include "../queries/deleted_entity_list.hpp" #include "../queries/deleted_entity_list.hpp"
void handle_deleted_entity(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) { void handle_deleted_entity(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedDeletedEntity", "0")); int64_t cursor = req.get_query_int64("lastChangedDeletedEntity");
int limit = std::stoi(req.get_query_param("limit", "600")); int limit = req.get_query_int("limit", 600);
auto deleted = get_deleted_entity_list(cursor, limit); auto deleted = get_deleted_entity_list(cursor, limit);
resp.send_json(200, deleted); resp.send_json(200, deleted);
} }

View File

@@ -1,22 +1,24 @@
#include "../http.hpp" #include "../http.hpp"
#include "../tls_server.hpp" #include "../tls_server.hpp"
#include "../router.hpp" #include "../router.hpp"
#include "../log.hpp"
#include "../queries/counts.hpp" #include "../queries/counts.hpp"
#include "../queries/customer_groups.hpp" #include "../queries/customer_groups.hpp"
#include "../queries/shop.hpp" #include "../queries/shop.hpp"
#include "../config.hpp" #include "../config.hpp"
void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) { void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t product_cursor = std::stoll(req.get_query_param("lastChangedProduct", "0")); int64_t product_cursor = req.get_query_int64("lastChangedProduct");
int64_t category_cursor = std::stoll(req.get_query_param("lastChangedCategory", "0")); int64_t category_cursor = req.get_query_int64("lastChangedCategory");
int64_t cg_cursor = std::stoll(req.get_query_param("lastChangedCustomerGroup", "0")); int64_t cg_cursor = req.get_query_int64("lastChangedCustomerGroup");
int64_t composite_cursor = std::stoll(req.get_query_param("lastChangedCompositeProduct", "0")); int64_t composite_cursor = req.get_query_int64("lastChangedCompositeProduct");
int64_t deleted_cursor = std::stoll(req.get_query_param("lastChangedDeletedEntity", "0")); int64_t deleted_cursor = req.get_query_int64("lastChangedDeletedEntity");
int root = config::get_int("ROOT_CATEGORY_ID", 1); int root = config::get_int("ROOT_CATEGORY_ID", 1);
int shop = get_active_shop_id(); int shop = get_active_shop_id();
int64_t product_count = 0, category_count = 0, cg_count = 0, composite_count = 0, deleted_count = 0; int64_t product_count = 0, category_count = 0, cg_count = 0, composite_count = 0, deleted_count = 0;
int64_t max_order_id_count = 0;
if (get_pool().execute_scalar("SELECT 1") != 0) { if (get_pool().execute_scalar("SELECT 1") != 0) {
product_count = get_product_count(root, shop, product_cursor); product_count = get_product_count(root, shop, product_cursor);
@@ -24,6 +26,13 @@ void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
cg_count = get_customer_group_count(cg_cursor); cg_count = get_customer_group_count(cg_cursor);
composite_count = get_composite_count(shop, composite_cursor); composite_count = get_composite_count(shop, composite_cursor);
deleted_count = get_deleted_count(deleted_cursor); deleted_count = get_deleted_count(deleted_cursor);
max_order_id_count = get_max_order_id_count(get_active_shop_subshop_id());
if (product_cursor == 0 && category_cursor == 0 &&
product_count == 0 && category_count == 0 && deleted_count == 0) {
logc::warn("init: all counts zero with cursors at 0 — check DB connectivity and shop/category config");
}
} else {
logc::warn("init: DB connection check failed, returning zero counts");
} }
resp.send_json(200, { resp.send_json(200, {
@@ -36,6 +45,6 @@ void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
{"configurationGroup_count", "0"}, {"configurationGroup_count", "0"},
{"configurationItem_count", "0"}, {"configurationItem_count", "0"},
{"deletedEntity_count", std::to_string(deleted_count)}, {"deletedEntity_count", std::to_string(deleted_count)},
{"max_orderId_count", "0"} {"max_orderId_count", std::to_string(max_order_id_count)}
}); });
} }

View File

@@ -1,11 +1,22 @@
// POST /v1/order — creates one or more POS orders.
// Port of src/endpoints/order.js and src/queries/create-order.js.
#include "../http.hpp" #include "../http.hpp"
#include "../tls_server.hpp"
#include "../router.hpp" #include "../router.hpp"
#include "../log.hpp" #include "../log.hpp"
#include "../order_log.hpp"
#include "../queries/create_order.hpp"
void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) { #include <string>
// TODO: full order creation (Milestone 5/6)
// For now, parse the JSON body and acknowledge static nlohmann::json get_orders(const nlohmann::json& body) {
if (!body.is_object()) return nlohmann::json::array();
auto it = body.find("orders");
if (it == body.end() || !it->is_array()) return nlohmann::json::array();
return *it;
}
void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
nlohmann::json body; nlohmann::json body;
try { try {
body = nlohmann::json::parse(req.body); body = nlohmann::json::parse(req.body);
@@ -14,15 +25,59 @@ void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
} }
nlohmann::json results = nlohmann::json::array(); nlohmann::json results = nlohmann::json::array();
if (body.contains("orders") && body["orders"].is_array()) { nlohmann::json orders = get_orders(body);
for (auto& order : body["orders"]) {
std::string ext_id = order.value("externalId", ""); int successful = 0;
int failed = 0;
for (const auto& order : orders) {
std::string externalOrderId = order.value("externalId", "");
g_order_log.log_order(order.dump(), externalOrderId);
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(),
externalOrderId.c_str());
results.push_back({ results.push_back({
{"status", "OK"}, {"status", "OK"},
{"externalOrderId", ext_id}, {"externalOrderId", externalOrderId},
{"message", ""} {"message", ""}
}); });
++successful;
}
} catch (const std::exception& ex) {
logc::error("order externalId=%s failed: %s", externalOrderId.c_str(), ex.what());
results.push_back({
{"status", "ERROR"},
{"externalOrderId", externalOrderId},
{"message", ex.what()}
});
++failed;
} }
} }
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");
} else {
logc::info("POST /v1/order: %d order(s), %d OK, %d ERROR",
static_cast<int>(orders.size()), successful, failed);
}
} }

View File

@@ -4,7 +4,7 @@
#include "../log.hpp" #include "../log.hpp"
#include "../queries/image.hpp" #include "../queries/image.hpp"
void handle_pimage(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) { void handle_pimage(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
std::string path = req.get_query_param("path"); std::string path = req.get_query_param("path");
if (path.empty()) { if (path.empty()) {
resp.send_json(400, {{"Message", "Missing required query parameter 'path'."}}); resp.send_json(400, {{"Message", "Missing required query parameter 'path'."}});

View File

@@ -3,9 +3,9 @@
#include "../router.hpp" #include "../router.hpp"
#include "../queries/product_list.hpp" #include "../queries/product_list.hpp"
void handle_product(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) { void handle_product(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedProduct", "0")); int64_t cursor = req.get_query_int64("lastChangedProduct");
int limit = std::stoi(req.get_query_param("limit", "20")); int limit = req.get_query_int("limit", 20);
auto products = get_product_list(cursor, limit); auto products = get_product_list(cursor, limit);
resp.send_json(200, products); resp.send_json(200, products);
} }

View File

@@ -3,9 +3,9 @@
#include "../router.hpp" #include "../router.hpp"
#include "../queries/composite_product_list.hpp" #include "../queries/composite_product_list.hpp"
void handle_productcomposite(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) { void handle_productcomposite(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
int64_t cursor = std::stoll(req.get_query_param("lastChangedCompositeProduct", "0")); int64_t cursor = req.get_query_int64("lastChangedCompositeProduct");
int limit = std::stoi(req.get_query_param("limit", "100")); int limit = req.get_query_int("limit", 100);
auto composites = get_composite_product_list(cursor, limit); auto composites = get_composite_product_list(cursor, limit);
resp.send_json(200, composites); resp.send_json(200, composites);
} }

View File

@@ -7,6 +7,25 @@
#include <cstring> #include <cstring>
#include <ctime> #include <ctime>
// ---------------------------------------------------------------------------
// Safe parsing
// ---------------------------------------------------------------------------
int parse_int(const std::string& s, int fallback) {
if (s.empty()) return fallback;
try { return std::stoi(s); } catch (...) { return fallback; }
}
int64_t parse_int64(const std::string& s, int64_t fallback) {
if (s.empty()) return fallback;
try { return std::stoll(s); } catch (...) { return fallback; }
}
double parse_double(const std::string& s, double fallback) {
if (s.empty()) return fallback;
try { return std::stod(s); } catch (...) { return fallback; }
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// HttpRequest // HttpRequest
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -39,6 +58,14 @@ std::string HttpRequest::get_query_param(const std::string& key, const std::stri
return decoded; return decoded;
} }
int HttpRequest::get_query_int(const std::string& key, int def) const {
return parse_int(get_query_param(key), def);
}
int64_t HttpRequest::get_query_int64(const std::string& key, int64_t def) const {
return parse_int64(get_query_param(key), def);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// HttpResponse // HttpResponse
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -17,6 +17,11 @@ struct tls_session;
using json = nlohmann::json; using json = nlohmann::json;
// Safe string-to-number parsing (never throws).
int parse_int(const std::string& s, int fallback = 0);
int64_t parse_int64(const std::string& s, int64_t fallback = 0);
double parse_double(const std::string& s, double fallback = 0.0);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parsed HTTP request // Parsed HTTP request
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -32,6 +37,8 @@ struct HttpRequest {
std::string query_string; // "authCode=xxx&name=yyy" std::string query_string; // "authCode=xxx&name=yyy"
std::string get_query_param(const std::string& key, const std::string& def = "") const; std::string get_query_param(const std::string& key, const std::string& def = "") const;
int get_query_int(const std::string& key, int def = 0) const;
int64_t get_query_int64(const std::string& key, int64_t def = 0) const;
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -16,6 +16,7 @@
#include <vips/vips.h> #include <vips/vips.h>
#include "queries/shop.hpp" #include "queries/shop.hpp"
#include "queries/customer_groups.hpp"
#include "request_log.hpp" #include "request_log.hpp"
#include "order_log.hpp" #include "order_log.hpp"
@@ -27,7 +28,6 @@ static uv_loop_t* loop = nullptr;
static Router router; static Router router;
static PairingStore pairing_store; static PairingStore pairing_store;
static RequestLog request_log; static RequestLog request_log;
static OrderLog order_log;
static json build_config() { static json build_config() {
return { return {
@@ -82,26 +82,36 @@ static void handle_request(tls_session* sess) {
} }
size_t resp_size = resp_body.size(); size_t resp_size = resp_body.size();
const bool is_init = (req.path == "/v1/init");
if (is_init) {
if (!router.should_log_init(url)) {
return;
}
} else {
router.flush_suppressed_init_logs();
}
// Console log with response size and truncated body // Console log with response size and truncated body
const char* ip = sess->peer_ip.c_str();
if (resp_size > 0) { if (resp_size > 0) {
std::string preview = resp_body.substr(0, std::min(resp_size, (size_t)200)); std::string preview = resp_body.substr(0, std::min(resp_size, (size_t)220));
logc::info("127.0.0.1 %s %s %d %dms [%zu bytes] %s", logc::info("%s %s %s %d %dms [%zu bytes] %s",
req.method.c_str(), url.c_str(), resp.status_code, ip, req.method.c_str(), url.c_str(), resp.status_code,
(int)elapsed, resp_size, preview.c_str()); (int)elapsed, resp_size, preview.c_str());
} else { } else {
logc::info("127.0.0.1 %s %s %d %dms", logc::info("%s %s %s %d %dms",
req.method.c_str(), url.c_str(), resp.status_code, (int)elapsed); ip, req.method.c_str(), url.c_str(), resp.status_code, (int)elapsed);
} }
// Request log // Request log
request_log.log("127.0.0.1", req.method, url, resp.status_code, (int)elapsed, resp_body); request_log.log(ip, req.method, url, resp.status_code, (int)elapsed, resp_body);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Entry point // Entry point
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
int main(int argc, char* argv[]) { int main(int /*argc*/, char* argv[]) {
config::load(".env"); config::load(".env");
if (VIPS_INIT(argv[0])) { if (VIPS_INIT(argv[0])) {
@@ -109,6 +119,7 @@ int main(int argc, char* argv[]) {
} }
int port = config::get_int("PORT", 4443); int port = config::get_int("PORT", 4443);
std::string bind_address = config::get("BIND_ADDRESS", "0.0.0.0");
std::string cert_path = "certs/cert.pem"; std::string cert_path = "certs/cert.pem";
std::string key_path = "certs/key.pem"; std::string key_path = "certs/key.pem";
@@ -130,7 +141,7 @@ int main(int argc, char* argv[]) {
// Initialize pairing store // Initialize pairing store
pairing_store.set_pairing_code(config::get("PAIRING_CODE", "307018"), "JTL-POS"); pairing_store.set_pairing_code(config::get("PAIRING_CODE", "307018"), "JTL-POS");
pairing_store.register_device(server_config["authToken"].get<std::string>(), "JTL-POS"); pairing_store.register_device(server_config.value("authToken", std::string("df40ad2067954646abb0499548a52241")), "JTL-POS");
// Connect to MSSQL // Connect to MSSQL
if (get_pool().connect() == 0) { if (get_pool().connect() == 0) {
@@ -139,7 +150,10 @@ int main(int argc, char* argv[]) {
config::get("MSSQL_DATABASE").c_str()); config::get("MSSQL_DATABASE").c_str());
if (fetch_active_shop()) { if (fetch_active_shop()) {
logc::info("Active shop ID: %d", get_active_shop_id()); logc::info("Active shop ID: %d", get_active_shop_id());
} else {
logc::warn("Active shop not loaded — sync filters and order mapping may be wrong");
} }
(void)get_customer_group_ids();
} else { } else {
logc::warn("MSSQL connection skipped"); logc::warn("MSSQL connection skipped");
logc::warn("POS handshake will still work; sync from database is not available yet."); logc::warn("POS handshake will still work; sync from database is not available yet.");
@@ -149,9 +163,9 @@ int main(int argc, char* argv[]) {
// Open log files // Open log files
request_log.open(config::get("LOG_FILE", "logs/requests.log")); request_log.open(config::get("LOG_FILE", "logs/requests.log"));
order_log.open(config::get("ORDER_LOG_FILE", "logs/orders.log")); g_order_log.open(config::get("ORDER_LOG_FILE", "logs/orders.log"));
int r = tls_server_init(loop, "0.0.0.0", port, int r = tls_server_init(loop, bind_address.c_str(), port,
cert_path.c_str(), key_path.c_str()); cert_path.c_str(), key_path.c_str());
if (r != 0) { if (r != 0) {
logc::error("failed to start TLS server"); logc::error("failed to start TLS server");
@@ -165,7 +179,7 @@ int main(int argc, char* argv[]) {
uv_run(loop, UV_RUN_DEFAULT); uv_run(loop, UV_RUN_DEFAULT);
request_log.close(); request_log.close();
order_log.close(); g_order_log.close();
get_pool().disconnect(); get_pool().disconnect();
vips_shutdown(); vips_shutdown();
logc::info("shutdown complete."); logc::info("shutdown complete.");

View File

@@ -3,6 +3,8 @@
#include <ctime> #include <ctime>
#include <sys/stat.h> #include <sys/stat.h>
OrderLog g_order_log;
static void ensure_parent_dir(const std::string& path) { static void ensure_parent_dir(const std::string& path) {
size_t pos = path.rfind('/'); size_t pos = path.rfind('/');
if (pos != std::string::npos) { if (pos != std::string::npos) {
@@ -19,10 +21,9 @@ void OrderLog::close() {
if (fp_) { std::fclose(fp_); fp_ = nullptr; } if (fp_) { std::fclose(fp_); fp_ = nullptr; }
} }
int OrderLog::log_order(const std::string& order_json) { void OrderLog::log_order(const std::string& order_json, const std::string& external_id) {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
sequence_++; sequence_++;
max_external_id_++;
if (fp_) { if (fp_) {
auto now = std::chrono::system_clock::now(); auto now = std::chrono::system_clock::now();
@@ -32,13 +33,8 @@ int OrderLog::log_order(const std::string& order_json) {
char ts[32]; char ts[32];
std::strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%S", &tm_buf); std::strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%S", &tm_buf);
std::fprintf(fp_, "%s #%d externalId=%d %s\n", std::fprintf(fp_, "%s #%d externalId=%s %s\n",
ts, sequence_, max_external_id_, order_json.c_str()); ts, sequence_, external_id.c_str(), order_json.c_str());
std::fflush(fp_); std::fflush(fp_);
} }
return max_external_id_;
}
std::string OrderLog::get_max_external_id() const {
return std::to_string(max_external_id_);
} }

View File

@@ -7,11 +7,11 @@ class OrderLog {
public: public:
void open(const std::string& path); void open(const std::string& path);
void close(); void close();
int log_order(const std::string& order_json); void log_order(const std::string& order_json, const std::string& external_id = "");
std::string get_max_external_id() const;
private: private:
FILE* fp_ = nullptr; FILE* fp_ = nullptr;
std::mutex mutex_; std::mutex mutex_;
int sequence_ = 0; int sequence_ = 0;
int max_external_id_ = 0;
}; };
extern OrderLog g_order_log;

View File

@@ -41,7 +41,8 @@ inline nlohmann::json get_category_list(int64_t cursor, int limit) {
auto ts = server_timestamp(); auto ts = server_timestamp();
nlohmann::json result = nlohmann::json::array(); nlohmann::json result = nlohmann::json::array();
for (auto& row : rs) { for (auto& row : rs) {
std::string pid = (std::stoll(row[1].str) == root) ? "0" : row[1].str; int64_t parent_id = parse_int64(row[1].str, 0);
std::string pid = (parent_id == root) ? "0" : row[1].str;
result.push_back({ result.push_back({
{"_id", row[0].str}, {"_id", row[0].str},
{"imghash", row[4].str.empty() ? nullptr : nlohmann::json(row[4].str)}, {"imghash", row[4].str.empty() ? nullptr : nlohmann::json(row[4].str)},

View File

@@ -0,0 +1,35 @@
#pragma once
#include <string>
#include "xml.hpp"
#include "../db/pool.hpp"
namespace delivery {
inline void commit_picklists(OdbcPool::Connection* c, int kBenutzer, int kSessionId, int kAuftrag) {
std::string bestellungen = xml::element("Bestellung", xml::tag("kBestellung", int64_t(kAuftrag)));
const char* sql =
"DECLARE @xBestellungen XML = CONVERT(XML, ?);"
"DECLARE @xResult XML;"
"EXEC Auslieferung.spPicklistenUebernehmen"
" @Bestellungen = @xBestellungen,"
" @kBenutzer = ?,"
" @nTeillieferung = 0,"
" @kSessionId = ?,"
" @xResult = @xResult OUTPUT";
std::vector<Param> ps = {
{ParamType::NVarChar, bestellungen, 0},
{ParamType::Int, "", kBenutzer},
{ParamType::Int, "", kSessionId}
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs)) {
const std::string& detail = get_pool().last_error();
throw std::runtime_error(detail.empty()
? "commit_picklists failed"
: "commit_picklists failed: " + detail);
}
}
} // namespace delivery

View File

@@ -43,23 +43,32 @@ static const char* DELETED_ENTITY_COUNT_SQL =
"SELECT COUNT(*) AS cnt FROM Pos.vDeletedEntity " "SELECT COUNT(*) AS cnt FROM Pos.vDeletedEntity "
"WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > ?"; "WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > ?";
static const char* MAX_ORDER_ID_SQL =
"SELECT ISNULL(MAX(kPosAuftrag), 0) AS cnt FROM Pos.tAuftragMapping "
"WHERE kShopSubShop = ?";
inline int64_t get_category_count(int64_t root_cat, int k_shop, int64_t cursor) { inline int64_t get_category_count(int64_t root_cat, int k_shop, int64_t cursor) {
return get_pool().execute_scalar(CATEGORY_COUNT_SQL, return get_pool().execute_scalar(CATEGORY_COUNT_SQL,
{{ParamType::BigInt,"",root_cat},{ParamType::BigInt,"",k_shop}, {{ParamType::BigInt,"",root_cat},{ParamType::Int,"",k_shop},
{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",cursor}}); {ParamType::Int,"",k_shop},{ParamType::BigInt,"",cursor}});
} }
inline int64_t get_product_count(int64_t root_cat, int k_shop, int64_t cursor) { inline int64_t get_product_count(int64_t root_cat, int k_shop, int64_t cursor) {
return get_pool().execute_scalar(PRODUCT_COUNT_SQL, return get_pool().execute_scalar(PRODUCT_COUNT_SQL,
{{ParamType::BigInt,"",root_cat},{ParamType::BigInt,"",k_shop}, {{ParamType::BigInt,"",root_cat},{ParamType::Int,"",k_shop},
{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",cursor}, {ParamType::Int,"",k_shop},{ParamType::BigInt,"",cursor},
{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",cursor}}); {ParamType::Int,"",k_shop},{ParamType::BigInt,"",cursor}});
} }
inline int64_t get_composite_count(int k_shop, int64_t cursor) { inline int64_t get_composite_count(int k_shop, int64_t cursor) {
return get_pool().execute_scalar(COMPOSITE_PRODUCT_COUNT_SQL, return get_pool().execute_scalar(COMPOSITE_PRODUCT_COUNT_SQL,
{{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",k_shop}, {{ParamType::Int,"",k_shop},{ParamType::Int,"",k_shop},
{ParamType::BigInt,"",cursor}}); {ParamType::BigInt,"",cursor}});
} }
inline int64_t get_deleted_count(int64_t cursor) { inline int64_t get_deleted_count(int64_t cursor) {
return get_pool().execute_scalar(DELETED_ENTITY_COUNT_SQL, return get_pool().execute_scalar(DELETED_ENTITY_COUNT_SQL,
{{ParamType::BigInt,"",cursor}}); {{ParamType::BigInt,"",cursor}});
} }
inline int64_t get_max_order_id_count(int k_shop_subshop) {
if (k_shop_subshop <= 0) return 0;
return get_pool().execute_scalar(MAX_ORDER_ID_SQL,
{{ParamType::Int,"",k_shop_subshop}});
}

View File

@@ -0,0 +1,966 @@
#pragma once
#include <algorithm>
#include <cctype>
#include <cmath>
#include <ctime>
#include <chrono>
#include <iomanip>
#include <map>
#include <optional>
#include <memory>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
#include "delivery.hpp"
#include "shop.hpp"
#include "../db/pool.hpp"
#include "../config.hpp"
#include "nlohmann/json.hpp"
namespace order {
struct Defaults;
namespace {
struct Config {
int kBenutzer = 1;
int kFirmaHistory = 0;
int kSprache = 1;
int kPlattform = 7;
int kVersandArt = 0;
int kKundengruppe = 0;
int orderNumberSequence = 3;
int customerNumberSequence = 6;
};
constexpr int VERSANDPOSITION_TYPE = 2;
constexpr int ZAHLUNG_TYPE_ZAHLUNG = 10;
constexpr int NIST_READONLY_NICHT_AENDERBAR = 2;
constexpr int NIST_EXTERNE_RECHNUNG_KEINE = 2;
Config g_config;
std::unique_ptr<Defaults> g_resolved_defaults;
std::map<std::string, nlohmann::json> g_zahlungsart_cache;
} // namespace
struct Defaults {
int kFirmaHistory = 1;
int kVersandArt = 1;
int kKundengruppe = 1;
int kPlattform = 1;
};
inline void load_config() {
g_config.kBenutzer = config::get_int("JTL_KBENUTZER", 1);
g_config.kFirmaHistory = config::get_int("JTL_KFIRMAHISTORY", 0);
g_config.kSprache = config::get_int("JTL_KSPRACHE", 1);
g_config.kPlattform = config::get_int("JTL_KPLATTFORM", 7);
g_config.kVersandArt = config::get_int("JTL_KVERSANDART", 0);
g_config.kKundengruppe = config::get_int("JTL_KKUNDENGRUPPE", 0);
g_config.orderNumberSequence = config::get_int("JTL_ORDER_NUMBER_SEQUENCE", 3);
g_config.customerNumberSequence = config::get_int("JTL_CUSTOMER_NUMBER_SEQUENCE", 6);
}
inline const Defaults& get_defaults(OdbcPool::Connection* c) {
if (g_resolved_defaults) return *g_resolved_defaults;
Defaults d;
d.kFirmaHistory = g_config.kFirmaHistory;
d.kVersandArt = g_config.kVersandArt;
d.kKundengruppe = g_config.kKundengruppe;
d.kPlattform = g_config.kPlattform;
const char* sql =
"SELECT"
" (SELECT MAX(kFirmaHistory) FROM dbo.tFirmaHistory) AS kFirmaHistory,"
" (SELECT MIN(kVersandArt) FROM dbo.tVersandArt) AS kVersandArt,"
" (SELECT TOP 1 kKundenGruppe FROM dbo.tKundenGruppe ORDER BY nStandard DESC, kKundenGruppe) AS kKundengruppe,"
" (SELECT CASE WHEN EXISTS (SELECT 1 FROM dbo.tPlattform WHERE nPlattform = ?) THEN ? ELSE 1 END) AS kPlattform";
std::vector<Param> ps = {
{ParamType::Int, "", g_config.kPlattform},
{ParamType::Int, "", g_config.kPlattform}
};
ResultSet rs;
if (get_pool().execute(c, sql, ps, rs) && !rs.empty() && !rs[0].empty()) {
if (!rs[0][0].str.empty()) d.kFirmaHistory = std::stoi(rs[0][0].str);
if (!rs[0][1].str.empty()) d.kVersandArt = std::stoi(rs[0][1].str);
if (!rs[0][2].str.empty()) d.kKundengruppe = std::stoi(rs[0][2].str);
if (!rs[0][3].str.empty()) d.kPlattform = std::stoi(rs[0][3].str);
}
// Apply environment overrides
if (g_config.kFirmaHistory > 0) d.kFirmaHistory = g_config.kFirmaHistory;
if (g_config.kVersandArt > 0) d.kVersandArt = g_config.kVersandArt;
if (g_config.kKundengruppe > 0) d.kKundengruppe = g_config.kKundengruppe;
g_resolved_defaults = std::make_unique<Defaults>(d);
return *g_resolved_defaults;
}
inline double to_number(const nlohmann::json& value, double fallback = 0) {
if (value.is_null()) return fallback;
if (value.is_number()) return value.get<double>();
if (value.is_string()) {
std::string s = value.get<std::string>();
if (s.empty()) return fallback;
std::replace(s.begin(), s.end(), ',', '.');
try { return std::stod(s); } catch (...) {}
}
return fallback;
}
inline int to_int(const nlohmann::json& value, int fallback = 0) {
if (value.is_null()) return fallback;
if (value.is_number_integer()) return value.get<int>();
if (value.is_number_unsigned()) return static_cast<int>(value.get<unsigned>());
if (value.is_number_float()) return static_cast<int>(value.get<double>());
if (value.is_boolean()) return value.get<bool>() ? 1 : 0;
if (value.is_string()) {
std::string s = value.get<std::string>();
if (s.empty()) return fallback;
try { return std::stoi(s); } catch (...) {}
}
return fallback;
}
inline int json_int(const nlohmann::json& obj, const char* key, int fallback = 0) {
auto it = obj.find(key);
if (it == obj.end()) return fallback;
return to_int(*it, fallback);
}
inline double json_double(const nlohmann::json& obj, const char* key, double fallback = 0) {
auto it = obj.find(key);
if (it == obj.end()) return fallback;
return to_number(*it, fallback);
}
inline int steuerklasse_for_vat(double vat) {
if (vat >= 15) return 1;
if (vat > 0) return 2;
return 1;
}
inline int iso_week(const std::tm& tm) {
// JTL placeholders: J = Jahr, M = Monat, T = Tag, K = Kalenderwoche.
// ISO week date
std::tm t0 = tm;
int yday = t0.tm_yday;
int wday = t0.tm_wday;
if (wday == 0) wday = 7;
int week = (yday - wday + 10) / 7;
if (week < 1) week = 1;
if (week > 53) week = 53;
return week;
}
inline std::string format_number_placeholders(const std::string& tpl, const std::tm& tm) {
if (tpl.empty()) return "";
std::string r = tpl;
auto replace = [&r](const std::string& from, const std::string& to) {
size_t start = 0;
while ((start = r.find(from, start)) != std::string::npos) {
r.replace(start, from.size(), to);
start += to.size();
}
};
std::ostringstream mm, dd, kk;
mm << std::setw(2) << std::setfill('0') << (tm.tm_mon + 1);
dd << std::setw(2) << std::setfill('0') << tm.tm_mday;
kk << std::setw(2) << std::setfill('0') << iso_week(tm);
replace("<J>", std::to_string(tm.tm_year + 1900));
replace("<M>", mm.str());
replace("<T>", dd.str());
replace("<K>", kk.str());
return r;
}
inline std::tm parse_order_date(const std::string& s) {
std::tm tm{};
std::string iso = s;
std::replace(iso.begin(), iso.end(), ' ', 'T');
std::istringstream ss(iso);
ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
if (ss.fail()) {
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
localtime_r(&t, &tm);
}
return tm;
}
inline std::string order_date_sql(const std::tm& tm) {
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
return buf;
}
inline std::string country_name(const std::string& iso) {
static const std::map<std::string, std::string> names = {
{"DE", "Deutschland"},
{"AT", "Oesterreich"},
{"CH", "Schweiz"}
};
auto it = names.find(iso);
return it != names.end() ? it->second : iso;
}
inline std::string next_number_from_sequence(OdbcPool::Connection* c, int kLaufendeNummer,
const std::tm& date) {
const char* sql =
"DECLARE @n INT, @cPrefix NVARCHAR(50), @cSuffix NVARCHAR(50);"
"UPDATE dbo.tLaufendeNummern"
" SET @n = nNummer = nNummer + 1, @cPrefix = cPrefix, @cSuffix = cSuffix"
" WHERE kLaufendeNummer = ?;"
"SELECT @n AS nNummer, @cPrefix AS cPrefix, @cSuffix AS cSuffix";
std::vector<Param> ps = {{ParamType::Int, "", kLaufendeNummer}};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].size() < 3) {
throw std::runtime_error("dbo.tLaufendeNummern has no row " + std::to_string(kLaufendeNummer));
}
int n = std::stoi(rs[0][0].str);
std::string prefix = format_number_placeholders(rs[0][1].str, date);
std::string suffix = format_number_placeholders(rs[0][2].str, date);
return prefix + std::to_string(n) + suffix;
}
inline int allocate_pk(OdbcPool::Connection* c, const std::string& tableName) {
const char* sql =
"DECLARE @pk INT;"
"UPDATE dbo.tpk SET @pk = nummer, nummer = nummer + 1, dChanged = GETDATE() WHERE cName = ?;"
"SELECT @pk AS pk";
std::vector<Param> ps = {{ParamType::NVarChar, tableName, 0}};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) {
throw std::runtime_error("dbo.tpk has no row for table '" + tableName + "'");
}
return std::stoi(rs[0][0].str);
}
inline int parse_import_setting(const nlohmann::json& order) {
if (!order.contains("settings") || order["settings"].is_null()) return 0;
return json_int(order["settings"], "importSetting", 0);
}
inline int parse_invoice_setting(const nlohmann::json& order) {
if (!order.contains("settings") || order["settings"].is_null()) return 0;
return json_int(order["settings"], "invoiceSetting", 0);
}
inline int resolve_n_ist_readonly(const nlohmann::json& order) {
return parse_import_setting(order) == 0 ? NIST_READONLY_NICHT_AENDERBAR : 0;
}
inline int resolve_n_ist_externe_rechnung(const nlohmann::json& order) {
const int import_setting = parse_import_setting(order);
const int invoice_setting = parse_invoice_setting(order);
if (import_setting >= 2 && import_setting <= 5) return 0;
if (invoice_setting & 1) return 0;
if (import_setting == 0) return NIST_EXTERNE_RECHNUNG_KEINE;
return 0;
}
struct VersandArtRow {
int kVersandArt = 0;
std::string cName;
double fPrice = 0;
double fMwSt = 19;
};
inline bool is_versandposition(const nlohmann::json& item) {
return json_int(item, "type", 0) == VERSANDPOSITION_TYPE;
}
inline bool has_versandposition(const nlohmann::json& items) {
if (!items.is_array()) return false;
for (const auto& item : items) {
if (is_versandposition(item)) return true;
}
return false;
}
inline bool has_non_return_sale_items(const nlohmann::json& items) {
if (!items.is_array()) return false;
for (const auto& item : items) {
if (json_double(item, "isReturn", 0) == 0) return true;
}
return false;
}
inline bool should_inject_selbstabholer_shipping(const nlohmann::json& items) {
if (has_versandposition(items)) return false;
return has_non_return_sale_items(items);
}
inline std::optional<VersandArtRow> lookup_versand_art(OdbcPool::Connection* c,
const std::string& shipping_name) {
auto try_lookup = [&](const std::string& cName) -> std::optional<VersandArtRow> {
std::vector<Param> ps = {{ParamType::NVarChar, cName, 0}};
ResultSet rs;
if (!get_pool().execute(c,
"SELECT TOP 1 kVersandArt, cName, fPrice, fMwSt "
"FROM dbo.tVersandArt WHERE cName = ?", ps, rs)
|| rs.empty() || rs[0].empty()) {
return std::nullopt;
}
VersandArtRow row;
row.kVersandArt = std::stoi(rs[0][0].str);
row.cName = rs[0][1].str;
row.fPrice = rs[0].size() > 2 ? to_number(nlohmann::json(rs[0][2].str), 0) : 0;
row.fMwSt = rs[0].size() > 3 ? to_number(nlohmann::json(rs[0][3].str), 19) : 19;
return row;
};
std::string name = shipping_name;
if (name.empty()) name = "Selbstabholer";
if (auto row = try_lookup(name)) return row;
if (name != "Selbstabholer") return try_lookup("Selbstabholer");
return std::nullopt;
}
inline nlohmann::json synthetic_shipping_item(const VersandArtRow& versand_art) {
const double vat = versand_art.fMwSt;
const double gross = versand_art.fPrice;
const double net = gross / (1 + vat / 100);
return {
{"type", std::to_string(VERSANDPOSITION_TYPE)},
{"quantity", "1"},
{"name", versand_art.cName},
{"priceGross", std::to_string(gross)},
{"priceNet", std::to_string(net)},
{"vat", std::to_string(vat)},
{"isReturn", "0"}
};
}
inline nlohmann::json resolve_zahlungsart(OdbcPool::Connection* c, const std::string& name) {
const std::string lookup_name = name.empty() ? "Bar" : name;
const std::string cache_key = [&]() {
std::string key = lookup_name;
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
return key;
}();
auto it = g_zahlungsart_cache.find(cache_key);
if (it != g_zahlungsart_cache.end()) return it->second;
auto fetch_row = [&](const char* sql) -> std::optional<std::pair<int, std::string>> {
std::vector<Param> ps = {{ParamType::NVarChar, lookup_name, 0}};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) {
return std::nullopt;
}
return std::make_pair(std::stoi(rs[0][0].str), rs[0][1].str);
};
std::optional<std::pair<int, std::string>> row =
fetch_row("SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE cName = ?");
if (!row) {
row = fetch_row(
"SELECT TOP 1 z.kZahlungsart, z.cName "
"FROM dbo.tZahlungsArtSprache zs "
"INNER JOIN dbo.tZahlungsart z ON z.kZahlungsart = zs.kZahlungsart "
"WHERE zs.cName = ?");
}
if (!row) {
row = fetch_row(
"SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE UPPER(cName) = UPPER(?)");
}
nlohmann::json zahlungsart;
if (row) {
zahlungsart = {{"kZahlungsart", row->first}, {"cName", row->second}};
} else {
int kZahlungsart = allocate_pk(c, "tZahlungsart");
const char* insert =
"INSERT INTO dbo.tZahlungsart"
" (kZahlungsart, cName, cPrtString, nLastschrift, cPrtStringVor, cPaymentOption, cKonto,"
" nAusliefernVorZahlung, nPrioritaet, nMahnwesenAktiv, fSkontoWert, nSkontoZeitraum,"
" nMatchingOptionen, nIstStandard, nAktiv)"
" VALUES (?, ?, '', 0, '', '', '', 0, 0, 0, 0, 0, 0, 0, 1)";
std::vector<Param> ps2 = {
{ParamType::Int, "", kZahlungsart},
{ParamType::NVarChar, lookup_name, 0}
};
ResultSet rs;
get_pool().execute(c, insert, ps2, rs);
zahlungsart = {{"kZahlungsart", kZahlungsart}, {"cName", lookup_name}};
}
g_zahlungsart_cache[cache_key] = zahlungsart;
return zahlungsart;
}
inline std::string next_customer_number(OdbcPool::Connection* c) {
std::tm date;
{
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
localtime_r(&t, &date);
}
return next_number_from_sequence(c, g_config.customerNumberSequence, date);
}
inline std::pair<int,int> lookup_kassenkunde(OdbcPool::Connection* c, const Defaults& defaults) {
ResultSet rs;
get_pool().execute(c,
"SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKassenKunde = 'Y' ORDER BY kKunde", rs);
if (!rs.empty() && !rs[0].empty()) {
int kKunde = std::stoi(rs[0][0].str);
int grp = defaults.kKundengruppe;
if (rs[0].size() > 1 && !rs[0][1].str.empty()) grp = std::stoi(rs[0][1].str);
return {kKunde, grp};
}
return {0, defaults.kKundengruppe};
}
inline bool is_walk_in_order(const nlohmann::json& order) {
std::string customer_number = order.value("customerNumber", "");
if (customer_number.empty() || customer_number == "0") return true;
const auto billing = order.value("billingAddress", nlohmann::json::object());
return billing.value("lastName", "") == "Laufkunde"
&& 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", "");
}
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;
std::string iso = a.value("countryIso", "DE");
std::transform(iso.begin(), iso.end(), iso.begin(), [](unsigned char ch) { return std::toupper(ch); });
int kKundengruppe = json_int(a, "customerGroupId", 0);
if (kKundengruppe <= 0) kKundengruppe = defaults.kKundengruppe;
const char* sql =
"DECLARE @returnValue INT;"
"DECLARE @kunde_daten dbo.TYPE_spkundeInsert;"
"INSERT INTO @kunde_daten"
" (kInetKunde, kKundenKategorie, cKundenNr, cFirma, cAnrede, cTitel, cVorname, cName,"
" cStrasse, cPLZ, cOrt, cLand, cTel, cFax, cEMail, dErstellt, cMobil, fRabatt, cUSTID, cNewsletter,"
" cZusatz, cEbayName, kBuyer, cAdressZusatz, cGeburtstag, cWWW, cSperre, cPostID, kKundenGruppe,"
" nZahlungsziel, kSprache, cISO, cBundesland, cHerkunft, cKassenKunde, cHRNr, kZahlungsart,"
" nDebitorennr, cSteuerNr, nKreditlimit, kKundenDrucktext, nMahnstopp, nMahnrhythmus, kFirma,"
" fProvision, nVertreter, fSkonto, nSkontoInTagen)"
" VALUES"
" (0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, GETDATE(), ?, ?, NULL, 'N',"
" N'', N'', 0, ?, ?, N'', 'N', NULL, ?,"
" 0, ?, ?, ?, ?, ?, N'', 0,"
" ?, N'', 0, 0, 0, 0, 0,"
" NULL, 0, 0, 0);"
"EXEC @returnValue = Kunde.spKundeInsert @daten = @kunde_daten;"
"SELECT @returnValue AS kKunde";
std::vector<Param> ps = {
{ParamType::NVarChar, customer_number, 0},
{ParamType::NVarChar, a.value("company", ""), 0},
{ParamType::NVarChar, a.value("salutation", ""), 0},
{ParamType::NVarChar, a.value("title", ""), 0},
{ParamType::NVarChar, a.value("firstName", ""), 0},
{ParamType::NVarChar, a.value("lastName", "Laufkunde"), 0},
{ParamType::NVarChar, a.value("street", "-"), 0},
{ParamType::NVarChar, a.value("zipCode", ""), 0},
{ParamType::NVarChar, a.value("city", "-"), 0},
{ParamType::NVarChar, country_name(iso), 0},
{ParamType::NVarChar, a.value("phone", ""), 0},
{ParamType::NVarChar, a.value("fax", ""), 0},
{ParamType::NVarChar, a.value("email", ""), 0},
{ParamType::NVarChar, a.value("mobile", ""), 0},
{ParamType::Double, "", 0, json_double(a, "discount", 0)},
{ParamType::NVarChar, a.value("addressAddition", ""), 0},
{ParamType::NVarChar, a.value("birthday", ""), 0},
{ParamType::Int, "", kKundengruppe},
{ParamType::Int, "", g_config.kSprache},
{ParamType::NVarChar, iso, 0},
{ParamType::NVarChar, a.value("state", ""), 0},
{ParamType::NVarChar, std::string("Kasse"), 0},
{ParamType::NVarChar, std::string("Y"), 0},
{ParamType::Int, "", json_int(a, "debtorNumber", 0)}
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) {
throw std::runtime_error("Kunde.spKundeInsert failed for '" + customer_number + "'");
}
int kKunde = std::stoi(rs[0][0].str);
if (kKunde <= 0) throw std::runtime_error("Kunde.spKundeInsert returned invalid kKunde");
return {kKunde, kKundengruppe};
}
inline std::pair<int,int> resolve_customer(OdbcPool::Connection* c, const nlohmann::json& order,
const Defaults& defaults) {
if (is_walk_in_order(order)) {
auto [kKunde, grp] = lookup_kassenkunde(c, defaults);
if (kKunde > 0) return {kKunde, grp};
return create_customer(c, next_customer_number(c),
order.value("billingAddress", nlohmann::json::object()), defaults);
}
std::string customer_number = order.value("customerNumber", "");
std::vector<Param> ps = {{ParamType::NVarChar, customer_number, 0}};
ResultSet rs;
get_pool().execute(c, "SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKundenNr = ?", ps, rs);
if (!rs.empty() && !rs[0].empty()) {
int kKunde = std::stoi(rs[0][0].str);
int grp = defaults.kKundengruppe;
if (rs[0].size() > 1 && !rs[0][1].str.empty()) grp = std::stoi(rs[0][1].str);
return {kKunde, grp};
}
return create_customer(c, customer_number, order.value("billingAddress", nlohmann::json::object()), defaults);
}
inline std::string next_order_number(OdbcPool::Connection* c, const std::tm& date) {
return next_number_from_sequence(c, g_config.orderNumberSequence, date);
}
inline void insert_order_address(OdbcPool::Connection* c, int kAuftrag, int kKunde,
const nlohmann::json& address, int nTyp) {
const nlohmann::json a = address.is_null() ? nlohmann::json::object() : address;
std::string iso = a.value("countryIso", "DE");
std::transform(iso.begin(), iso.end(), iso.begin(), [](unsigned char ch) { return std::toupper(ch); });
const char* sql =
"INSERT INTO Verkauf.tAuftragAdresse"
" (kAuftrag, kKunde, cFirma, cAnrede, cTitel, cVorname, cName, cStrasse, cPLZ, cOrt, cLand,"
" cTel, cZusatz, cAdressZusatz, cMobil, cMail, cFax, cBundesland, cISO, nTyp, nZolldokumenteErforderlich)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)";
std::vector<Param> ps = {
{ParamType::Int, "", kAuftrag},
{ParamType::Int, "", kKunde},
{ParamType::NVarChar, a.value("company", ""), 0},
{ParamType::NVarChar, a.value("salutation", ""), 0},
{ParamType::NVarChar, a.value("title", ""), 0},
{ParamType::NVarChar, a.value("firstName", ""), 0},
{ParamType::NVarChar, a.value("lastName", "-"), 0},
{ParamType::NVarChar, a.value("street", "-"), 0},
{ParamType::NVarChar, a.value("zipCode", ""), 0},
{ParamType::NVarChar, a.value("city", "-"), 0},
{ParamType::NVarChar, country_name(iso), 0},
{ParamType::NVarChar, a.value("phone", ""), 0},
{ParamType::NVarChar, a.value("extraAddressLine", ""), 0},
{ParamType::NVarChar, a.value("addressAddition", ""), 0},
{ParamType::NVarChar, a.value("mobile", ""), 0},
{ParamType::NVarChar, a.value("email", ""), 0},
{ParamType::NVarChar, a.value("fax", ""), 0},
{ParamType::NVarChar, a.value("state", ""), 0},
{ParamType::NVarChar, iso, 0},
{ParamType::Int, "", nTyp}
};
ResultSet rs;
get_pool().execute(c, sql, ps, rs);
}
inline int insert_order_item(OdbcPool::Connection* c, int kAuftrag, const nlohmann::json& item) {
double vat = json_double(item, "vat", 19);
double quantity = json_double(item, "quantity", 1);
double price_gross = json_double(item, "priceGross", 0);
double price_net = json_double(item, "priceNet", price_gross / (1 + vat / 100));
double discount = json_double(item, "discountPercent", 0);
int kSteuerklasse = steuerklasse_for_vat(vat);
std::string sku = item.value("sku", "");
const int position_type = is_versandposition(item) ? VERSANDPOSITION_TYPE : json_int(item, "type", 0);
int kArtikel = 0;
bool has_artikel = false;
if (!sku.empty() && position_type != VERSANDPOSITION_TYPE) {
std::vector<Param> ps = {{ParamType::NVarChar, sku, 0}};
ResultSet rs;
get_pool().execute(c, "SELECT TOP 1 kArtikel FROM dbo.tArtikel WHERE cArtNr = ?", ps, rs);
if (!rs.empty() && !rs[0].empty()) {
kArtikel = std::stoi(rs[0][0].str);
has_artikel = true;
}
}
const int nType = position_type == VERSANDPOSITION_TYPE ? VERSANDPOSITION_TYPE : (has_artikel ? 1 : 0);
const int nReserviert = nType == VERSANDPOSITION_TYPE ? 0 : 1;
const char* sql =
"DECLARE @t TABLE ([kAuftragPosition] INT);"
"INSERT INTO Verkauf.tAuftragPosition"
" (kArtikel, kAuftrag, cArtNr, nReserviert, cName, cHinweis, fAnzahl, fVkNetto, fMwSt,"
" cNameStandard, kSteuerklasse, nType, cEinheit, fFaktor, kSteuerschluessel, fRabatt)"
" OUTPUT inserted.kAuftragPosition INTO @t"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,"
" ?, ?, ?, ?, 1.0, 3, ?);"
"SELECT kAuftragPosition FROM @t";
Param cartnr{ParamType::NVarChar, has_artikel ? sku : std::string(), 0, 0.0, !has_artikel};
Param pk_art{ParamType::Int, "", kArtikel, 0.0, !has_artikel};
std::string name = item.value("name", sku.empty() ? std::string("Position") : sku);
std::vector<Param> ps = {
pk_art,
{ParamType::Int, "", kAuftrag},
cartnr,
{ParamType::Int, "", nReserviert},
{ParamType::NVarChar, name, 0},
{ParamType::NVarChar, item.value("note", ""), 0},
{ParamType::Double, "", 0, quantity},
{ParamType::Double, "", 0, price_net},
{ParamType::Double, "", 0, vat},
{ParamType::NVarChar, name, 0},
{ParamType::Int, "", kSteuerklasse},
{ParamType::Int, "", nType},
{ParamType::NVarChar, item.value("unit", ""), 0},
{ParamType::Double, "", 0, discount}
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) return 0;
return std::stoi(rs[0][0].str);
}
inline int parse_pos_auftrag_id(const std::string& external_id) {
if (external_id.empty()) 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.
inline std::optional<std::pair<int, std::string>> find_existing_pos_order(
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, 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";
ResultSet rs;
std::vector<Param> ps = {
{ParamType::Int, "", kPosAuftrag},
{ParamType::Int, "", kShopSubShop},
};
if (!get_pool().execute(c, sql, ps, rs)
|| rs.empty() || rs[0].empty() || rs[0][0].type == CellType::Null) {
return std::nullopt;
}
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);
}
inline void upsert_pos_order_mapping(OdbcPool::Connection* c, int kAuftrag, int kPosAuftrag) {
int kShopSubShop = get_active_shop_subshop_id();
if (kPosAuftrag <= 0 || kShopSubShop <= 0) return;
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> ins_ps = {
{ParamType::Int, "", kAuftrag},
{ParamType::Int, "", kPosAuftrag},
{ParamType::Int, "", kShopSubShop},
};
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,
const std::string& external_id) {
int kPosAuftragPosition = 0;
try { kPosAuftragPosition = std::stoi(external_id); } catch (...) { return; }
int kShopSubShop = get_active_shop_subshop_id();
if (kAuftragPosition <= 0 || kPosAuftragPosition <= 0 || kShopSubShop <= 0) return;
const char* sql = "INSERT INTO Pos.tAuftragPositionMapping (kAuftragPosition, kPosAuftragPosition, kShopSubShop) VALUES (?, ?, ?)";
std::vector<Param> ps = {
{ParamType::Int, "", kAuftragPosition},
{ParamType::Int, "", kPosAuftragPosition},
{ParamType::Int, "", kShopSubShop}
};
ResultSet rs;
get_pool().execute(c, sql, ps, rs);
}
inline bool is_order_delivered(const nlohmann::json& order) {
auto it = order.find("settings");
if (it != order.end() && !it->is_null()) {
auto del = it->find("deliver");
if (del != it->end()) {
if (del->is_boolean()) return del->get<bool>();
if (del->is_number()) return del->get<int>() != 0;
if (del->is_string()) {
std::string s = *del;
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char ch) { return std::tolower(ch); });
return s == "true" || s == "1";
}
return false;
}
}
return true;
}
// ODBC maps ? placeholders to @p1, @p2, … — never DECLARE @p1 in the same batch.
inline void recalculate_auftrag_eckdaten(OdbcPool::Connection* c, int kAuftrag) {
const char* sql =
"DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;"
"INSERT INTO @eckdaten_calc VALUES (?);"
"EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc";
std::vector<Param> ps = {{ParamType::Int, "", kAuftrag}};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs)) {
throw std::runtime_error("spAuftragEckdatenBerechnen failed for kAuftrag="
+ std::to_string(kAuftrag));
}
}
inline std::optional<double> get_offener_auftragswert(OdbcPool::Connection* c, int kAuftrag) {
const char* sql =
"SELECT ROUND(tAuftragEckdaten.fOffenerWertOhneStorno, 2) AS fOffenerAuftragswert "
"FROM Verkauf.tAuftrag "
"LEFT JOIN Verkauf.tAuftragEckdaten ON tAuftragEckdaten.kAuftrag = tAuftrag.kAuftrag "
"WHERE tAuftrag.kAuftrag = ?";
std::vector<Param> ps = {{ParamType::Int, "", kAuftrag}};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()
|| rs[0][0].type == CellType::Null) {
return std::nullopt;
}
return to_number(nlohmann::json(rs[0][0].str), 0);
}
inline bool is_new_payment(const nlohmann::json& payment) {
return json_int(payment, "paymentId", 0) <= 0;
}
inline void insert_payment(OdbcPool::Connection* c, int kAuftrag, const nlohmann::json& payment,
const nlohmann::json& order, const std::tm& order_date_tm) {
std::string payment_name = payment.value("paymentMethodName", "");
if (payment_name.empty()) payment_name = order.value("paymentMethodName", "Bar");
nlohmann::json zahlungsart = resolve_zahlungsart(c, payment_name);
int kZahlung = allocate_pk(c, "tZahlung");
recalculate_auftrag_eckdaten(c, kAuftrag);
auto fOffenerWert = get_offener_auftragswert(c, kAuftrag);
if (!fOffenerWert) {
throw std::runtime_error("no open order amount for kAuftrag=" + std::to_string(kAuftrag));
}
const char* sql =
"IF EXISTS ("
" SELECT 1 FROM Verkauf.tAuftragEckdaten"
" WHERE kAuftrag = ?"
" AND ROUND(fOffenerWertOhneStorno, 2) = ROUND(?, 2)"
")"
"BEGIN"
" INSERT INTO dbo.tZahlung"
" (kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart,"
" nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung)"
" VALUES (?, ?, ?, ?, ?, ?, 0, '', ?,"
" 0, ?, 0, ?, '', 0);"
"END;"
"SELECT @@ROWCOUNT AS inserted";
std::vector<Param> ps = {
{ParamType::Int, "", kAuftrag},
{ParamType::Double, "", 0, *fOffenerWert},
{ParamType::Int, "", kZahlung},
{ParamType::NVarChar, zahlungsart.value("cName", ""), 0},
{ParamType::NVarChar, order_date_sql(order_date_tm), 0},
{ParamType::Double, "", 0, json_double(payment, "amount", 0)},
{ParamType::Int, "", kAuftrag},
{ParamType::Int, "", g_config.kBenutzer},
{ParamType::Int, "", json_int(zahlungsart, "kZahlungsart", 0)},
{ParamType::NVarChar, order.value("externalOrderNumber", ""), 0},
{ParamType::Int, "", ZAHLUNG_TYPE_ZAHLUNG},
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()
|| std::stoi(rs[0][0].str) == 0) {
throw std::runtime_error("payment insert skipped: open amount changed for kAuftrag="
+ std::to_string(kAuftrag));
}
}
inline nlohmann::json create_order(const nlohmann::json& order) {
load_config();
auto guard = get_pool().checkout();
if (!guard) throw std::runtime_error("no ODBC connection available");
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, external_order_number)) {
return {
{"orderId", std::to_string(existing->first)},
{"orderNumber", existing->second},
{"alreadyExists", true}
};
}
}
const Defaults& defaults = get_defaults(c);
if (!get_pool().begin(c)) throw std::runtime_error("failed to begin transaction");
try {
g_zahlungsart_cache.clear();
std::string creation_date = order.value("creationDate", "");
std::tm order_date_tm = creation_date.empty()
? []() {
std::tm tm{};
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
localtime_r(&t, &tm);
return tm;
}()
: parse_order_date(creation_date);
auto [kKunde, kKundengruppe] = resolve_customer(c, order, defaults);
nlohmann::json zahlungsart = resolve_zahlungsart(c, order.value("paymentMethodName", "Bar"));
std::string cAuftragsNr = next_order_number(c, order_date_tm);
nlohmann::json order_items = order.contains("orderItems") ? order["orderItems"] : nlohmann::json::array();
if (!order_items.is_array()) order_items = nlohmann::json::array();
auto versand_art = lookup_versand_art(c, order.value("shippingName", ""));
int kVersandArt = versand_art ? versand_art->kVersandArt : defaults.kVersandArt;
if (should_inject_selbstabholer_shipping(order_items) && versand_art) {
order_items.push_back(synthetic_shipping_item(*versand_art));
}
int active_shop = get_active_shop_id();
const int nIstReadOnly = resolve_n_ist_readonly(order);
const int nIstExterneRechnung = resolve_n_ist_externe_rechnung(order);
const char* insert_auftrag =
"DECLARE @t TABLE ([kAuftrag] INT);"
"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, 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");
std::transform(shipping_iso.begin(), shipping_iso.end(), shipping_iso.begin(),
[](unsigned char ch) { return std::toupper(ch); });
std::string currency_iso = order.value("currencyIso", "EUR");
std::string cKundenNr = resolve_auftrag_c_kunden_nr(order);
std::vector<Param> ps = {
{ParamType::NVarChar, cAuftragsNr, 0},
{ParamType::NVarChar, order_date_sql(order_date_tm), 0},
{ParamType::Int, "", g_config.kBenutzer},
{ParamType::Int, "", kKunde},
{ParamType::Int, "", g_config.kBenutzer},
{ParamType::Int, "", defaults.kFirmaHistory},
{ParamType::Int, "", g_config.kSprache},
{ParamType::NVarChar, currency_iso, 0},
{ParamType::NVarChar, currency_iso, 0},
{ParamType::Int, "", defaults.kPlattform},
{ParamType::Int, "", active_shop == 0 ? -1 : active_shop},
{ParamType::NVarChar, cKundenNr, 0},
{ParamType::NVarChar, shipping_iso, 0},
{ParamType::Int, "", kVersandArt},
{ParamType::Int, "", json_int(zahlungsart, "kZahlungsart", 0)},
{ParamType::Int, "", kKundengruppe},
{ParamType::NVarChar, order.value("externalOrderNumber", ""), 0},
{ParamType::Int, "", nIstExterneRechnung},
{ParamType::NVarChar, "Y", 0},
{ParamType::Int, "", nIstReadOnly},
};
if (active_shop == 0) {
ps[11] = Param::null_int();
}
ResultSet rs;
if (!get_pool().execute(c, insert_auftrag, ps, rs) || rs.empty() || rs[0].empty()) {
throw std::runtime_error("failed to insert Verkauf.tAuftrag");
}
int kAuftrag = std::stoi(rs[0][0].str);
upsert_pos_order_mapping(c, kAuftrag, kPosAuftrag);
insert_order_address(c, kAuftrag, kKunde, order.value("shippingAddress", nlohmann::json::object()), 0);
insert_order_address(c, kAuftrag, kKunde, order.value("billingAddress", nlohmann::json::object()), 1);
std::vector<delivery::DeliveredItem> delivered_items;
for (const auto& item : order_items) {
int kAuftragPosition = insert_order_item(c, kAuftrag, item);
const std::string external_id = item.value("externalId", "");
if (!external_id.empty()) {
insert_pos_order_position_mapping(c, kAuftragPosition, external_id);
}
if (kAuftragPosition > 0 && !is_versandposition(item)) {
delivered_items.push_back({kAuftragPosition, json_double(item, "quantity", 1)});
}
}
const nlohmann::json& payments = order.contains("payments") ? order["payments"] : nlohmann::json::array();
for (const auto& payment : payments) {
if (!is_new_payment(payment)) continue;
insert_payment(c, kAuftrag, payment, order, order_date_tm);
}
if (is_order_delivered(order)) {
delivery::deliver_order(c, g_config.kBenutzer, kAuftrag, kVersandArt, delivered_items);
}
const char* calc =
"DECLARE @auftrag_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;"
"INSERT INTO @auftrag_calc VALUES (?);"
"EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @auftrag_calc";
std::vector<Param> ps_calc = {{ParamType::Int, "", kAuftrag}};
get_pool().execute(c, calc, ps_calc, rs);
if (!get_pool().commit(c)) throw std::runtime_error("failed to commit transaction");
return {
{"orderId", std::to_string(kAuftrag)},
{"orderNumber", cAuftragsNr},
{"alreadyExists", false}
};
} catch (const std::exception&) {
try { get_pool().rollback(c); } catch (...) {}
throw;
}
}
} // namespace order

View File

@@ -1,5 +1,7 @@
#pragma once #pragma once
#include "../db/pool.hpp" #include "../db/pool.hpp"
#include "../log.hpp"
#include "../http.hpp"
#include "nlohmann/json.hpp" #include "nlohmann/json.hpp"
static const char* CUSTOMER_GROUP_IDS_SQL = static const char* CUSTOMER_GROUP_IDS_SQL =
@@ -16,13 +18,20 @@ static const char* CUSTOMER_GROUP_COUNT_SQL =
"WHERE CONVERT(BIGINT, bRowversion) > ?"; "WHERE CONVERT(BIGINT, bRowversion) > ?";
inline std::vector<int64_t> get_customer_group_ids() { inline std::vector<int64_t> get_customer_group_ids() {
static std::vector<int64_t> cached;
static bool loaded = false;
if (loaded) return cached;
ResultSet rs; ResultSet rs;
get_pool().execute(CUSTOMER_GROUP_IDS_SQL, rs); if (!get_pool().execute(CUSTOMER_GROUP_IDS_SQL, rs)) {
std::vector<int64_t> ids; logc::warn("failed to load customer group ids");
for (auto& row : rs) { return {};
ids.push_back(std::stoll(row[0].str));
} }
return ids; for (auto& row : rs) {
cached.push_back(parse_int64(row[0].str, 0));
}
loaded = true;
return cached;
} }
inline nlohmann::json get_customer_group_list(int64_t cursor = 0) { inline nlohmann::json get_customer_group_list(int64_t cursor = 0) {
@@ -35,7 +44,7 @@ inline nlohmann::json get_customer_group_list(int64_t cursor = 0) {
{"customerGroupId", row[0].str}, {"customerGroupId", row[0].str},
{"name", row[1].str}, {"name", row[1].str},
{"standard", row[2].str}, {"standard", row[2].str},
{"discountPercent", std::to_string(std::stod(row[3].str))}, {"discountPercent", std::to_string(parse_double(row[3].str, 0))},
{"lastChanged", row[4].str} {"lastChanged", row[4].str}
}); });
} }

View File

@@ -0,0 +1,42 @@
#pragma once
#include <string>
#include "xml.hpp"
#include "../db/pool.hpp"
namespace delivery {
inline void deliver_picklists(OdbcPool::Connection* c, int kBenutzer, int kSessionId,
int kAuftrag, int kVersandArt) {
std::string pakete = xml::element("Paket",
xml::tag("kBestellung", int64_t(kAuftrag)) +
xml::tag("kVersandart", int64_t(kVersandArt)) +
xml::tag("fGewicht", 0.0));
const int AUSLIEFERN_OPTIONS = 0x002;
const char* sql =
"DECLARE @xHinweise XML = NULL;"
"DECLARE @xPakete XML = CONVERT(XML, ?);"
"DECLARE @xResult XML;"
"EXEC Auslieferung.spPicklistenAusliefern"
" @xHinweise = @xHinweise,"
" @Pakete = @xPakete,"
" @nOptions = ?,"
" @kBenutzer = ?,"
" @kSessionId = ?,"
" @xResult = @xResult OUTPUT;"
"SELECT @xResult AS xResult";
std::vector<Param> ps = {
{ParamType::NVarChar, pakete, 0},
{ParamType::Int, "", AUSLIEFERN_OPTIONS},
{ParamType::Int, "", kBenutzer},
{ParamType::Int, "", kSessionId}
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs)) {
throw std::runtime_error("deliver_picklists failed");
}
}
} // namespace delivery

View File

@@ -0,0 +1,37 @@
#pragma once
#include "xml.hpp"
#include "session.hpp"
#include "warehouse.hpp"
#include "reserve.hpp"
#include "stock_shortage.hpp"
#include "commit.hpp"
#include "deliver.hpp"
#include "../db/pool.hpp"
namespace delivery {
inline void deliver_order(OdbcPool::Connection* c, int kBenutzer, int kAuftrag,
int kVersandArt, const std::vector<DeliveredItem>& items) {
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 (...) {
try { discard_session(c, kBenutzer, kSessionId); } catch (...) {}
try { close_session(c, kSessionId); } catch (...) {}
throw;
}
try { discard_session(c, kBenutzer, kSessionId); } catch (...) {}
try { close_session(c, kSessionId); } catch (...) {}
}
} // namespace delivery

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "../db/pool.hpp" #include "../db/pool.hpp"
#include "../log.hpp" #include "../log.hpp"
#include "../http.hpp"
#include <string> #include <string>
#include <vector> #include <vector>
#include <algorithm> #include <algorithm>
@@ -83,10 +84,10 @@ inline ImageResult get_image_by_hash(const std::string& hash, const std::string&
auto& row = rs[0]; auto& row = rs[0];
int target = size.empty() ? 200 : std::stoi(size); int target = parse_int(size, 200);
std::string ct = content_type_for(row[6].str); std::string ct = content_type_for(row[6].str);
int preview_w = row[4].str.empty() ? 0 : std::stoi(row[4].str); int preview_w = row[4].str.empty() ? 0 : parse_int(row[4].str, 0);
int preview_h = row[5].str.empty() ? 0 : std::stoi(row[5].str); int preview_h = row[5].str.empty() ? 0 : parse_int(row[5].str, 0);
int preview_max = std::max(preview_w, preview_h); int preview_max = std::max(preview_w, preview_h);
bool has_full = !row[0].blob.empty(); bool has_full = !row[0].blob.empty();

View File

@@ -1,4 +1,5 @@
#pragma once #pragma once
#include "../log.hpp"
#include "../db/pool.hpp" #include "../db/pool.hpp"
#include "nlohmann/json.hpp" #include "nlohmann/json.hpp"
#include "shop.hpp" #include "shop.hpp"
@@ -41,16 +42,9 @@ static const char* PRODUCT_LIST_SQL =
"OR (ir.maxImageRV IS NOT NULL AND ir.maxImageRV > ?)) " "OR (ir.maxImageRV IS NOT NULL AND ir.maxImageRV > ?)) "
"ORDER BY lastChanged ASC"; "ORDER BY lastChanged ASC";
static const char* PRICE_OVERRIDES_SQL =
"SELECT p.kArtikel AS articleId, p.kKundenGruppe AS customerGroupId, "
"MIN(pd.fNettoPreis) AS netPrice FROM dbo.tPreis p "
"INNER JOIN dbo.tPreisDetail pd ON pd.kPreis = p.kPreis "
"WHERE p.kArtikel IN (%s) AND p.kShop = 0 AND pd.nAnzahlAb = 0 "
"GROUP BY p.kArtikel, p.kKundenGruppe";
static std::string gross_price(const std::string& net, const std::string& tax) { static std::string gross_price(const std::string& net, const std::string& tax) {
double n = std::stod(net.empty() ? "0" : net); double n = parse_double(net, 0);
double t = std::stod(tax.empty() ? "0" : tax); double t = parse_double(tax, 0);
char buf[32]; char buf[32];
std::snprintf(buf, sizeof(buf), "%.2f", n * (1.0 + t / 100.0)); std::snprintf(buf, sizeof(buf), "%.2f", n * (1.0 + t / 100.0));
return buf; return buf;
@@ -72,7 +66,11 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
{ParamType::BigInt,"",cursor} {ParamType::BigInt,"",cursor}
}; };
ResultSet rs; ResultSet rs;
get_pool().execute(PRODUCT_LIST_SQL, ps, rs); if (!get_pool().execute(PRODUCT_LIST_SQL, ps, rs)) {
logc::warn("product list query failed (cursor=%lld limit=%d shop=%d)",
(long long)cursor, limit, shop);
return nlohmann::json::array();
}
auto cg_ids = get_customer_group_ids(); auto cg_ids = get_customer_group_ids();
nlohmann::json result = nlohmann::json::array(); nlohmann::json result = nlohmann::json::array();
@@ -109,7 +107,7 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
{"imgsrc", row[7].str.empty() ? nullptr : nlohmann::json(row[7].str)}, {"imgsrc", row[7].str.empty() ? nullptr : nlohmann::json(row[7].str)},
{"sku", row[1].str}, {"sku", row[1].str},
{"name", row[2].str}, {"name", row[2].str},
{"tax_rate", std::to_string((int)std::round(std::stod(row[4].str.empty()?"0":row[4].str)))}, {"tax_rate", std::to_string((int)std::round(parse_double(row[4].str, 0)))},
{"price", base_price}, {"price", base_price},
{"created_at", row[5].str}, {"created_at", row[5].str},
{"lastChanged", row[6].str}, {"lastChanged", row[6].str},
@@ -117,7 +115,7 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
{"categories", cats}, {"categories", cats},
{"prices", prices}, {"prices", prices},
{"is_parent", row[9].str == "1" ? "1" : "0"}, {"is_parent", row[9].str == "1" ? "1" : "0"},
{"parent", std::stoll(row[10].str) > 0 ? row[10].str : "0"}, {"parent", parse_int64(row[10].str, 0) > 0 ? row[10].str : "0"},
{"variants", row[12].str}, {"variants", row[12].str},
{"isCompositeProduct", row[11].str}, {"isCompositeProduct", row[11].str},
{"attributes", nlohmann::json::array()}, {"attributes", nlohmann::json::array()},

View File

@@ -0,0 +1,60 @@
#pragma once
#include <string>
#include <vector>
#include "xml.hpp"
#include "../db/pool.hpp"
namespace delivery {
struct DeliveredItem {
int kAuftragPosition;
double quantity;
};
inline void reserve_positions(OdbcPool::Connection* c, int kBenutzer, int kSessionId,
int kWarenLager, const std::vector<DeliveredItem>& items) {
if (items.empty()) return;
std::string bestellpositionen;
for (const auto& it : items) {
bestellpositionen += xml::element("Bestellposition",
xml::tag("kBestellPos", int64_t(it.kAuftragPosition)) +
xml::tag("fAnzahl", it.quantity));
}
std::string laeger = xml::element("Lager",
xml::tag("kWarenlager", int64_t(kWarenLager)) +
xml::tag("nPrio", int64_t(0)) +
xml::tag("kLieferant", int64_t(0)) +
xml::tag("kAnsprechpartner", int64_t(0)));
const int RESERVIERE_OPTIONS = 0x102;
const char* sql =
"DECLARE @xBestellpositionen XML = CONVERT(XML, ?);"
"DECLARE @xLaeger XML = CONVERT(XML, ?);"
"EXEC Auslieferung.spReserviereBestellpositionen"
" @Bestellpositionen = @xBestellpositionen,"
" @Laeger = @xLaeger,"
" @Warenlagereingaenge = NULL,"
" @nOptions = ?,"
" @kBenutzer = ?,"
" @kSessionId = ?";
std::vector<Param> ps = {
{ParamType::NVarChar, bestellpositionen, 0},
{ParamType::NVarChar, laeger, 0},
{ParamType::Int, "", RESERVIERE_OPTIONS},
{ParamType::Int, "", kBenutzer},
{ParamType::Int, "", kSessionId}
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs)) {
const std::string& detail = get_pool().last_error();
throw std::runtime_error(detail.empty()
? "reserve_positions failed"
: "reserve_positions failed: " + detail);
}
}
} // namespace delivery

View File

@@ -0,0 +1,42 @@
#pragma once
#include <string>
#include "../db/pool.hpp"
namespace delivery {
inline int open_session(OdbcPool::Connection* c, int kBenutzer,
const std::string& hostname = "jtlsrv") {
const char* sql =
"DECLARE @t TABLE ([kSessionId] INT);"
"INSERT INTO dbo.tSessionId (cRechnername, kBenutzer, dLastAction)"
" OUTPUT inserted.kSessionId INTO @t"
" VALUES (?, ?, DATEADD(day, 10, GETDATE()));"
"SELECT kSessionId FROM @t;";
std::vector<Param> ps = {
{ParamType::NVarChar, hostname, 0},
{ParamType::Int, "", kBenutzer}
};
ResultSet rs;
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) {
throw std::runtime_error("failed to open delivery session");
}
return std::stoi(rs[0][0].str);
}
inline void discard_session(OdbcPool::Connection* c, int kBenutzer, int kSessionId) {
std::vector<Param> ps = {
{ParamType::Int, "", kBenutzer},
{ParamType::Int, "", kSessionId}
};
ResultSet rs;
get_pool().execute(c, "{CALL Auslieferung.spPicklistenVerwerfen(?, ?)}", ps, rs);
}
inline void close_session(OdbcPool::Connection* c, int kSessionId) {
std::vector<Param> ps = {{ParamType::Int, "", kSessionId}};
ResultSet rs;
get_pool().execute(c, "DELETE FROM dbo.tSessionId WHERE kSessionId = ?", ps, rs);
}
} // namespace delivery

View File

@@ -0,0 +1,28 @@
#include "shop.hpp"
#include "../log.hpp"
#include <stdexcept>
int g_active_shop_id = 0;
int g_active_shop_subshop_id = 0;
static int cell_to_int(const Cell& cell) {
if (cell.type == CellType::Int64) return static_cast<int>(cell.i64);
if (cell.type == CellType::String && !cell.str.empty()) return std::stoi(cell.str);
return 0;
}
bool fetch_active_shop() {
ResultSet rs;
if (!get_pool().execute(
"SELECT TOP 1 kShop, kShopSubshop FROM dbo.tShopSubshop "
"WHERE nGesperrt = 0 ORDER BY kShop",
rs) || rs.empty()) {
logc::warn("failed to load active shop from dbo.tShopSubshop");
return false;
}
g_active_shop_id = cell_to_int(rs[0][0]);
g_active_shop_subshop_id = rs[0].size() > 1 ? cell_to_int(rs[0][1]) : 0;
logc::info("Active shop: kShop=%d kShopSubshop=%d", g_active_shop_id, g_active_shop_subshop_id);
return g_active_shop_id > 0;
}

View File

@@ -1,21 +1,10 @@
#pragma once #pragma once
#include "../db/pool.hpp" #include "../db/pool.hpp"
#include "../config.hpp"
static int g_active_shop_id = 0; extern int g_active_shop_id;
static int g_active_shop_subshop_id = 0; extern int g_active_shop_subshop_id;
inline int get_active_shop_id() { return g_active_shop_id; } inline int get_active_shop_id() { return g_active_shop_id; }
inline int get_active_shop_subshop_id() { return g_active_shop_subshop_id; } inline int get_active_shop_subshop_id() { return g_active_shop_subshop_id; }
inline bool fetch_active_shop() { bool fetch_active_shop();
ResultSet rs;
if (!get_pool().execute(
"SELECT TOP 1 kShop, kShopSubshop FROM dbo.tShopSubshop "
"WHERE nGesperrt = 0 ORDER BY kShop", rs) || rs.empty()) {
return false;
}
g_active_shop_id = std::stoi(rs[0][0].str);
g_active_shop_subshop_id = rs[0].size() > 1 ? std::stoi(rs[0][1].str) : 0;
return true;
}

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

@@ -0,0 +1,37 @@
#pragma once
#include "../db/pool.hpp"
#include "../config.hpp"
namespace delivery {
inline int resolve_outgoing_warehouse(OdbcPool::Connection* c) {
int configured = config::get_int("JTL_KWARENLAGER", 0);
if (configured > 0) return configured;
ResultSet rs;
if (!get_pool().execute(c,
"SELECT TOP 1 kWarenLager FROM dbo.tWarenLager "
"WHERE nFulfillment = 0 AND ISNULL(nAktiv, 1) = 1 "
"ORDER BY nAuslieferungsPrio, kWarenLager", rs) || rs.empty() || rs[0].empty()) {
throw std::runtime_error("No local warehouse (dbo.tWarenLager.nFulfillment = 0) found; set JTL_KWARENLAGER explicitly.");
}
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

View File

@@ -0,0 +1,40 @@
#pragma once
#include <string>
namespace xml {
inline std::string escape(const std::string& s) {
std::string r;
r.reserve(s.size());
for (char c : s) {
switch (c) {
case '<': r += "&lt;"; break;
case '>': r += "&gt;"; break;
case '&': r += "&amp;"; break;
case '\'': r += "&apos;"; break;
case '"': r += "&quot;"; break;
default: r += c; break;
}
}
return r;
}
inline std::string tag(const std::string& name, const std::string& value) {
return "<" + name + ">" + escape(value) + "</" + name + ">";
}
inline std::string tag(const std::string& name, int64_t value) {
return "<" + name + ">" + std::to_string(value) + "</" + name + ">";
}
inline std::string tag(const std::string& name, double value) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%.6f", value);
return "<" + name + ">" + buf + "</" + name + ">";
}
inline std::string element(const std::string& name, const std::string& children) {
return "<" + name + ">" + children + "</" + name + ">";
}
} // namespace xml

View File

@@ -1,8 +1,7 @@
#include "router.hpp" #include "router.hpp"
#include "log.hpp" #include "log.hpp"
#include <chrono> #include <exception>
#include <algorithm>
void Router::add_route(const std::string& method, const std::string& path, Handler handler) { void Router::add_route(const std::string& method, const std::string& path, Handler handler) {
std::string key = method + " " + path; std::string key = method + " " + path;
@@ -27,7 +26,21 @@ void Router::dispatch(tls_session* sess, PairingStore& pairing, const json& conf
ctx.full_url = full_url; ctx.full_url = full_url;
ctx.pairing_store = &pairing; ctx.pairing_store = &pairing;
ctx.config = config; ctx.config = config;
try {
it->second(req, resp, ctx); it->second(req, resp, ctx);
} catch (const std::exception& ex) {
logc::error("request handler error: %s %s — %s",
req.method.c_str(), full_url.c_str(), ex.what());
if (!resp.headers_sent) {
resp.send_json(500, {{"Message", "Internal server error"}});
}
} catch (...) {
logc::error("request handler error: %s %s — unknown exception",
req.method.c_str(), full_url.c_str());
if (!resp.headers_sent) {
resp.send_json(500, {{"Message", "Internal server error"}});
}
}
return; return;
} }
@@ -37,32 +50,20 @@ void Router::dispatch(tls_session* sess, PairingStore& pairing, const json& conf
}); });
} }
// Init suppression: log only if non-200, slow, or first/different request // Suppress consecutive identical /v1/init URLs; report count on non-init logs.
// within 30s window. Port of server.js shouldLogInit(). bool Router::should_log_init(const std::string& url) {
bool Router::should_log_init(const std::string& url, int status, int duration_ms, if (last_logged_init_url_ == url) {
const std::string& response) { suppressed_init_count_++;
// Report suppressed count hourly
auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
if (suppressed_count_ > 0 && (now - suppressed_report_ts_) > 3'600'000) {
logc::info("Suppressed %d init log(s) in the last hour", suppressed_count_);
suppressed_count_ = 0;
suppressed_report_ts_ = now;
}
if (status != 200) return true;
if (duration_ms > 400) return true;
int64_t time_since_last = now - last_init_timestamp_;
if (time_since_last < 30'000 && url == last_init_url_ && response == last_init_result_) {
last_init_timestamp_ = now;
suppressed_count_++;
return false; return false;
} }
last_logged_init_url_ = url;
last_init_url_ = url;
last_init_result_ = response;
last_init_timestamp_ = now;
return true; return true;
} }
void Router::flush_suppressed_init_logs() {
if (suppressed_init_count_ > 0) {
logc::info("Suppressed %d duplicate init log(s)", suppressed_init_count_);
suppressed_init_count_ = 0;
}
last_logged_init_url_.clear();
}

View File

@@ -19,17 +19,16 @@ public:
// Dispatch a request. Called from the TLS on_request callback. // Dispatch a request. Called from the TLS on_request callback.
void dispatch(tls_session* sess, PairingStore& pairing, const json& config); void dispatch(tls_session* sess, PairingStore& pairing, const json& config);
// Suppress init logs for repeated identical requests within 30s // Suppress consecutive identical init URLs; return false if suppressed.
bool should_log_init(const std::string& url, int status, int duration_ms, bool should_log_init(const std::string& url);
const std::string& response);
// Print suppressed init count (if any) before a non-init log line.
void flush_suppressed_init_logs();
private: private:
std::unordered_map<std::string, Handler> routes_; std::unordered_map<std::string, Handler> routes_;
// Init suppression state // Init suppression state
std::string last_init_url_; std::string last_logged_init_url_;
std::string last_init_result_; int suppressed_init_count_ = 0;
int64_t last_init_timestamp_ = 0;
int suppressed_count_ = 0;
int64_t suppressed_report_ts_ = 0;
}; };

View File

@@ -197,7 +197,6 @@ static void on_read(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) {
int ret = SSL_do_handshake(sess->ssl); int ret = SSL_do_handshake(sess->ssl);
if (ret == 1) { if (ret == 1) {
sess->handshake_done = true; sess->handshake_done = true;
logc::info("TLS handshake complete");
flush_encrypted(sess); flush_encrypted(sess);
} else { } else {
int err = SSL_get_error(sess->ssl, ret); int err = SSL_get_error(sess->ssl, ret);
@@ -276,7 +275,7 @@ static void on_connection(uv_stream_t* server, int status) {
uv_ip4_name(reinterpret_cast<struct sockaddr_in*>(&saddr), addr_buf, sizeof(addr_buf)); uv_ip4_name(reinterpret_cast<struct sockaddr_in*>(&saddr), addr_buf, sizeof(addr_buf));
else else
uv_ip6_name(reinterpret_cast<struct sockaddr_in6*>(&saddr), addr_buf, sizeof(addr_buf)); uv_ip6_name(reinterpret_cast<struct sockaddr_in6*>(&saddr), addr_buf, sizeof(addr_buf));
logc::info("TLS connection from %s", addr_buf); sess->peer_ip = addr_buf;
// Read encrypted data // Read encrypted data
uv_read_start(reinterpret_cast<uv_stream_t*>(&sess->tcp_handle), on_alloc, on_read); uv_read_start(reinterpret_cast<uv_stream_t*>(&sess->tcp_handle), on_alloc, on_read);
@@ -341,6 +340,7 @@ static void tls_server_shutdown() {
uv_close(reinterpret_cast<uv_handle_t*>(&g_sigint), nullptr); uv_close(reinterpret_cast<uv_handle_t*>(&g_sigint), nullptr);
uv_close(reinterpret_cast<uv_handle_t*>(&g_sigterm), nullptr); uv_close(reinterpret_cast<uv_handle_t*>(&g_sigterm), nullptr);
uv_close(reinterpret_cast<uv_handle_t*>(&g_server), nullptr); uv_close(reinterpret_cast<uv_handle_t*>(&g_server), nullptr);
uv_stop(g_loop);
} }
void tls_server_install_signals(uv_loop_t* loop) { void tls_server_install_signals(uv_loop_t* loop) {

View File

@@ -35,6 +35,7 @@ struct tls_session {
bool handshake_done = false; bool handshake_done = false;
uint32_t body_length = 0; uint32_t body_length = 0;
std::string peer_ip;
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -8,6 +8,8 @@
"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",
"backup:s3:quick": "node scripts/s3-backup/backup.mjs --skip-trust",
"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,51 @@
# MSSQL backup via local S3 endpoint
Custom S3-compatible HTTPS server (no MinIO). SQL Server 2022+ backs up with `BACKUP TO URL`; files land on disk under `data/sqlbackups/`.
## Quick start
```bash
npm run backup:s3
```
First run installs the CA into the `mssql` Docker container (`/var/opt/mssql/security/ca-certificates/`) and restarts SQL Server — required on Linux.
## Commands
| Command | Description |
|---------|-------------|
| `npm run backup:s3` | Start S3 endpoint + backup `MSSQL_DATABASE` from `.env` |
| `npm run backup:s3:quick` | Same, but skip PAL CA install/restart (after first setup) |
| `node scripts/s3-backup/backup.mjs --all` | Backup `eazybusiness` and `Mandant_3` |
| `node scripts/s3-backup/backup.mjs --server-only` | Run endpoint only |
| `npm run backup:s3 -- --skip-trust` | Skip CA install (npm needs `--` before script args) |
## Layout
```
scripts/s3-backup/
server.mjs S3-compatible HTTPS server (SigV4, multipart upload)
backup.mjs Orchestrator: trust CA → start server → sqlcmd BACKUP
config.mjs Host, port, credentials
ensure-certs.mjs TLS certs + Docker MSSQL PAL trust
sigv4.mjs AWS Signature V4 verification
data/sqlbackups/ Backup files written here
```
## Configuration
Reads `MSSQL_*` from repo `.env`. Optional overrides:
| Variable | Default | Description |
|----------|---------|-------------|
| `S3_BACKUP_HOST` | Docker bridge IP (`172.17.0.1`) | Host SQL Server uses in `s3://` URL |
| `S3_BACKUP_PORT` | `19443` | HTTPS port |
| `S3_BACKUP_ACCESS_KEY` | `jtlsrv-s3` | S3 access key |
| `S3_BACKUP_SECRET_KEY` | `jtlsrv-s3-secret` | S3 secret key |
| `MSSQL_DOCKER_CONTAINER` | `mssql` | Container name for CA install |
## Notes
- MSSQL runs in Docker: the endpoint binds `0.0.0.0` and uses the docker bridge IP so the container can reach it.
- SQL Server on Linux uses **SQLPAL** for outbound TLS — the CA must be in `/var/opt/mssql/security/ca-certificates/`, not only the OS trust store.
- Regenerating certs requires re-running without `--skip-trust` so PAL stays in sync.

View File

@@ -0,0 +1,267 @@
#!/usr/bin/env node
import { fork, spawn, 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,
SERVER_CERT,
s3BaseUrl,
} from './config.mjs';
import { caTrustStatus, ensureCerts, installCaTrust } from './ensure-certs.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
dotenv.config({ path: path.join(root, '.env') });
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 || '';
return new Promise((resolve, reject) => {
const child = spawn(
'sqlcmd',
['-W', '-C', '-S', `${server},${port}`, '-U', user, '-P', password, '-Q', query],
{ encoding: 'utf8' }
);
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk) => {
stdout += chunk;
process.stdout.write(chunk);
});
child.stderr?.on('data', (chunk) => {
stderr += chunk;
process.stderr.write(chunk);
});
child.on('close', (code) => {
const output = `${stdout}${stderr}`;
if (code !== 0 || /^\s*Msg \d+,/m.test(output)) {
reject(new Error(output.trim() || 'sqlcmd failed'));
} else {
resolve(stdout);
}
});
child.on('error', reject);
});
}
function timestamp() {
return new Date().toISOString().replace(/[:.]/g, '-');
}
async function isServerUp() {
const probeHost = /^\d+\./.test(HOST) ? '127.0.0.1' : HOST;
return new Promise((resolve) => {
const opts = {
host: probeHost,
port: PORT,
path: '/',
method: 'GET',
rejectUnauthorized: false,
};
if (!/^\d+\./.test(HOST)) {
opts.servername = HOST;
}
const req = https.request(opts, (res) => {
res.resume();
resolve(res.statusCode === 403 || res.statusCode === 200);
});
req.on('error', () => resolve(false));
req.setTimeout(1000, () => {
req.destroy();
resolve(false);
});
req.end();
});
}
async function waitForServer() {
for (let i = 0; i < 40; i++) {
if (await isServerUp()) return;
await new Promise((r) => setTimeout(r, 250));
}
throw new Error(`S3 endpoint did not start on https://${HOST}:${PORT}`);
}
function pidOnPort(port) {
const result = spawnSync('ss', ['-tlnp'], { encoding: 'utf8' });
const match = result.stdout?.match(new RegExp(`:${port}\\s+.*?pid=(\\d+)`));
return match ? Number(match[1]) : null;
}
async function stopPortListener(port) {
const stale = pidOnPort(port);
if (!stale) return;
try {
process.kill(stale);
} catch {
spawnSync('fuser', ['-k', `${port}/tcp`], { stdio: 'pipe' });
}
await new Promise((r) => setTimeout(r, 200));
}
async function ensureServerProcess() {
await stopPortListener(PORT);
return startServerProcess();
}
function ensureCredential() {
const cred = s3BaseUrl();
return sqlcmd(`
IF NOT EXISTS (SELECT 1 FROM sys.credentials WHERE name = N'${cred}')
BEGIN
CREATE CREDENTIAL [${cred}]
WITH IDENTITY = 'S3 Access Key',
SECRET = '${ACCESS_KEY}:${SECRET_KEY}';
END
`);
}
async function backupDatabase(db) {
const file = `${db}-${timestamp()}.bak`;
const url = `${s3BaseUrl()}/${file}`;
console.log(`Backing up ${db} -> ${url}`);
await sqlcmd(`
BACKUP DATABASE [${db}]
TO URL = '${url}'
WITH FORMAT, COMPRESSION, MAXTRANSFERSIZE = 20971520, 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;
}
function waitForSql() {
for (let i = 0; i < 60; i++) {
const result = spawnSync(
'sqlcmd',
[
'-W',
'-C',
'-S',
`${process.env.MSSQL_SERVER || 'localhost'},${process.env.MSSQL_PORT || '1433'}`,
'-U',
process.env.MSSQL_USER || 'sa',
'-P',
process.env.MSSQL_PASSWORD || '',
'-Q',
'SELECT 1',
],
{ encoding: 'utf8' }
);
if (result.status === 0 && !/Msg \d+,/.test(result.stdout || '')) {
return;
}
spawnSync('sleep', ['2']);
}
throw new Error('MSSQL did not become ready');
}
function startServerProcess() {
const child = fork(new URL('./server.mjs', import.meta.url), {
env: { ...process.env, S3_BACKUP_CHILD: '1' },
stdio: 'inherit',
});
return child;
}
async function main() {
if (skipTrust) {
const status = caTrustStatus();
if (!status.inSync) {
throw new Error(
'PAL CA is out of sync with scripts/s3-backup/certs/ca.pem. Run: npm run backup:s3'
);
}
console.log('Skipping PAL CA install (--skip-trust)');
if (!fs.existsSync(SERVER_CERT)) {
throw new Error('No TLS certs found. Run: npm run backup:s3');
}
} else {
ensureCerts(HOST);
const trust = installCaTrust();
if (!trust.ok) {
console.warn('Could not install CA into MSSQL container. Run:');
console.warn(' docker exec -u root mssql mkdir -p /var/opt/mssql/security/ca-certificates');
console.warn(' docker cp scripts/s3-backup/certs/ca.pem mssql:/var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem');
console.warn(' docker exec -u root mssql chown mssql:mssql /var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem');
console.warn(' docker restart mssql');
} else {
console.log('Installed S3 CA into MSSQL PAL trust store');
if (trust.restarted) {
console.log('Waiting for MSSQL to restart...');
waitForSql();
}
}
}
const serverChild = await ensureServerProcess();
try {
await waitForServer();
if (serverOnly) {
console.log('Server running (--server-only). Ctrl+C to stop.');
await new Promise((resolve) => serverChild.on('exit', resolve));
return;
}
await ensureCredential();
const saved = [];
for (const db of databases) {
saved.push(await backupDatabase(db));
}
console.log('\nBackups on disk:');
for (const file of saved) {
console.log(` ${file}`);
}
} finally {
if (!serverOnly) {
serverChild.kill();
await stopPortListener(PORT);
}
}
}
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,197 @@
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';
const SQL_CA_DIR = '/var/opt/mssql/security/ca-certificates';
export function certHosts(host) {
const hosts = new Set([host, '127.0.0.1', 'localhost', 's3backup.local']);
for (const iface of Object.values(os.networkInterfaces())) {
for (const addr of iface || []) {
if (addr.family === 'IPv4' && !addr.internal && !addr.address.startsWith('169.254.')) {
hosts.add(addr.address);
}
}
}
return [...hosts].sort();
}
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 caTrustStatus(container = process.env.MSSQL_DOCKER_CONTAINER || 'mssql') {
if (!fs.existsSync(CA_CERT)) {
return { ok: false, inSync: false, restarted: false };
}
const localFp = execSync(`openssl x509 -in "${CA_CERT}" -noout -fingerprint -sha256`, {
encoding: 'utf8',
}).trim();
const remoteFp = spawnSync(
'docker',
[
'exec',
container,
'bash',
'-lc',
`test -f ${SQL_CA_DIR}/jtlsrv-s3-ca.pem && openssl x509 -in ${SQL_CA_DIR}/jtlsrv-s3-ca.pem -noout -fingerprint -sha256`,
],
{ encoding: 'utf8' }
);
const inSync = remoteFp.status === 0 && remoteFp.stdout.trim() === localFp;
return { ok: remoteFp.status === 0, inSync, restarted: false };
}
export function installCaTrust(container = process.env.MSSQL_DOCKER_CONTAINER || 'mssql') {
if (!fs.existsSync(CA_CERT)) {
ensureCerts();
}
const localFp = execSync(`openssl x509 -in "${CA_CERT}" -noout -fingerprint -sha256`, {
encoding: 'utf8',
}).trim();
const remoteFp = spawnSync(
'docker',
[
'exec',
container,
'bash',
'-lc',
`test -f ${SQL_CA_DIR}/jtlsrv-s3-ca.pem && openssl x509 -in ${SQL_CA_DIR}/jtlsrv-s3-ca.pem -noout -fingerprint -sha256`,
],
{ encoding: 'utf8' }
);
if (remoteFp.status === 0 && remoteFp.stdout.trim() === localFp) {
spawnSync('docker', [
'exec',
'-u',
'root',
container,
'bash',
'-lc',
'grep -q s3backup.local /etc/hosts || echo "172.17.0.1 s3backup.local" >> /etc/hosts',
]);
return { ok: true, restarted: false };
}
spawnSync('docker', ['exec', '-u', 'root', container, 'mkdir', '-p', SQL_CA_DIR], {
stdio: 'pipe',
});
spawnSync('docker', [
'exec',
'-u',
'root',
container,
'bash',
'-lc',
`rm -f ${SQL_CA_DIR}/*.pem ${SQL_CA_DIR}/*.crt`,
]);
spawnSync('docker', [
'exec',
'-u',
'root',
container,
'bash',
'-lc',
'grep -q s3backup.local /etc/hosts || echo "172.17.0.1 s3backup.local" >> /etc/hosts',
]);
try {
execSync('grep -q s3backup.local /etc/hosts || echo "172.17.0.1 s3backup.local" >> /etc/hosts', {
stdio: 'pipe',
});
} catch {
// optional on host
}
const copied = spawnSync(
'docker',
['cp', CA_CERT, `${container}:${SQL_CA_DIR}/jtlsrv-s3-ca.pem`],
{ stdio: 'pipe' }
);
if (copied.status !== 0) {
return { ok: false, restarted: false };
}
const perms = spawnSync(
'docker',
[
'exec',
'-u',
'root',
container,
'bash',
'-lc',
`chown mssql:mssql ${SQL_CA_DIR}/jtlsrv-s3-ca.pem && chmod 644 ${SQL_CA_DIR}/jtlsrv-s3-ca.pem`,
],
{ stdio: 'pipe' }
);
if (perms.status !== 0) {
return { ok: false, restarted: false };
}
const restarted = spawnSync('docker', ['restart', container], { stdio: 'pipe' });
return { ok: true, restarted: restarted.status === 0 };
}

View File

@@ -0,0 +1,311 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
import { URL, pathToFileURL } 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) {
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'DELETE') {
return Promise.resolve(Buffer.alloc(0));
}
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
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;
}
function debug(...args) {
if (process.env.S3_BACKUP_DEBUG) {
console.error(...args);
}
}
async function handle(req, res) {
const body = await readBody(req);
debug(`${req.method} ${req.url} len=${body.length}`);
if (!authOk(req, body)) {
debug(`auth failed ${req.method} ${req.url}`);
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 === 'HEAD' && !key) {
return send(res, 200, '');
}
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() {
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, reject) => {
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
reject(new Error(`Port ${PORT} is already in use`));
return;
}
reject(err);
});
server.on('secureConnection', (tlsSocket) => {
debug(`tls ${tlsSocket.remoteAddress}:${tlsSocket.remotePort}`);
});
server.listen(PORT, BIND, () => {
console.log(`S3 endpoint https://${HOST}:${PORT}/${BUCKET} -> ${DATA_DIR}`);
resolve(server);
});
});
}
if (process.env.S3_BACKUP_CHILD || import.meta.url === pathToFileURL(process.argv[1]).href) {
startServer().catch((err) => {
console.error(err.message || err);
process.exit(1);
});
}

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],
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 regionToUse = parsed.region || region;
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}/${regionToUse}/s3/aws4_request`;
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, hash(canonical)].join('\n');
const signingKey = hmac(
hmac(hmac(hmac(`AWS4${secretKey}`, parsed.date), regionToUse), 's3'),
'aws4_request'
);
const expected = hmac(signingKey, stringToSign, 'hex');
if (expected.length !== parsed.signature.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parsed.signature, 'hex'));
}
export function etagFor(data) {
return `"${crypto.createHash('md5').update(data).digest('hex')}"`;
}

View File

@@ -36,33 +36,14 @@ function truncateUrl(url) {
return `${url.slice(0, CONSOLE_URL_MAX_LENGTH)}...`; return `${url.slice(0, CONSOLE_URL_MAX_LENGTH)}...`;
} }
const lastInitState = { url: '', result: '', timestamp: 0 }; let lastLoggedInitUrl = null;
let suppressedCount = 0; let suppressedInitCount = 0;
setInterval(() => { function flushSuppressedInitLogs() {
if (suppressedCount > 0) { if (suppressedInitCount > 0) {
logger.info(`Suppressed ${suppressedCount} init log(s) in the last hour`); logger.info(`Suppressed ${suppressedInitCount} duplicate init log(s)`);
suppressedCount = 0; suppressedInitCount = 0;
} }
}, 60 * 60 * 1000);
function shouldLogInit(url, statusCode, durationMs, responseBody) {
if (statusCode !== 200) return true;
if (durationMs > 400) return true;
const now = Date.now();
const timeSinceLast = now - lastInitState.timestamp;
if (timeSinceLast < 30_000 && url === lastInitState.url && responseBody === lastInitState.result) {
lastInitState.timestamp = now;
suppressedCount++;
return false;
}
lastInitState.url = url;
lastInitState.result = responseBody;
lastInitState.timestamp = now;
return true;
} }
function formatBody(buffer) { function formatBody(buffer) {
@@ -100,7 +81,17 @@ const loggedJtlHandler = async (req, res) => {
const durationMs = Date.now() - started; const durationMs = Date.now() - started;
const isInit = req.url.startsWith('/api/v1/init'); const isInit = req.url.startsWith('/api/v1/init');
if (!isInit || shouldLogInit(req.url, res.statusCode, durationMs, responseBody)) { if (isInit) {
if (lastLoggedInitUrl === req.url) {
suppressedInitCount++;
return;
}
lastLoggedInitUrl = req.url;
} else {
flushSuppressedInitLogs();
lastLoggedInitUrl = null;
}
logger.info(`${req.socket.remoteAddress} ${req.method} ${truncateUrl(req.url)} ${res.statusCode} ${durationMs}ms`); logger.info(`${req.socket.remoteAddress} ${req.method} ${truncateUrl(req.url)} ${res.statusCode} ${durationMs}ms`);
logRequest({ logRequest({
@@ -111,7 +102,6 @@ const loggedJtlHandler = async (req, res) => {
durationMs, durationMs,
response: responseBody, response: responseBody,
}); });
}
}; };
const httpsServer = https.createServer( const httpsServer = https.createServer(

View File

@@ -1,6 +1,6 @@
import { sendJson } from '../http.js'; import { sendJson } from '../http.js';
import { getMaxExternalId } from '../order-log.js';
import { getCategoryCount } from '../queries/category-count.js'; import { getCategoryCount } from '../queries/category-count.js';
import { getMaxOrderIdCount } from '../queries/max-order-id.js';
import { getCompositeProductCount } from '../queries/composite-product-count.js'; import { getCompositeProductCount } from '../queries/composite-product-count.js';
import { getCustomerGroupCount } from '../queries/customer-groups.js'; import { getCustomerGroupCount } from '../queries/customer-groups.js';
import { getDeletedEntityCount } from '../queries/deleted-entity-count.js'; import { getDeletedEntityCount } from '../queries/deleted-entity-count.js';
@@ -16,12 +16,13 @@ export async function handle(_req, res, { url }) {
const compositeProductCursor = Number(url.searchParams.get('lastChangedCompositeProduct')) || 0; const compositeProductCursor = Number(url.searchParams.get('lastChangedCompositeProduct')) || 0;
const deletedEntityCursor = Number(url.searchParams.get('lastChangedDeletedEntity')) || 0; const deletedEntityCursor = Number(url.searchParams.get('lastChangedDeletedEntity')) || 0;
const [productCount, categoryCount, customerGroupCount, compositeProductCount, deletedEntityCount] = await Promise.all([ const [productCount, categoryCount, customerGroupCount, compositeProductCount, deletedEntityCount, maxOrderIdCount] = await Promise.all([
getProductCount({ cursor: productCursor }), getProductCount({ cursor: productCursor }),
getCategoryCount({ cursor: categoryCursor }), getCategoryCount({ cursor: categoryCursor }),
getCustomerGroupCount({ cursor: customerGroupCursor }), getCustomerGroupCount({ cursor: customerGroupCursor }),
getCompositeProductCount({ cursor: compositeProductCursor }), getCompositeProductCount({ cursor: compositeProductCursor }),
getDeletedEntityCount({ cursor: deletedEntityCursor }), getDeletedEntityCount({ cursor: deletedEntityCursor }),
getMaxOrderIdCount(),
]); ]);
return sendJson(res, 200, { return sendJson(res, 200, {
@@ -34,6 +35,6 @@ export async function handle(_req, res, { url }) {
configurationGroup_count: '0', configurationGroup_count: '0',
configurationItem_count: '0', configurationItem_count: '0',
deletedEntity_count: String(deletedEntityCount), deletedEntity_count: String(deletedEntityCount),
max_orderId_count: getMaxExternalId(), max_orderId_count: String(maxOrderIdCount),
}); });
} }

View File

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

@@ -7,20 +7,13 @@ fs.mkdirSync(path.dirname(ORDER_LOG_FILE), { recursive: true });
const stream = fs.createWriteStream(ORDER_LOG_FILE, { flags: 'a' }); const stream = fs.createWriteStream(ORDER_LOG_FILE, { flags: 'a' });
let orderSequence = 0; let orderSequence = 0;
let maxExternalId = 0;
export function logOrder(order) { export function logOrder(order) {
orderSequence += 1; orderSequence += 1;
maxExternalId += 1; const externalId = order?.externalId ?? '';
const line = `${new Date().toISOString()} #${orderSequence} externalId=${maxExternalId} ${JSON.stringify(order)}\n`; const line = `${new Date().toISOString()} #${orderSequence} externalId=${externalId} ${JSON.stringify(order)}\n`;
stream.write(line); stream.write(line);
return maxExternalId;
}
export function getMaxExternalId() {
return String(maxExternalId);
} }
export function closeOrderLog() { export function closeOrderLog() {

View File

@@ -133,32 +133,134 @@ async function allocatePk(transaction, tableName) {
return pk; return pk;
} }
const VERSANDPOSITION_TYPE = 2;
const ZAHLUNG_TYPE_ZAHLUNG = 10;
const NIST_READONLY_NICHT_AENDERBAR = 2;
const NIST_EXTERNE_RECHNUNG_KEINE = 2;
function parseImportSetting(order) {
return toNumber(order.settings?.importSetting, 0);
}
function parseInvoiceSetting(order) {
return toNumber(order.settings?.invoiceSetting, 0);
}
/** PosOrderSettingsService: importSetting=0 → NichtAenderbar. */
function resolveNIstReadOnly(order) {
return parseImportSetting(order) === 0 ? NIST_READONLY_NICHT_AENDERBAR : 0;
}
/**
* PosOrderSettingsService: importSetting=0 → KeineRechnungKeineAusgabe;
* importSetting 25 or invoiceSetting Create flag → Wawi invoice.
*/
function resolveNIstExterneRechnung(order) {
const importSetting = parseImportSetting(order);
const invoiceSetting = parseInvoiceSetting(order);
if (importSetting >= 2 && importSetting <= 5) return 0;
if (invoiceSetting & 1) return 0;
if (importSetting === 0) return NIST_EXTERNE_RECHNUNG_KEINE;
return 0;
}
function isVersandposition(item) {
return toNumber(item.type, 0) === VERSANDPOSITION_TYPE;
}
function hasVersandposition(items) {
return items.some(isVersandposition);
}
function hasNonReturnSaleItems(items) {
return items.some((item) => toNumber(item.isReturn, 0) === 0);
}
function shouldInjectSelbstabholerShipping(items) {
if (hasVersandposition(items)) return false;
return hasNonReturnSaleItems(items);
}
async function lookupVersandArt(transaction, shippingName) {
const name = String(shippingName ?? '').trim() || 'Selbstabholer';
const tryLookup = async (cName) => {
const result = await new sql.Request(transaction)
.input('cName', sql.NVarChar, cName)
.query(`
SELECT TOP 1 kVersandArt, cName, fPrice, fMwSt
FROM dbo.tVersandArt
WHERE cName = @cName
`);
return result.recordset[0] ?? null;
};
let row = await tryLookup(name);
if (!row && name !== 'Selbstabholer') {
row = await tryLookup('Selbstabholer');
}
return row;
}
function syntheticShippingItem(versandArt) {
const vat = toNumber(versandArt.fMwSt, 19);
const gross = toNumber(versandArt.fPrice, 0);
const net = gross / (1 + vat / 100);
return {
type: String(VERSANDPOSITION_TYPE),
quantity: '1',
name: versandArt.cName,
priceGross: String(gross),
priceNet: String(net),
vat: String(vat),
isReturn: '0',
};
}
/** /**
* Resolves a Zahlungsart by name; creates it on the fly (including its PK * Resolves a Zahlungsart by name; creates it on the fly (including its PK
* from dbo.tpk) when it does not exist yet. * from dbo.tpk) when it does not exist yet.
*/ */
async function resolveZahlungsart(transaction, name, cache) { async function resolveZahlungsart(transaction, name, cache) {
const key = String(name || 'Bar'); const lookupName = String(name || 'Bar');
const cached = cache.get(key.toLowerCase()); const cacheKey = lookupName.toLowerCase();
const cached = cache.get(cacheKey);
if (cached) { if (cached) {
return cached; return cached;
} }
const existing = await new sql.Request(transaction) let row = null;
.input('cName', sql.NVarChar, key) const exact = await new sql.Request(transaction)
.input('cName', sql.NVarChar, lookupName)
.query('SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE cName = @cName'); .query('SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE cName = @cName');
row = exact.recordset[0] ?? null;
if (!row) {
const lang = await new sql.Request(transaction)
.input('cName', sql.NVarChar, lookupName)
.query(`
SELECT TOP 1 z.kZahlungsart, z.cName
FROM dbo.tZahlungsArtSprache zs
INNER JOIN dbo.tZahlungsart z ON z.kZahlungsart = zs.kZahlungsart
WHERE zs.cName = @cName
`);
row = lang.recordset[0] ?? null;
}
if (!row) {
const ci = await new sql.Request(transaction)
.input('cName', sql.NVarChar, lookupName)
.query('SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE UPPER(cName) = UPPER(@cName)');
row = ci.recordset[0] ?? null;
}
let zahlungsart; let zahlungsart;
if (existing.recordset[0]) { if (row) {
zahlungsart = { zahlungsart = { kZahlungsart: row.kZahlungsart, cName: row.cName };
kZahlungsart: existing.recordset[0].kZahlungsart,
cName: existing.recordset[0].cName,
};
} else { } else {
const kZahlungsart = await allocatePk(transaction, 'tZahlungsart'); const kZahlungsart = await allocatePk(transaction, 'tZahlungsart');
await new sql.Request(transaction) await new sql.Request(transaction)
.input('kZahlungsart', sql.Int, kZahlungsart) .input('kZahlungsart', sql.Int, kZahlungsart)
.input('cName', sql.NVarChar, key) .input('cName', sql.NVarChar, lookupName)
.query(` .query(`
INSERT INTO dbo.tZahlungsart INSERT INTO dbo.tZahlungsart
(kZahlungsart, cName, cPrtString, nLastschrift, cPrtStringVor, cPaymentOption, cKonto, (kZahlungsart, cName, cPrtString, nLastschrift, cPrtStringVor, cPaymentOption, cKonto,
@@ -166,10 +268,10 @@ async function resolveZahlungsart(transaction, name, cache) {
nMatchingOptionen, nIstStandard, nAktiv) nMatchingOptionen, nIstStandard, nAktiv)
VALUES (@kZahlungsart, @cName, '', 0, '', '', '', 0, 0, 0, 0, 0, 0, 0, 1) VALUES (@kZahlungsart, @cName, '', 0, '', '', '', 0, 0, 0, 0, 0, 0, 0, 1)
`); `);
zahlungsart = { kZahlungsart, cName: key }; zahlungsart = { kZahlungsart, cName: lookupName };
} }
cache.set(key.toLowerCase(), zahlungsart); cache.set(cacheKey, zahlungsart);
return zahlungsart; return zahlungsart;
} }
@@ -182,7 +284,7 @@ async function nextCustomerNumber(transaction) {
* the search tables). Columns of the table type are named explicitly because * the search tables). Columns of the table type are named explicitly because
* their order differs between JTL versions. * their order differs between JTL versions.
*/ */
async function createCustomer(transaction, { customerNumber, address, isPosCustomer, defaults }) { async function createCustomer(transaction, { customerNumber, address, defaults }) {
const a = address || {}; const a = address || {};
const iso = (a.countryIso || 'DE').toUpperCase(); const iso = (a.countryIso || 'DE').toUpperCase();
const kKundengruppe = Number(a.customerGroupId) || defaults.kKundengruppe; const kKundengruppe = Number(a.customerGroupId) || defaults.kKundengruppe;
@@ -209,13 +311,13 @@ async function createCustomer(transaction, { customerNumber, address, isPosCusto
.input('kSprache', sql.Int, config.kSprache) .input('kSprache', sql.Int, config.kSprache)
.input('cISO', sql.NVarChar, iso) .input('cISO', sql.NVarChar, iso)
.input('cBundesland', sql.NVarChar, a.state || '') .input('cBundesland', sql.NVarChar, a.state || '')
.input('cHerkunft', sql.NVarChar, isPosCustomer ? 'Kasse' : 'POS-API') .input('cHerkunft', sql.NVarChar, 'Kasse')
.input('cKassenKunde', sql.Char(1), isPosCustomer ? 'Y' : 'N') .input('cKassenKunde', sql.Char(1), 'Y')
.input('nDebitorennr', sql.Int, Number(a.debtorNumber) || 0) .input('nDebitorennr', sql.Int, Number(a.debtorNumber) || 0)
.query(` .query(`
DECLARE @returnValue INT; DECLARE @returnValue INT;
DECLARE @p1 dbo.TYPE_spkundeInsert; DECLARE @kunde_daten dbo.TYPE_spkundeInsert;
INSERT INTO @p1 INSERT INTO @kunde_daten
(kInetKunde, kKundenKategorie, cKundenNr, cFirma, cAnrede, cTitel, cVorname, cName, (kInetKunde, kKundenKategorie, cKundenNr, cFirma, cAnrede, cTitel, cVorname, cName,
cStrasse, cPLZ, cOrt, cLand, cTel, cFax, cEMail, dErstellt, cMobil, fRabatt, cUSTID, cNewsletter, cStrasse, cPLZ, cOrt, cLand, cTel, cFax, cEMail, dErstellt, cMobil, fRabatt, cUSTID, cNewsletter,
cZusatz, cEbayName, kBuyer, cAdressZusatz, cGeburtstag, cWWW, cSperre, cPostID, kKundenGruppe, cZusatz, cEbayName, kBuyer, cAdressZusatz, cGeburtstag, cWWW, cSperre, cPostID, kKundenGruppe,
@@ -229,7 +331,7 @@ async function createCustomer(transaction, { customerNumber, address, isPosCusto
0, @kSprache, @cISO, @cBundesland, @cHerkunft, @cKassenKunde, N'', 0, 0, @kSprache, @cISO, @cBundesland, @cHerkunft, @cKassenKunde, N'', 0,
@nDebitorennr, N'', 0, 0, 0, 0, 0, @nDebitorennr, N'', 0, 0, 0, 0, 0,
NULL, 0, 0, 0); NULL, 0, 0, 0);
EXEC @returnValue = Kunde.spKundeInsert @daten = @p1; EXEC @returnValue = Kunde.spKundeInsert @daten = @kunde_daten;
SELECT @returnValue AS kKunde; SELECT @returnValue AS kKunde;
`); `);
@@ -240,15 +342,47 @@ async function createCustomer(transaction, { customerNumber, address, isPosCusto
return { kKunde, kKundengruppe }; return { kKunde, kKundengruppe };
} }
function isWalkInOrder(order) {
const customerNumber = String(order.customerNumber || '').trim();
if (!customerNumber || customerNumber === '0') return true;
const billing = order.billingAddress || {};
return (billing.lastName || '') === 'Laufkunde' && !(billing.firstName || '').trim();
}
function resolveAuftragCKundenNr(order) {
return isWalkInOrder(order) ? '0' : String(order.customerNumber || '');
}
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"
);
if (result.recordset[0]) {
return {
kKunde: result.recordset[0].kKunde,
kKundengruppe: result.recordset[0].kKundenGruppe || defaults.kKundengruppe,
};
}
return null;
}
/** /**
* Resolves the customer by cKundenNr; creates the Kunde on the fly when it * Resolves the customer by cKundenNr; creates the Kunde on the fly when it
* does not exist. Orders without a customer number use (or create) the * does not exist. Walk-in orders use (or create) the Kassenkunde.
* Kassenkunde (walk-in customer).
*/ */
async function resolveCustomer(transaction, order, defaults) { async function resolveCustomer(transaction, order, defaults) {
const customerNumber = String(order.customerNumber || '').trim(); if (isWalkInOrder(order)) {
const kassenKunde = await lookupKassenkunde(transaction, defaults);
if (kassenKunde) return kassenKunde;
return createCustomer(transaction, {
customerNumber: await nextCustomerNumber(transaction),
address: order.billingAddress,
defaults,
});
}
if (customerNumber) { const customerNumber = String(order.customerNumber || '').trim();
const result = await new sql.Request(transaction) const result = await new sql.Request(transaction)
.input('cKundenNr', sql.NVarChar, customerNumber) .input('cKundenNr', sql.NVarChar, customerNumber)
.query('SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKundenNr = @cKundenNr'); .query('SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKundenNr = @cKundenNr');
@@ -261,25 +395,6 @@ async function resolveCustomer(transaction, order, defaults) {
return createCustomer(transaction, { return createCustomer(transaction, {
customerNumber, customerNumber,
address: order.billingAddress, address: order.billingAddress,
isPosCustomer: false,
defaults,
});
}
const kassenKunde = await new sql.Request(transaction).query(
"SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKassenKunde = 'Y' ORDER BY kKunde"
);
if (kassenKunde.recordset[0]) {
return {
kKunde: kassenKunde.recordset[0].kKunde,
kKundengruppe: kassenKunde.recordset[0].kKundenGruppe || defaults.kKundengruppe,
};
}
return createCustomer(transaction, {
customerNumber: await nextCustomerNumber(transaction),
address: order.billingAddress,
isPosCustomer: true,
defaults, defaults,
}); });
} }
@@ -328,15 +443,19 @@ async function insertOrderItem(transaction, kAuftrag, item) {
const discount = toNumber(item.discountPercent, 0); const discount = toNumber(item.discountPercent, 0);
const kSteuerklasse = steuerklasseForVat(vat); const kSteuerklasse = steuerklasseForVat(vat);
const sku = String(item.sku || '').trim(); const sku = String(item.sku || '').trim();
const positionType = isVersandposition(item) ? VERSANDPOSITION_TYPE : toNumber(item.type, 0);
let kArtikel = null; let kArtikel = null;
if (sku) { if (sku && positionType !== VERSANDPOSITION_TYPE) {
const result = await new sql.Request(transaction) const result = await new sql.Request(transaction)
.input('cArtNr', sql.NVarChar, sku) .input('cArtNr', sql.NVarChar, sku)
.query('SELECT TOP 1 kArtikel FROM dbo.tArtikel WHERE cArtNr = @cArtNr'); .query('SELECT TOP 1 kArtikel FROM dbo.tArtikel WHERE cArtNr = @cArtNr');
kArtikel = result.recordset[0]?.kArtikel ?? null; kArtikel = result.recordset[0]?.kArtikel ?? null;
} }
const nType = positionType === VERSANDPOSITION_TYPE ? VERSANDPOSITION_TYPE : (kArtikel ? 1 : 0);
const nReserviert = nType === VERSANDPOSITION_TYPE ? 0 : 1;
const result = await new sql.Request(transaction) const result = await new sql.Request(transaction)
.input('kArtikel', sql.Int, kArtikel) .input('kArtikel', sql.Int, kArtikel)
.input('kAuftrag', sql.Int, kAuftrag) .input('kAuftrag', sql.Int, kAuftrag)
@@ -347,7 +466,8 @@ async function insertOrderItem(transaction, kAuftrag, item) {
.input('fVkNetto', sql.Float, priceNet) .input('fVkNetto', sql.Float, priceNet)
.input('fMwSt', sql.Float, vat) .input('fMwSt', sql.Float, vat)
.input('kSteuerklasse', sql.Int, kSteuerklasse) .input('kSteuerklasse', sql.Int, kSteuerklasse)
.input('nType', sql.Int, kArtikel ? 1 : 0) .input('nType', sql.Int, nType)
.input('nReserviert', sql.Int, nReserviert)
.input('cEinheit', sql.NVarChar, item.unit || '') .input('cEinheit', sql.NVarChar, item.unit || '')
.input('fRabatt', sql.Float, discount) .input('fRabatt', sql.Float, discount)
.query(` .query(`
@@ -356,7 +476,7 @@ async function insertOrderItem(transaction, kAuftrag, item) {
(kArtikel, kAuftrag, cArtNr, nReserviert, cName, cHinweis, fAnzahl, fVkNetto, fMwSt, (kArtikel, kAuftrag, cArtNr, nReserviert, cName, cHinweis, fAnzahl, fVkNetto, fMwSt,
cNameStandard, kSteuerklasse, nType, cEinheit, fFaktor, kSteuerschluessel, fRabatt) cNameStandard, kSteuerklasse, nType, cEinheit, fFaktor, kSteuerschluessel, fRabatt)
OUTPUT inserted.kAuftragPosition INTO @t OUTPUT inserted.kAuftragPosition INTO @t
VALUES (@kArtikel, @kAuftrag, @cArtNr, 1, @cName, @cHinweis, @fAnzahl, @fVkNetto, @fMwSt, VALUES (@kArtikel, @kAuftrag, @cArtNr, @nReserviert, @cName, @cHinweis, @fAnzahl, @fVkNetto, @fMwSt,
@cName, @kSteuerklasse, @nType, @cEinheit, 1.0, 3, @fRabatt); @cName, @kSteuerklasse, @nType, @cEinheit, 1.0, 3, @fRabatt);
SELECT kAuftragPosition FROM @t; SELECT kAuftragPosition FROM @t;
`); `);
@@ -364,15 +484,62 @@ async function insertOrderItem(transaction, kAuftrag, item) {
} }
/** /**
* Links a JTL-Wawi order (and its positions) back to the originating * JTL PosOrderCreationService.CheckIfOrderExists — skip re-import when the POS
* POS order/position ids via Pos.tAuftragMapping / Pos.tAuftragPositionMapping, * order id is already mapped to a Wawi order for this subshop with the same
* so the POS system can recognise orders it already pushed into JTL-Wawi. * external receipt number.
*/ */
async function insertPosOrderMapping(transaction, kAuftrag, 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;
}
const result = await getPool()
.request()
.input('kPosAuftrag', sql.Int, kPosAuftrag)
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(`
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
AND m.kShopSubShop = @kShopSubShop
AND m.kAuftrag IS NOT NULL
ORDER BY m.kAuftrag DESC
`);
const row = result.recordset[0];
if (!row?.kAuftrag) {
return null;
}
if (!externalOrderNumbersMatch(row.cExterneAuftragsnummer, externalOrderNumber)) {
return null;
}
return row;
}
/**
* Links a JTL-Wawi order back to the originating POS order id.
* MERGE matches JTL CreateOrUpdate outcome for the final committed row.
*/
async function upsertPosOrderMapping(transaction, kAuftrag, kPosAuftrag) {
const kShopSubShop = getActiveShopSubshopId(); const kShopSubShop = getActiveShopSubshopId();
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)
@@ -418,11 +585,43 @@ function isOrderDelivered(order) {
return deliver === true || String(deliver) === '1' || String(deliver).toLowerCase() === 'true'; return deliver === true || String(deliver) === '1' || String(deliver).toLowerCase() === 'true';
} }
async function recalculateAuftragEckdaten(transaction, kAuftrag) {
await new sql.Request(transaction).input('kAuftrag', sql.Int, kAuftrag).query(`
DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;
INSERT INTO @eckdaten_calc VALUES (@kAuftrag);
EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc;
`);
}
async function getOffenerAuftragswert(transaction, kAuftrag) {
const result = await new sql.Request(transaction)
.input('kAuftrag', sql.Int, kAuftrag)
.query(`
SELECT ROUND(tAuftragEckdaten.fOffenerWertOhneStorno, 2) AS fOffenerAuftragswert
FROM Verkauf.tAuftrag
LEFT JOIN Verkauf.tAuftragEckdaten ON tAuftragEckdaten.kAuftrag = tAuftrag.kAuftrag
WHERE tAuftrag.kAuftrag = @kAuftrag
`);
const value = result.recordset[0]?.fOffenerAuftragswert;
return value == null ? null : toNumber(value, 0);
}
function isNewPayment(payment) {
const paymentId = toNumber(payment.paymentId, 0);
return paymentId <= 0;
}
async function insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache) { async function insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache) {
const zahlungsart = await resolveZahlungsart(transaction, payment.paymentMethodName || order.paymentMethodName, zahlungsartCache); const zahlungsart = await resolveZahlungsart(transaction, payment.paymentMethodName || order.paymentMethodName, zahlungsartCache);
const kZahlung = await allocatePk(transaction, 'tZahlung'); const kZahlung = await allocatePk(transaction, 'tZahlung');
await new sql.Request(transaction) await recalculateAuftragEckdaten(transaction, kAuftrag);
const fOffenerWert = await getOffenerAuftragswert(transaction, kAuftrag);
if (fOffenerWert == null) {
throw new Error(`no open order amount for kAuftrag=${kAuftrag}`);
}
const result = await new sql.Request(transaction)
.input('kZahlung', sql.Int, kZahlung) .input('kZahlung', sql.Int, kZahlung)
.input('cName', sql.NVarChar, zahlungsart.cName) .input('cName', sql.NVarChar, zahlungsart.cName)
.input('dDatum', sql.DateTime, orderDate) .input('dDatum', sql.DateTime, orderDate)
@@ -431,16 +630,42 @@ async function insertPayment(transaction, kAuftrag, payment, order, orderDate, z
.input('kBenutzer', sql.Int, config.kBenutzer) .input('kBenutzer', sql.Int, config.kBenutzer)
.input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart) .input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart)
.input('cExternalTransactionId', sql.NVarChar, order.externalOrderNumber || '') .input('cExternalTransactionId', sql.NVarChar, order.externalOrderNumber || '')
.input('fOffenerWert', sql.Float, fOffenerWert)
.query(` .query(`
IF EXISTS (
SELECT 1 FROM Verkauf.tAuftragEckdaten
WHERE kAuftrag = @kBestellung
AND ROUND(fOffenerWertOhneStorno, 2) = ROUND(@fOffenerWert, 2)
)
BEGIN
INSERT INTO dbo.tZahlung INSERT INTO dbo.tZahlung
(kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart, (kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart,
nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung) nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung)
VALUES (@kZahlung, @cName, @dDatum, @fBetrag, @kBestellung, @kBenutzer, 0, '', @kZahlungsart, VALUES (@kZahlung, @cName, @dDatum, @fBetrag, @kBestellung, @kBenutzer, 0, '', @kZahlungsart,
0, @cExternalTransactionId, 0, 0, '', 0) 0, @cExternalTransactionId, 0, ${ZAHLUNG_TYPE_ZAHLUNG}, '', 0);
END
SELECT @@ROWCOUNT AS inserted;
`); `);
if (!result.recordset[0]?.inserted) {
throw new Error(`payment insert skipped: open amount changed for kAuftrag=${kAuftrag}`);
}
} }
export async function createOrder(order) { 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, externalOrderNumber);
if (existing) {
return {
orderId: String(existing.kAuftrag),
orderNumber: existing.cAuftragsNr || '',
alreadyExists: true,
};
}
}
const defaults = await getDefaults(); const defaults = await getDefaults();
const transaction = new sql.Transaction(getPool()); const transaction = new sql.Transaction(getPool());
await transaction.begin(sql.ISOLATION_LEVEL.READ_COMMITTED); await transaction.begin(sql.ISOLATION_LEVEL.READ_COMMITTED);
@@ -456,6 +681,16 @@ export async function createOrder(order) {
const zahlungsart = await resolveZahlungsart(transaction, order.paymentMethodName, zahlungsartCache); const zahlungsart = await resolveZahlungsart(transaction, order.paymentMethodName, zahlungsartCache);
const cAuftragsNr = await nextOrderNumber(transaction, orderDate); const cAuftragsNr = await nextOrderNumber(transaction, orderDate);
const orderItems = [...(order.orderItems || [])];
const versandArt = await lookupVersandArt(transaction, order.shippingName);
let kVersandArt = versandArt?.kVersandArt ?? defaults.kVersandArt;
if (shouldInjectSelbstabholerShipping(orderItems) && versandArt) {
orderItems.push(syntheticShippingItem(versandArt));
}
const nIstReadOnly = resolveNIstReadOnly(order);
const nIstExterneRechnung = resolveNIstExterneRechnung(order);
const resultAuftrag = await new sql.Request(transaction) const resultAuftrag = await new sql.Request(transaction)
.input('cAuftragsNr', sql.NVarChar, cAuftragsNr) .input('cAuftragsNr', sql.NVarChar, cAuftragsNr)
.input('dErstellt', sql.DateTime, orderDate) .input('dErstellt', sql.DateTime, orderDate)
@@ -466,62 +701,67 @@ 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, 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, nIstExterneRechnung)
.input('nIstReadOnly', sql.Int, nIstReadOnly)
.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', @nIstReadOnly, 0, 10);
SELECT kAuftrag FROM @t; SELECT kAuftrag FROM @t;
`); `);
const kAuftrag = resultAuftrag.recordset[0].kAuftrag; const kAuftrag = resultAuftrag.recordset[0].kAuftrag;
const kPosAuftrag = Number.parseInt(order.externalId, 10); const kPosAuftrag = Number.parseInt(order.externalId, 10);
await insertPosOrderMapping(transaction, kAuftrag, kPosAuftrag); await upsertPosOrderMapping(transaction, kAuftrag, kPosAuftrag);
await insertOrderAddress(transaction, kAuftrag, kKunde, order.shippingAddress, 0); await insertOrderAddress(transaction, kAuftrag, kKunde, order.shippingAddress, 0);
await insertOrderAddress(transaction, kAuftrag, kKunde, order.billingAddress, 1); await insertOrderAddress(transaction, kAuftrag, kKunde, order.billingAddress, 1);
const deliveredItems = []; const deliveredItems = [];
for (const item of order.orderItems || []) { for (const item of orderItems) {
const kAuftragPosition = await insertOrderItem(transaction, kAuftrag, item); const kAuftragPosition = await insertOrderItem(transaction, kAuftrag, item);
const kPosAuftragPosition = Number.parseInt(item.externalId, 10); const kPosAuftragPosition = Number.parseInt(item.externalId, 10);
if (Number.isInteger(kPosAuftragPosition)) {
await insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition); await insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition);
if (kAuftragPosition != null) { }
if (kAuftragPosition != null && !isVersandposition(item)) {
deliveredItems.push({ kAuftragPosition, quantity: toNumber(item.quantity, 1) }); deliveredItems.push({ kAuftragPosition, quantity: toNumber(item.quantity, 1) });
} }
} }
for (const payment of order.payments || []) { for (const payment of order.payments || []) {
if (!isNewPayment(payment)) continue;
await insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache); await insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache);
} }
if (isOrderDelivered(order)) { if (isOrderDelivered(order)) {
await deliverOrder(transaction, config.kBenutzer, kAuftrag, defaults.kVersandArt, deliveredItems); await deliverOrder(transaction, config.kBenutzer, kAuftrag, kVersandArt, deliveredItems);
} }
await new sql.Request(transaction).input('kAuftrag', sql.Int, kAuftrag).query(` await new sql.Request(transaction).input('kAuftrag', sql.Int, kAuftrag).query(`
DECLARE @p1 Verkauf.TYPE_spAuftragEckdatenBerechnen; DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;
INSERT INTO @p1 VALUES (@kAuftrag); INSERT INTO @eckdaten_calc VALUES (@kAuftrag);
EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @p1; EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc;
`); `);
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;
}

View File

@@ -0,0 +1,23 @@
import sql from 'mssql';
import { getPool } from '../db.js';
import { getActiveShopSubshopId } from '../shop.js';
const MAX_ORDER_ID_SQL = `
SELECT ISNULL(MAX(kPosAuftrag), 0) AS MaxOrderId
FROM Pos.tAuftragMapping
WHERE kShopSubShop = @kShopSubShop;
`;
export async function getMaxOrderIdCount() {
const kShopSubShop = getActiveShopSubshopId();
if (!kShopSubShop) {
return 0;
}
const result = await getPool()
.request()
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(MAX_ORDER_ID_SQL);
return result.recordset[0]?.MaxOrderId ?? 0;
}