Compare commits
8 Commits
b7b76c9d39
...
72b74f5037
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b74f5037 | ||
|
|
1de4144ae2 | ||
|
|
056c9e18cd | ||
|
|
7fa839da4d | ||
|
|
ca7c4af973 | ||
|
|
2c76b401b5 | ||
|
|
262fa63f1b | ||
|
|
245fb047d7 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -3,4 +3,7 @@ node_modules/
|
||||
certs/
|
||||
logs/
|
||||
capturedDataReference
|
||||
decompiledReference
|
||||
decompiledReference
|
||||
scripts/s3-backup/data/
|
||||
scripts/s3-backup/tmp/
|
||||
scripts/s3-backup/certs/
|
||||
@@ -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.
|
||||
29
jtlsrv-cpp/.cursor/rules/no-background-servers.mdc
Normal file
29
jtlsrv-cpp/.cursor/rules/no-background-servers.mdc
Normal 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.
|
||||
@@ -1,4 +0,0 @@
|
||||
build/
|
||||
odbc-driver/
|
||||
logs/
|
||||
*.log
|
||||
3
jtlsrv-cpp/.gitignore
vendored
3
jtlsrv-cpp/.gitignore
vendored
@@ -5,9 +5,6 @@ build/
|
||||
jtlsrv
|
||||
jtlsrv-debug
|
||||
|
||||
# Compiled commands
|
||||
compile_commands.json
|
||||
|
||||
# ODBC driver (downloaded, not source)
|
||||
odbc-driver/
|
||||
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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
89
jtlsrv-cpp/Makefile
Normal 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
124
jtlsrv-cpp/README.md
Normal 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
|
||||
```
|
||||
@@ -3,7 +3,10 @@
|
||||
#include "../log.hpp"
|
||||
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
|
||||
static OdbcPool g_pool;
|
||||
|
||||
@@ -11,6 +14,35 @@ OdbcPool& get_pool() { return g_pool; }
|
||||
|
||||
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() {
|
||||
SQLRETURN rc;
|
||||
|
||||
@@ -31,7 +63,6 @@ int OdbcPool::connect() {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Connection string
|
||||
std::string conn_str =
|
||||
"DRIVER={ODBC Driver 18 for SQL Server};"
|
||||
"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());
|
||||
|
||||
// Create pool of 4 connections
|
||||
const int POOL_SIZE = 4;
|
||||
const int POOL_SIZE = config::get_int("MSSQL_POOL_SIZE", 1);
|
||||
conns_.resize(POOL_SIZE);
|
||||
int connected = 0;
|
||||
|
||||
@@ -58,14 +88,14 @@ int OdbcPool::connect() {
|
||||
|
||||
if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
|
||||
SQLAllocHandle(SQL_HANDLE_STMT, conns_[i].hdbc, &conns_[i].hstmt);
|
||||
SQLSetConnectAttr(conns_[i].hdbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0);
|
||||
connected++;
|
||||
} else {
|
||||
SQLCHAR state[6], msg[SQL_MAX_MESSAGE_LENGTH];
|
||||
SQLINTEGER native;
|
||||
SQLSMALLINT msg_len;
|
||||
SQLGetDiagRec(SQL_HANDLE_DBC, conns_[i].hdbc, 1,
|
||||
state, &native, msg, sizeof(msg), &msg_len);
|
||||
logc::warn("ODBC connect[%d] failed: %s - %s", i, state, msg);
|
||||
char ctx[32];
|
||||
std::snprintf(ctx, sizeof(ctx), "connect[%d]", i);
|
||||
odbc_log_diag(SQL_HANDLE_DBC, conns_[i].hdbc, ctx);
|
||||
SQLFreeHandle(SQL_HANDLE_DBC, conns_[i].hdbc);
|
||||
conns_[i].hdbc = SQL_NULL_HDBC;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,44 +117,52 @@ void OdbcPool::disconnect() {
|
||||
if (henv_ != SQL_NULL_HENV) { SQLFreeHandle(SQL_HANDLE_ENV, henv_); henv_ = SQL_NULL_HENV; }
|
||||
}
|
||||
|
||||
OdbcPool::Connection* OdbcPool::checkout() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
for (auto& c : conns_) {
|
||||
if (!c.in_use) { c.in_use = true; return &c; }
|
||||
OdbcPool::Connection* OdbcPool::checkout_raw() {
|
||||
for (int attempt = 0; attempt < 300; attempt++) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
for (auto& c : conns_) {
|
||||
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;
|
||||
}
|
||||
|
||||
OdbcPool::ConnGuard OdbcPool::checkout() {
|
||||
return ConnGuard(checkout_raw(), this);
|
||||
}
|
||||
|
||||
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_);
|
||||
c->in_use = false;
|
||||
}
|
||||
|
||||
bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params, ResultSet& out) {
|
||||
Connection* c = checkout();
|
||||
if (!c) return false;
|
||||
static bool bind_params(SQLHSTMT hstmt, const std::vector<Param>& params, std::vector<SQLLEN>& indicators) {
|
||||
indicators.assign(params.size(), 0);
|
||||
|
||||
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++) {
|
||||
const auto& p = params[i];
|
||||
SQLUSMALLINT param_num = static_cast<SQLUSMALLINT>(i + 1);
|
||||
SQLPOINTER val_ptr = nullptr;
|
||||
SQLPOINTER val_ptr = (SQLPOINTER)&null_placeholder;
|
||||
SQLLEN buf_len = 0;
|
||||
SQLLEN indicator = 0;
|
||||
SQLSMALLINT c_type = SQL_C_CHAR;
|
||||
SQLSMALLINT sql_type = SQL_VARCHAR;
|
||||
SQLULEN column_size = 1;
|
||||
|
||||
switch (p.type) {
|
||||
case ParamType::Int:
|
||||
@@ -132,12 +170,16 @@ bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params,
|
||||
sql_type = SQL_INTEGER;
|
||||
val_ptr = (SQLPOINTER)&p.int_val;
|
||||
buf_len = sizeof(SQLINTEGER);
|
||||
column_size = sizeof(SQLINTEGER);
|
||||
indicators[i] = buf_len;
|
||||
break;
|
||||
case ParamType::BigInt:
|
||||
c_type = SQL_C_SBIGINT;
|
||||
sql_type = SQL_BIGINT;
|
||||
val_ptr = (SQLPOINTER)&p.int_val;
|
||||
buf_len = sizeof(SQLBIGINT);
|
||||
column_size = sizeof(SQLBIGINT);
|
||||
indicators[i] = buf_len;
|
||||
break;
|
||||
case ParamType::Float:
|
||||
case ParamType::Double:
|
||||
@@ -145,128 +187,173 @@ bool OdbcPool::execute(const std::string& sql, const std::vector<Param>& params,
|
||||
sql_type = SQL_DOUBLE;
|
||||
val_ptr = (SQLPOINTER)&p.dbl_val;
|
||||
buf_len = sizeof(SQLDOUBLE);
|
||||
column_size = sizeof(SQLDOUBLE);
|
||||
indicators[i] = buf_len;
|
||||
break;
|
||||
case ParamType::NVarChar: {
|
||||
// Convert narrow string to SQLWCHAR (unsigned short = UTF-16)
|
||||
// SQLWCHAR is unsigned short (2 bytes) on this platform
|
||||
std::vector<SQLWCHAR> wbuf(p.str_val.size());
|
||||
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;
|
||||
case ParamType::NVarChar:
|
||||
case ParamType::DateTime:
|
||||
// UTF-8 SQL_C_CHAR + SQL_WVARCHAR works with msodbcsql18 for nvarchar columns.
|
||||
c_type = SQL_C_CHAR;
|
||||
sql_type = SQL_WVARCHAR;
|
||||
val_ptr = (SQLPOINTER)wb.data();
|
||||
buf_len = (SQLLEN)(wb.size() * sizeof(SQLWCHAR));
|
||||
indicator = wb.empty() ? SQL_NULL_DATA : (SQLLEN)(wb.size() * sizeof(SQLWCHAR));
|
||||
column_size = p.str_val.empty() ? 1 : p.str_val.size();
|
||||
if (!p.is_null) {
|
||||
val_ptr = (SQLPOINTER)p.str_val.c_str();
|
||||
buf_len = static_cast<SQLLEN>(p.str_val.size());
|
||||
indicators[i] = SQL_NTS;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ParamType::Bit:
|
||||
c_type = SQL_C_BIT;
|
||||
sql_type = SQL_BIT;
|
||||
val_ptr = (SQLPOINTER)&p.int_val;
|
||||
buf_len = 1;
|
||||
column_size = 1;
|
||||
indicators[i] = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
rc = SQLBindParameter(c->hstmt, param_num, SQL_PARAM_INPUT,
|
||||
c_type, sql_type, p.str_val.size() + 1, 0, val_ptr, buf_len, &indicator);
|
||||
if (p.is_null) {
|
||||
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) {
|
||||
release(c);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Execute
|
||||
rc = SQLExecute(c->hstmt);
|
||||
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO && rc != SQL_NO_DATA) {
|
||||
release(c);
|
||||
static bool fetch_results(SQLHSTMT hstmt, ResultSet& out) {
|
||||
out.clear();
|
||||
|
||||
while (true) {
|
||||
SQLSMALLINT col_count = 0;
|
||||
SQLNumResultCols(hstmt, &col_count);
|
||||
|
||||
if (col_count > 0) {
|
||||
std::vector<SQLSMALLINT> col_types(col_count);
|
||||
for (SQLSMALLINT col = 0; col < col_count; col++) {
|
||||
SQLSMALLINT data_type;
|
||||
SQLDescribeCol(hstmt, col + 1, nullptr, 0, nullptr, &data_type, nullptr, nullptr, nullptr);
|
||||
col_types[col] = data_type;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
SQLRETURN rc = SQLFetch(hstmt);
|
||||
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) break;
|
||||
|
||||
Row row;
|
||||
for (SQLSMALLINT col = 0; col < col_count; col++) {
|
||||
Cell cell;
|
||||
SQLLEN ind;
|
||||
SQLSMALLINT sql_type = col_types[col];
|
||||
|
||||
if (sql_type == SQL_BINARY || sql_type == SQL_VARBINARY ||
|
||||
sql_type == SQL_LONGVARBINARY) {
|
||||
std::vector<uint8_t> blob_data;
|
||||
unsigned char chunk[8192];
|
||||
while (true) {
|
||||
rc = SQLGetData(hstmt, col + 1, SQL_C_BINARY, chunk, sizeof(chunk), &ind);
|
||||
if (ind == SQL_NULL_DATA) { break; }
|
||||
if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
|
||||
SQLLEN copy_len = std::min(ind, (SQLLEN)sizeof(chunk));
|
||||
blob_data.insert(blob_data.end(), chunk, chunk + copy_len);
|
||||
}
|
||||
if (rc == SQL_SUCCESS) break;
|
||||
if (rc != SQL_SUCCESS_WITH_INFO) break;
|
||||
}
|
||||
if (!blob_data.empty()) {
|
||||
cell.type = CellType::Blob;
|
||||
cell.blob = std::move(blob_data);
|
||||
}
|
||||
} else {
|
||||
char buf[4096];
|
||||
rc = SQLGetData(hstmt, col + 1, SQL_C_CHAR, buf, sizeof(buf) - 1, &ind);
|
||||
if (ind == SQL_NULL_DATA) {
|
||||
cell.type = CellType::Null;
|
||||
} else if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
|
||||
cell.type = CellType::String;
|
||||
cell.str = std::string(buf, std::min((SQLLEN)(sizeof(buf)-1), ind));
|
||||
}
|
||||
}
|
||||
row.push_back(std::move(cell));
|
||||
}
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
if (!out.empty()) return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Fetch results if it's a SELECT
|
||||
out.clear();
|
||||
SQLSMALLINT col_count = 0;
|
||||
SQLNumResultCols(c->hstmt, &col_count);
|
||||
|
||||
if (col_count > 0) {
|
||||
// Discover column types
|
||||
std::vector<SQLSMALLINT> col_types(col_count);
|
||||
bool has_blobs = false;
|
||||
for (SQLSMALLINT col = 0; col < col_count; col++) {
|
||||
SQLSMALLINT data_type;
|
||||
SQLDescribeCol(c->hstmt, col + 1, nullptr, 0, nullptr, &data_type, nullptr, nullptr, nullptr);
|
||||
col_types[col] = data_type;
|
||||
if (data_type == SQL_BINARY || data_type == SQL_VARBINARY || data_type == SQL_LONGVARBINARY)
|
||||
has_blobs = true;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
rc = SQLFetch(c->hstmt);
|
||||
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) break;
|
||||
|
||||
Row row;
|
||||
for (SQLSMALLINT col = 0; col < col_count; col++) {
|
||||
Cell cell;
|
||||
SQLLEN ind;
|
||||
SQLSMALLINT sql_type = col_types[col];
|
||||
|
||||
// Binary columns: read as binary
|
||||
if (sql_type == SQL_BINARY || sql_type == SQL_VARBINARY ||
|
||||
sql_type == SQL_LONGVARBINARY) {
|
||||
std::vector<uint8_t> blob_data;
|
||||
unsigned char chunk[8192];
|
||||
int chunk_count = 0;
|
||||
while (true) {
|
||||
rc = SQLGetData(c->hstmt, col + 1, SQL_C_BINARY, chunk, sizeof(chunk), &ind);
|
||||
if (ind == SQL_NULL_DATA) { break; }
|
||||
if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
|
||||
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);
|
||||
chunk_count++;
|
||||
}
|
||||
if (rc == SQL_SUCCESS) break;
|
||||
if (rc != SQL_SUCCESS_WITH_INFO) break;
|
||||
}
|
||||
if (chunk_count > 0) {
|
||||
cell.type = CellType::Blob;
|
||||
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 {
|
||||
// String/numeric columns: read as char
|
||||
char buf[4096];
|
||||
rc = SQLGetData(c->hstmt, col + 1, SQL_C_CHAR, buf, sizeof(buf) - 1, &ind);
|
||||
if (ind == SQL_NULL_DATA) {
|
||||
cell.type = CellType::Null;
|
||||
} else if (rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO) {
|
||||
cell.type = CellType::String;
|
||||
cell.str = std::string(buf, std::min((SQLLEN)(sizeof(buf)-1), ind));
|
||||
}
|
||||
}
|
||||
row.push_back(std::move(cell));
|
||||
}
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Reset statement for reuse
|
||||
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_CLOSE);
|
||||
release(c);
|
||||
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) {
|
||||
return execute(sql, {}, out);
|
||||
}
|
||||
|
||||
int64_t OdbcPool::execute_scalar(const std::string& sql, const std::vector<Param>& params, int64_t fallback) {
|
||||
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];
|
||||
if (cell.type == CellType::Int64) return cell.i64;
|
||||
if (cell.type == CellType::String) {
|
||||
@@ -274,3 +361,42 @@ int64_t OdbcPool::execute_scalar(const std::string& sql, const std::vector<Param
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -21,35 +21,78 @@ struct Cell {
|
||||
using Row = std::vector<Cell>;
|
||||
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 {
|
||||
ParamType type;
|
||||
std::string str_val;
|
||||
int64_t int_val = 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 {
|
||||
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 {
|
||||
SQLHDBC hdbc = SQL_NULL_HDBC;
|
||||
SQLHSTMT hstmt = SQL_NULL_HSTMT;
|
||||
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;
|
||||
std::vector<Connection> conns_;
|
||||
std::mutex mutex_;
|
||||
Connection* checkout();
|
||||
void release(Connection* c);
|
||||
std::string last_error_;
|
||||
Connection* checkout_raw();
|
||||
};
|
||||
|
||||
OdbcPool& get_pool();
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
#include "../router.hpp"
|
||||
#include "../queries/category_list.hpp"
|
||||
|
||||
void handle_category(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
|
||||
int64_t cursor = std::stoll(req.get_query_param("lastChangedCategory", "0"));
|
||||
int limit = std::stoi(req.get_query_param("limit", "20"));
|
||||
void handle_category(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
|
||||
int64_t cursor = req.get_query_int64("lastChangedCategory");
|
||||
int limit = req.get_query_int("limit", 20);
|
||||
auto categories = get_category_list(cursor, limit);
|
||||
resp.send_json(200, categories);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include "../log.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");
|
||||
if (path.empty()) {
|
||||
resp.send_json(400, {{"Message", "Missing required query parameter 'path'."}});
|
||||
|
||||
@@ -6,16 +6,27 @@
|
||||
#include "../tls_server.hpp"
|
||||
#include "../router.hpp"
|
||||
|
||||
static std::string config_string(const json& config, const char* key,
|
||||
const std::string& def = "") {
|
||||
if (!config.is_object()) return def;
|
||||
json v = config.value(key, json());
|
||||
if (v.is_null()) return def;
|
||||
if (v.is_string()) return v.get<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) {
|
||||
return {
|
||||
{"authCode", nullptr},
|
||||
{"authToken", config["authToken"]},
|
||||
{"certificateFingerprint", config["certificateFingerprint"]},
|
||||
{"certificateSerialNumber", config["certificateSerialNumber"]},
|
||||
{"mandantId", config["mandantId"]},
|
||||
{"authToken", config_string(config, "authToken")},
|
||||
{"certificateFingerprint", config_string(config, "certificateFingerprint")},
|
||||
{"certificateSerialNumber", config_string(config, "certificateSerialNumber")},
|
||||
{"mandantId", config_string(config, "mandantId")},
|
||||
{"mandantName", nullptr},
|
||||
{"mandantDatabase", nullptr},
|
||||
{"serverFingerprint", config["serverFingerprint"]},
|
||||
{"serverFingerprint", config_string(config, "serverFingerprint")},
|
||||
{"name", nullptr},
|
||||
{"serverTimestamp", server_timestamp()},
|
||||
};
|
||||
@@ -24,12 +35,12 @@ static json build_client_step1(const json& config) {
|
||||
static json build_client_step2(const std::string& auth_code, const json& config) {
|
||||
return {
|
||||
{"authCode", auth_code},
|
||||
{"authToken", config["authToken"]},
|
||||
{"certificateFingerprint", config["certificateFingerprint"]},
|
||||
{"certificateSerialNumber", config["certificateSerialNumber"]},
|
||||
{"mandantId", config["mandantId"]},
|
||||
{"mandantName", config["mandantName"]},
|
||||
{"mandantDatabase", config["mandantDatabase"]},
|
||||
{"authToken", config_string(config, "authToken")},
|
||||
{"certificateFingerprint", config_string(config, "certificateFingerprint")},
|
||||
{"certificateSerialNumber", config_string(config, "certificateSerialNumber")},
|
||||
{"mandantId", config_string(config, "mandantId")},
|
||||
{"mandantName", config_string(config, "mandantName")},
|
||||
{"mandantDatabase", config_string(config, "mandantDatabase")},
|
||||
{"serverFingerprint", nullptr},
|
||||
{"name", nullptr},
|
||||
{"serverTimestamp", server_timestamp()},
|
||||
@@ -47,7 +58,7 @@ void handle_client(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
|
||||
if (auth_code.size() == 6) {
|
||||
if (ctx.pairing_store->has_pairing_code(auth_code)) {
|
||||
ctx.pairing_store->revoke_pairing_code(auth_code);
|
||||
ctx.pairing_store->register_device(ctx.config["authToken"].get<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(400, {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
#include "../router.hpp"
|
||||
#include "../queries/customer_groups.hpp"
|
||||
|
||||
void handle_customergroup(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
|
||||
int64_t cursor = std::stoll(req.get_query_param("lastChangedCustomerGroup", "0"));
|
||||
void handle_customergroup(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
|
||||
int64_t cursor = req.get_query_int64("lastChangedCustomerGroup");
|
||||
auto groups = get_customer_group_list(cursor);
|
||||
resp.send_json(200, groups);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
#include "../router.hpp"
|
||||
#include "../queries/deleted_entity_list.hpp"
|
||||
|
||||
void handle_deleted_entity(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
|
||||
int64_t cursor = std::stoll(req.get_query_param("lastChangedDeletedEntity", "0"));
|
||||
int limit = std::stoi(req.get_query_param("limit", "600"));
|
||||
void handle_deleted_entity(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
|
||||
int64_t cursor = req.get_query_int64("lastChangedDeletedEntity");
|
||||
int limit = req.get_query_int("limit", 600);
|
||||
auto deleted = get_deleted_entity_list(cursor, limit);
|
||||
resp.send_json(200, deleted);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
#include "../http.hpp"
|
||||
#include "../tls_server.hpp"
|
||||
#include "../router.hpp"
|
||||
#include "../log.hpp"
|
||||
#include "../queries/counts.hpp"
|
||||
#include "../queries/customer_groups.hpp"
|
||||
#include "../queries/shop.hpp"
|
||||
#include "../config.hpp"
|
||||
|
||||
void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
|
||||
int64_t product_cursor = std::stoll(req.get_query_param("lastChangedProduct", "0"));
|
||||
int64_t category_cursor = std::stoll(req.get_query_param("lastChangedCategory", "0"));
|
||||
int64_t cg_cursor = std::stoll(req.get_query_param("lastChangedCustomerGroup", "0"));
|
||||
int64_t composite_cursor = std::stoll(req.get_query_param("lastChangedCompositeProduct", "0"));
|
||||
int64_t deleted_cursor = std::stoll(req.get_query_param("lastChangedDeletedEntity", "0"));
|
||||
void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
|
||||
int64_t product_cursor = req.get_query_int64("lastChangedProduct");
|
||||
int64_t category_cursor = req.get_query_int64("lastChangedCategory");
|
||||
int64_t cg_cursor = req.get_query_int64("lastChangedCustomerGroup");
|
||||
int64_t composite_cursor = req.get_query_int64("lastChangedCompositeProduct");
|
||||
int64_t deleted_cursor = req.get_query_int64("lastChangedDeletedEntity");
|
||||
|
||||
int root = config::get_int("ROOT_CATEGORY_ID", 1);
|
||||
int shop = get_active_shop_id();
|
||||
|
||||
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) {
|
||||
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);
|
||||
composite_count = get_composite_count(shop, composite_cursor);
|
||||
deleted_count = get_deleted_count(deleted_cursor);
|
||||
max_order_id_count = get_max_order_id_count(get_active_shop_subshop_id());
|
||||
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, {
|
||||
@@ -36,6 +45,6 @@ void handle_init(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
|
||||
{"configurationGroup_count", "0"},
|
||||
{"configurationItem_count", "0"},
|
||||
{"deletedEntity_count", std::to_string(deleted_count)},
|
||||
{"max_orderId_count", "0"}
|
||||
{"max_orderId_count", std::to_string(max_order_id_count)}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 "../tls_server.hpp"
|
||||
#include "../router.hpp"
|
||||
#include "../log.hpp"
|
||||
#include "../order_log.hpp"
|
||||
#include "../queries/create_order.hpp"
|
||||
|
||||
void handle_order(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
|
||||
// TODO: full order creation (Milestone 5/6)
|
||||
// For now, parse the JSON body and acknowledge
|
||||
#include <string>
|
||||
|
||||
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;
|
||||
try {
|
||||
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();
|
||||
if (body.contains("orders") && body["orders"].is_array()) {
|
||||
for (auto& order : body["orders"]) {
|
||||
std::string ext_id = order.value("externalId", "");
|
||||
nlohmann::json orders = get_orders(body);
|
||||
|
||||
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({
|
||||
{"status", "OK"},
|
||||
{"externalOrderId", externalOrderId},
|
||||
{"message", ""}
|
||||
});
|
||||
++successful;
|
||||
}
|
||||
} catch (const std::exception& ex) {
|
||||
logc::error("order externalId=%s failed: %s", externalOrderId.c_str(), ex.what());
|
||||
results.push_back({
|
||||
{"status", "OK"},
|
||||
{"externalOrderId", ext_id},
|
||||
{"message", ""}
|
||||
{"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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include "../log.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");
|
||||
if (path.empty()) {
|
||||
resp.send_json(400, {{"Message", "Missing required query parameter 'path'."}});
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
#include "../router.hpp"
|
||||
#include "../queries/product_list.hpp"
|
||||
|
||||
void handle_product(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
|
||||
int64_t cursor = std::stoll(req.get_query_param("lastChangedProduct", "0"));
|
||||
int limit = std::stoi(req.get_query_param("limit", "20"));
|
||||
void handle_product(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
|
||||
int64_t cursor = req.get_query_int64("lastChangedProduct");
|
||||
int limit = req.get_query_int("limit", 20);
|
||||
auto products = get_product_list(cursor, limit);
|
||||
resp.send_json(200, products);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
#include "../router.hpp"
|
||||
#include "../queries/composite_product_list.hpp"
|
||||
|
||||
void handle_productcomposite(HttpRequest& req, HttpResponse& resp, RouteContext& ctx) {
|
||||
int64_t cursor = std::stoll(req.get_query_param("lastChangedCompositeProduct", "0"));
|
||||
int limit = std::stoi(req.get_query_param("limit", "100"));
|
||||
void handle_productcomposite(HttpRequest& req, HttpResponse& resp, RouteContext& /*ctx*/) {
|
||||
int64_t cursor = req.get_query_int64("lastChangedCompositeProduct");
|
||||
int limit = req.get_query_int("limit", 100);
|
||||
auto composites = get_composite_product_list(cursor, limit);
|
||||
resp.send_json(200, composites);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,25 @@
|
||||
#include <cstring>
|
||||
#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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -39,6 +58,14 @@ std::string HttpRequest::get_query_param(const std::string& key, const std::stri
|
||||
return decoded;
|
||||
}
|
||||
|
||||
int HttpRequest::get_query_int(const std::string& key, int def) const {
|
||||
return parse_int(get_query_param(key), def);
|
||||
}
|
||||
|
||||
int64_t HttpRequest::get_query_int64(const std::string& key, int64_t def) const {
|
||||
return parse_int64(get_query_param(key), def);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HttpResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -17,6 +17,11 @@ struct tls_session;
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
// Safe string-to-number parsing (never throws).
|
||||
int parse_int(const std::string& s, int fallback = 0);
|
||||
int64_t parse_int64(const std::string& s, int64_t fallback = 0);
|
||||
double parse_double(const std::string& s, double fallback = 0.0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsed HTTP request
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -32,6 +37,8 @@ struct HttpRequest {
|
||||
std::string query_string; // "authCode=xxx&name=yyy"
|
||||
|
||||
std::string get_query_param(const std::string& key, const std::string& def = "") const;
|
||||
int get_query_int(const std::string& key, int def = 0) const;
|
||||
int64_t get_query_int64(const std::string& key, int64_t def = 0) const;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
#include <vips/vips.h>
|
||||
#include "queries/shop.hpp"
|
||||
#include "queries/customer_groups.hpp"
|
||||
#include "request_log.hpp"
|
||||
#include "order_log.hpp"
|
||||
|
||||
@@ -27,7 +28,6 @@ static uv_loop_t* loop = nullptr;
|
||||
static Router router;
|
||||
static PairingStore pairing_store;
|
||||
static RequestLog request_log;
|
||||
static OrderLog order_log;
|
||||
|
||||
static json build_config() {
|
||||
return {
|
||||
@@ -82,26 +82,36 @@ static void handle_request(tls_session* sess) {
|
||||
}
|
||||
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
|
||||
const char* ip = sess->peer_ip.c_str();
|
||||
if (resp_size > 0) {
|
||||
std::string preview = resp_body.substr(0, std::min(resp_size, (size_t)200));
|
||||
logc::info("127.0.0.1 %s %s %d %dms [%zu bytes] %s",
|
||||
req.method.c_str(), url.c_str(), resp.status_code,
|
||||
std::string preview = resp_body.substr(0, std::min(resp_size, (size_t)220));
|
||||
logc::info("%s %s %s %d %dms [%zu bytes] %s",
|
||||
ip, req.method.c_str(), url.c_str(), resp.status_code,
|
||||
(int)elapsed, resp_size, preview.c_str());
|
||||
} else {
|
||||
logc::info("127.0.0.1 %s %s %d %dms",
|
||||
req.method.c_str(), url.c_str(), resp.status_code, (int)elapsed);
|
||||
logc::info("%s %s %s %d %dms",
|
||||
ip, req.method.c_str(), url.c_str(), resp.status_code, (int)elapsed);
|
||||
}
|
||||
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
int main(int /*argc*/, char* argv[]) {
|
||||
config::load(".env");
|
||||
|
||||
if (VIPS_INIT(argv[0])) {
|
||||
@@ -109,6 +119,7 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
|
||||
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 key_path = "certs/key.pem";
|
||||
|
||||
@@ -130,7 +141,7 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
// Initialize pairing store
|
||||
pairing_store.set_pairing_code(config::get("PAIRING_CODE", "307018"), "JTL-POS");
|
||||
pairing_store.register_device(server_config["authToken"].get<std::string>(), "JTL-POS");
|
||||
pairing_store.register_device(server_config.value("authToken", std::string("df40ad2067954646abb0499548a52241")), "JTL-POS");
|
||||
|
||||
// Connect to MSSQL
|
||||
if (get_pool().connect() == 0) {
|
||||
@@ -139,7 +150,10 @@ int main(int argc, char* argv[]) {
|
||||
config::get("MSSQL_DATABASE").c_str());
|
||||
if (fetch_active_shop()) {
|
||||
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 {
|
||||
logc::warn("MSSQL connection skipped");
|
||||
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
|
||||
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());
|
||||
if (r != 0) {
|
||||
logc::error("failed to start TLS server");
|
||||
@@ -165,7 +179,7 @@ int main(int argc, char* argv[]) {
|
||||
uv_run(loop, UV_RUN_DEFAULT);
|
||||
|
||||
request_log.close();
|
||||
order_log.close();
|
||||
g_order_log.close();
|
||||
get_pool().disconnect();
|
||||
vips_shutdown();
|
||||
logc::info("shutdown complete.");
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <ctime>
|
||||
#include <sys/stat.h>
|
||||
|
||||
OrderLog g_order_log;
|
||||
|
||||
static void ensure_parent_dir(const std::string& path) {
|
||||
size_t pos = path.rfind('/');
|
||||
if (pos != std::string::npos) {
|
||||
@@ -19,10 +21,9 @@ void OrderLog::close() {
|
||||
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_);
|
||||
sequence_++;
|
||||
max_external_id_++;
|
||||
|
||||
if (fp_) {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
@@ -32,13 +33,8 @@ int OrderLog::log_order(const std::string& order_json) {
|
||||
char ts[32];
|
||||
std::strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%S", &tm_buf);
|
||||
|
||||
std::fprintf(fp_, "%s #%d externalId=%d %s\n",
|
||||
ts, sequence_, max_external_id_, order_json.c_str());
|
||||
std::fprintf(fp_, "%s #%d externalId=%s %s\n",
|
||||
ts, sequence_, external_id.c_str(), order_json.c_str());
|
||||
std::fflush(fp_);
|
||||
}
|
||||
return max_external_id_;
|
||||
}
|
||||
|
||||
std::string OrderLog::get_max_external_id() const {
|
||||
return std::to_string(max_external_id_);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ class OrderLog {
|
||||
public:
|
||||
void open(const std::string& path);
|
||||
void close();
|
||||
int log_order(const std::string& order_json);
|
||||
std::string get_max_external_id() const;
|
||||
void log_order(const std::string& order_json, const std::string& external_id = "");
|
||||
private:
|
||||
FILE* fp_ = nullptr;
|
||||
std::mutex mutex_;
|
||||
int sequence_ = 0;
|
||||
int max_external_id_ = 0;
|
||||
};
|
||||
|
||||
extern OrderLog g_order_log;
|
||||
|
||||
@@ -41,7 +41,8 @@ inline nlohmann::json get_category_list(int64_t cursor, int limit) {
|
||||
auto ts = server_timestamp();
|
||||
nlohmann::json result = nlohmann::json::array();
|
||||
for (auto& row : rs) {
|
||||
std::string pid = (std::stoll(row[1].str) == root) ? "0" : row[1].str;
|
||||
int64_t parent_id = parse_int64(row[1].str, 0);
|
||||
std::string pid = (parent_id == root) ? "0" : row[1].str;
|
||||
result.push_back({
|
||||
{"_id", row[0].str},
|
||||
{"imghash", row[4].str.empty() ? nullptr : nlohmann::json(row[4].str)},
|
||||
|
||||
35
jtlsrv-cpp/src/queries/commit.hpp
Normal file
35
jtlsrv-cpp/src/queries/commit.hpp
Normal 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
|
||||
@@ -43,23 +43,32 @@ static const char* DELETED_ENTITY_COUNT_SQL =
|
||||
"SELECT COUNT(*) AS cnt FROM Pos.vDeletedEntity "
|
||||
"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) {
|
||||
return get_pool().execute_scalar(CATEGORY_COUNT_SQL,
|
||||
{{ParamType::BigInt,"",root_cat},{ParamType::BigInt,"",k_shop},
|
||||
{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",cursor}});
|
||||
{{ParamType::BigInt,"",root_cat},{ParamType::Int,"",k_shop},
|
||||
{ParamType::Int,"",k_shop},{ParamType::BigInt,"",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,
|
||||
{{ParamType::BigInt,"",root_cat},{ParamType::BigInt,"",k_shop},
|
||||
{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",cursor},
|
||||
{ParamType::BigInt,"",k_shop},{ParamType::BigInt,"",cursor}});
|
||||
{{ParamType::BigInt,"",root_cat},{ParamType::Int,"",k_shop},
|
||||
{ParamType::Int,"",k_shop},{ParamType::BigInt,"",cursor},
|
||||
{ParamType::Int,"",k_shop},{ParamType::BigInt,"",cursor}});
|
||||
}
|
||||
inline int64_t get_composite_count(int k_shop, int64_t cursor) {
|
||||
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}});
|
||||
}
|
||||
inline int64_t get_deleted_count(int64_t cursor) {
|
||||
return get_pool().execute_scalar(DELETED_ENTITY_COUNT_SQL,
|
||||
{{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}});
|
||||
}
|
||||
|
||||
966
jtlsrv-cpp/src/queries/create_order.hpp
Normal file
966
jtlsrv-cpp/src/queries/create_order.hpp
Normal 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
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
#include "../db/pool.hpp"
|
||||
#include "../log.hpp"
|
||||
#include "../http.hpp"
|
||||
#include "nlohmann/json.hpp"
|
||||
|
||||
static const char* CUSTOMER_GROUP_IDS_SQL =
|
||||
@@ -16,13 +18,20 @@ static const char* CUSTOMER_GROUP_COUNT_SQL =
|
||||
"WHERE CONVERT(BIGINT, bRowversion) > ?";
|
||||
|
||||
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;
|
||||
get_pool().execute(CUSTOMER_GROUP_IDS_SQL, rs);
|
||||
std::vector<int64_t> ids;
|
||||
for (auto& row : rs) {
|
||||
ids.push_back(std::stoll(row[0].str));
|
||||
if (!get_pool().execute(CUSTOMER_GROUP_IDS_SQL, rs)) {
|
||||
logc::warn("failed to load customer group ids");
|
||||
return {};
|
||||
}
|
||||
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) {
|
||||
@@ -35,7 +44,7 @@ inline nlohmann::json get_customer_group_list(int64_t cursor = 0) {
|
||||
{"customerGroupId", row[0].str},
|
||||
{"name", row[1].str},
|
||||
{"standard", row[2].str},
|
||||
{"discountPercent", std::to_string(std::stod(row[3].str))},
|
||||
{"discountPercent", std::to_string(parse_double(row[3].str, 0))},
|
||||
{"lastChanged", row[4].str}
|
||||
});
|
||||
}
|
||||
|
||||
42
jtlsrv-cpp/src/queries/deliver.hpp
Normal file
42
jtlsrv-cpp/src/queries/deliver.hpp
Normal 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
|
||||
37
jtlsrv-cpp/src/queries/delivery.hpp
Normal file
37
jtlsrv-cpp/src/queries/delivery.hpp
Normal 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
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "../db/pool.hpp"
|
||||
#include "../log.hpp"
|
||||
#include "../http.hpp"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
@@ -83,10 +84,10 @@ inline ImageResult get_image_by_hash(const std::string& hash, const std::string&
|
||||
|
||||
auto& row = rs[0];
|
||||
|
||||
int target = size.empty() ? 200 : std::stoi(size);
|
||||
int target = parse_int(size, 200);
|
||||
std::string ct = content_type_for(row[6].str);
|
||||
int preview_w = row[4].str.empty() ? 0 : std::stoi(row[4].str);
|
||||
int preview_h = row[5].str.empty() ? 0 : std::stoi(row[5].str);
|
||||
int preview_w = row[4].str.empty() ? 0 : parse_int(row[4].str, 0);
|
||||
int preview_h = row[5].str.empty() ? 0 : parse_int(row[5].str, 0);
|
||||
int preview_max = std::max(preview_w, preview_h);
|
||||
|
||||
bool has_full = !row[0].blob.empty();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
#include "../log.hpp"
|
||||
#include "../db/pool.hpp"
|
||||
#include "nlohmann/json.hpp"
|
||||
#include "shop.hpp"
|
||||
@@ -41,16 +42,9 @@ static const char* PRODUCT_LIST_SQL =
|
||||
"OR (ir.maxImageRV IS NOT NULL AND ir.maxImageRV > ?)) "
|
||||
"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) {
|
||||
double n = std::stod(net.empty() ? "0" : net);
|
||||
double t = std::stod(tax.empty() ? "0" : tax);
|
||||
double n = parse_double(net, 0);
|
||||
double t = parse_double(tax, 0);
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof(buf), "%.2f", n * (1.0 + t / 100.0));
|
||||
return buf;
|
||||
@@ -72,7 +66,11 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
|
||||
{ParamType::BigInt,"",cursor}
|
||||
};
|
||||
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();
|
||||
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)},
|
||||
{"sku", row[1].str},
|
||||
{"name", row[2].str},
|
||||
{"tax_rate", std::to_string((int)std::round(std::stod(row[4].str.empty()?"0":row[4].str)))},
|
||||
{"tax_rate", std::to_string((int)std::round(parse_double(row[4].str, 0)))},
|
||||
{"price", base_price},
|
||||
{"created_at", row[5].str},
|
||||
{"lastChanged", row[6].str},
|
||||
@@ -117,7 +115,7 @@ inline nlohmann::json get_product_list(int64_t cursor, int limit) {
|
||||
{"categories", cats},
|
||||
{"prices", prices},
|
||||
{"is_parent", row[9].str == "1" ? "1" : "0"},
|
||||
{"parent", std::stoll(row[10].str) > 0 ? row[10].str : "0"},
|
||||
{"parent", parse_int64(row[10].str, 0) > 0 ? row[10].str : "0"},
|
||||
{"variants", row[12].str},
|
||||
{"isCompositeProduct", row[11].str},
|
||||
{"attributes", nlohmann::json::array()},
|
||||
|
||||
60
jtlsrv-cpp/src/queries/reserve.hpp
Normal file
60
jtlsrv-cpp/src/queries/reserve.hpp
Normal 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
|
||||
42
jtlsrv-cpp/src/queries/session.hpp
Normal file
42
jtlsrv-cpp/src/queries/session.hpp
Normal 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
|
||||
28
jtlsrv-cpp/src/queries/shop.cpp
Normal file
28
jtlsrv-cpp/src/queries/shop.cpp
Normal 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;
|
||||
}
|
||||
@@ -1,21 +1,10 @@
|
||||
#pragma once
|
||||
#include "../db/pool.hpp"
|
||||
#include "../config.hpp"
|
||||
|
||||
static int g_active_shop_id = 0;
|
||||
static int g_active_shop_subshop_id = 0;
|
||||
extern int g_active_shop_id;
|
||||
extern int g_active_shop_subshop_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 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;
|
||||
}
|
||||
bool fetch_active_shop();
|
||||
|
||||
137
jtlsrv-cpp/src/queries/stock_shortage.hpp
Normal file
137
jtlsrv-cpp/src/queries/stock_shortage.hpp
Normal file
@@ -0,0 +1,137 @@
|
||||
#pragma once
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "reserve.hpp"
|
||||
#include "warehouse.hpp"
|
||||
#include "../db/pool.hpp"
|
||||
#include "../log.hpp"
|
||||
|
||||
namespace delivery {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* POS_SHORTAGE_COMMENT = "Korrekturbuchung erstellt durch POS-Abgleich";
|
||||
constexpr int BUCHUNGSART_WARENEINGANG = 10;
|
||||
|
||||
inline double get_reserved_quantity(OdbcPool::Connection* c, int kSessionId, int kAuftragPosition) {
|
||||
const char* sql =
|
||||
"SELECT ISNULL(SUM(pp.fAnzahl), 0) FROM dbo.tPicklistePos pp "
|
||||
"INNER JOIN dbo.tPickliste p ON p.kPickliste = pp.kPickliste "
|
||||
"WHERE p.kSessionId = ? AND pp.kBestellPos = ?";
|
||||
|
||||
std::vector<Param> ps = {
|
||||
{ParamType::Int, "", kSessionId},
|
||||
{ParamType::Int, "", kAuftragPosition},
|
||||
};
|
||||
ResultSet rs;
|
||||
if (!get_pool().execute(c, sql, ps, rs) || rs.empty() || rs[0].empty()) {
|
||||
return 0.0;
|
||||
}
|
||||
try {
|
||||
return std::stod(rs[0][0].str);
|
||||
} catch (...) {
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
inline int get_position_artikel(OdbcPool::Connection* c, int kAuftragPosition) {
|
||||
std::vector<Param> ps = {{ParamType::Int, "", kAuftragPosition}};
|
||||
ResultSet rs;
|
||||
if (!get_pool().execute(c,
|
||||
"SELECT kArtikel FROM Verkauf.tAuftragPosition WHERE kAuftragPosition = ?",
|
||||
ps, rs) || rs.empty() || rs[0].empty() || rs[0][0].type == CellType::Null) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return std::stoi(rs[0][0].str);
|
||||
} catch (...) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
inline void book_wareneingang(OdbcPool::Connection* c, int kBenutzer,
|
||||
int kWarenLagerPlatz, int kArtikel, double fehlmenge) {
|
||||
const char* sql =
|
||||
"DECLARE @kWarenlagerEingang INT;"
|
||||
"EXEC dbo.spWarenlagerEingangSchreiben"
|
||||
" @kArtikel = ?,"
|
||||
" @kWarenLagerPlatz = ?,"
|
||||
" @kLieferantenBestellungPos = 0,"
|
||||
" @kBenutzer = ?,"
|
||||
" @fAnzahl = ?,"
|
||||
" @fEkEinzel = 0,"
|
||||
" @cLieferscheinNr = '',"
|
||||
" @cChargenNr = NULL,"
|
||||
" @dMHD = NULL,"
|
||||
" @dGeliefertAm = NULL,"
|
||||
" @cKommentar = ?,"
|
||||
" @kGutschriftPos = 0,"
|
||||
" @kLHM = 0,"
|
||||
" @kSessionId = 0,"
|
||||
" @kBuchungsart = ?,"
|
||||
" @kBestellPosUmlagerung = 0,"
|
||||
" @kRMRetourePos = 0,"
|
||||
" @nHistorieNichtSchreiben = 0,"
|
||||
" @kWarenlagerEingang = @kWarenlagerEingang OUTPUT;"
|
||||
"SELECT @kWarenlagerEingang AS kWarenlagerEingang;";
|
||||
|
||||
std::vector<Param> ps = {
|
||||
{ParamType::Int, "", kArtikel},
|
||||
{ParamType::Int, "", kWarenLagerPlatz},
|
||||
{ParamType::Int, "", kBenutzer},
|
||||
{ParamType::Double, "", 0, fehlmenge},
|
||||
{ParamType::NVarChar, POS_SHORTAGE_COMMENT, 0},
|
||||
{ParamType::Int, "", BUCHUNGSART_WARENEINGANG},
|
||||
};
|
||||
ResultSet rs;
|
||||
if (!get_pool().execute(c, sql, ps, rs)) {
|
||||
const std::string& detail = get_pool().last_error();
|
||||
throw std::runtime_error(detail.empty()
|
||||
? "spWarenlagerEingangSchreiben failed"
|
||||
: "spWarenlagerEingangSchreiben failed: " + detail);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// PosStockPositionService.FehlbestandEinbuchen — book missing stock, then re-reserve.
|
||||
inline void book_stock_shortfalls_and_rereserve(OdbcPool::Connection* c, int kBenutzer,
|
||||
int kSessionId, int kWarenLager,
|
||||
int kWarenLagerPlatz,
|
||||
const std::vector<DeliveredItem>& items) {
|
||||
std::vector<DeliveredItem> rereserve;
|
||||
|
||||
for (const auto& item : items) {
|
||||
if (item.kAuftragPosition <= 0 || item.quantity <= 0.0) continue;
|
||||
|
||||
double reserved = get_reserved_quantity(c, kSessionId, item.kAuftragPosition);
|
||||
double shortage = item.quantity - reserved;
|
||||
if (shortage <= 0.0001) continue;
|
||||
|
||||
int kArtikel = get_position_artikel(c, item.kAuftragPosition);
|
||||
if (kArtikel <= 0) continue;
|
||||
|
||||
logc::info("POS shortage booking: kBestellPos=%d kArtikel=%d reserved=%.4f need=%.4f book=%.4f",
|
||||
item.kAuftragPosition, kArtikel, reserved, item.quantity, shortage);
|
||||
book_wareneingang(c, kBenutzer, kWarenLagerPlatz, kArtikel, shortage);
|
||||
rereserve.push_back({item.kAuftragPosition, shortage});
|
||||
}
|
||||
|
||||
if (!rereserve.empty()) {
|
||||
reserve_positions(c, kBenutzer, kSessionId, kWarenLager, rereserve);
|
||||
}
|
||||
|
||||
for (const auto& item : items) {
|
||||
if (item.kAuftragPosition <= 0 || item.quantity <= 0.0) continue;
|
||||
double reserved = get_reserved_quantity(c, kSessionId, item.kAuftragPosition);
|
||||
if (reserved + 0.0001 < item.quantity) {
|
||||
throw std::runtime_error(
|
||||
"insufficient stock after POS shortage booking for kBestellPos="
|
||||
+ std::to_string(item.kAuftragPosition));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace delivery
|
||||
37
jtlsrv-cpp/src/queries/warehouse.hpp
Normal file
37
jtlsrv-cpp/src/queries/warehouse.hpp
Normal 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
|
||||
40
jtlsrv-cpp/src/queries/xml.hpp
Normal file
40
jtlsrv-cpp/src/queries/xml.hpp
Normal 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 += "<"; break;
|
||||
case '>': r += ">"; break;
|
||||
case '&': r += "&"; break;
|
||||
case '\'': r += "'"; break;
|
||||
case '"': r += """; 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
|
||||
@@ -1,8 +1,7 @@
|
||||
#include "router.hpp"
|
||||
#include "log.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <algorithm>
|
||||
#include <exception>
|
||||
|
||||
void Router::add_route(const std::string& method, const std::string& path, Handler handler) {
|
||||
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.pairing_store = &pairing;
|
||||
ctx.config = config;
|
||||
it->second(req, resp, ctx);
|
||||
try {
|
||||
it->second(req, resp, ctx);
|
||||
} catch (const std::exception& ex) {
|
||||
logc::error("request handler error: %s %s — %s",
|
||||
req.method.c_str(), full_url.c_str(), ex.what());
|
||||
if (!resp.headers_sent) {
|
||||
resp.send_json(500, {{"Message", "Internal server error"}});
|
||||
}
|
||||
} catch (...) {
|
||||
logc::error("request handler error: %s %s — unknown exception",
|
||||
req.method.c_str(), full_url.c_str());
|
||||
if (!resp.headers_sent) {
|
||||
resp.send_json(500, {{"Message", "Internal server error"}});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// within 30s window. Port of server.js shouldLogInit().
|
||||
bool Router::should_log_init(const std::string& url, int status, int duration_ms,
|
||||
const std::string& response) {
|
||||
// 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_++;
|
||||
// Suppress consecutive identical /v1/init URLs; report count on non-init logs.
|
||||
bool Router::should_log_init(const std::string& url) {
|
||||
if (last_logged_init_url_ == url) {
|
||||
suppressed_init_count_++;
|
||||
return false;
|
||||
}
|
||||
|
||||
last_init_url_ = url;
|
||||
last_init_result_ = response;
|
||||
last_init_timestamp_ = now;
|
||||
last_logged_init_url_ = url;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -19,17 +19,16 @@ public:
|
||||
// Dispatch a request. Called from the TLS on_request callback.
|
||||
void dispatch(tls_session* sess, PairingStore& pairing, const json& config);
|
||||
|
||||
// Suppress init logs for repeated identical requests within 30s
|
||||
bool should_log_init(const std::string& url, int status, int duration_ms,
|
||||
const std::string& response);
|
||||
// Suppress consecutive identical init URLs; return false if suppressed.
|
||||
bool should_log_init(const std::string& url);
|
||||
|
||||
// Print suppressed init count (if any) before a non-init log line.
|
||||
void flush_suppressed_init_logs();
|
||||
|
||||
private:
|
||||
std::unordered_map<std::string, Handler> routes_;
|
||||
|
||||
// Init suppression state
|
||||
std::string last_init_url_;
|
||||
std::string last_init_result_;
|
||||
int64_t last_init_timestamp_ = 0;
|
||||
int suppressed_count_ = 0;
|
||||
int64_t suppressed_report_ts_ = 0;
|
||||
std::string last_logged_init_url_;
|
||||
int suppressed_init_count_ = 0;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
if (ret == 1) {
|
||||
sess->handshake_done = true;
|
||||
logc::info("TLS handshake complete");
|
||||
flush_encrypted(sess);
|
||||
} else {
|
||||
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));
|
||||
else
|
||||
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
|
||||
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_sigterm), nullptr);
|
||||
uv_close(reinterpret_cast<uv_handle_t*>(&g_server), nullptr);
|
||||
uv_stop(g_loop);
|
||||
}
|
||||
|
||||
void tls_server_install_signals(uv_loop_t* loop) {
|
||||
|
||||
@@ -35,6 +35,7 @@ struct tls_session {
|
||||
|
||||
bool handshake_done = false;
|
||||
uint32_t body_length = 0;
|
||||
std::string peer_ip;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"cert": "node generate-cert.js",
|
||||
"backup:s3": "node scripts/s3-backup/backup.mjs",
|
||||
"backup:s3:quick": "node scripts/s3-backup/backup.mjs --skip-trust",
|
||||
"start": "node --watch server.js",
|
||||
"test:client": "node test-client.js"
|
||||
},
|
||||
|
||||
51
scripts/s3-backup/README.md
Normal file
51
scripts/s3-backup/README.md
Normal 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.
|
||||
267
scripts/s3-backup/backup.mjs
Normal file
267
scripts/s3-backup/backup.mjs
Normal 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);
|
||||
});
|
||||
40
scripts/s3-backup/config.mjs
Normal file
40
scripts/s3-backup/config.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function dockerGateway() {
|
||||
try {
|
||||
const out = execSync("ip -4 route show default dev docker0 2>/dev/null | awk '{print $3}'", {
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
if (out) return out;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return '172.17.0.1';
|
||||
}
|
||||
|
||||
export const HOST = process.env.S3_BACKUP_HOST || dockerGateway();
|
||||
export const BIND = process.env.S3_BACKUP_BIND || '0.0.0.0';
|
||||
export const PORT = Number(process.env.S3_BACKUP_PORT || 19443);
|
||||
export const BUCKET = process.env.S3_BACKUP_BUCKET || 'sqlbackups';
|
||||
export const ACCESS_KEY = process.env.S3_BACKUP_ACCESS_KEY || 'jtlsrv-s3';
|
||||
export const SECRET_KEY = process.env.S3_BACKUP_SECRET_KEY || 'jtlsrv-s3-secret';
|
||||
export const REGION = process.env.S3_BACKUP_REGION || 'us-east-1';
|
||||
|
||||
export const DATA_DIR = path.join(root, 'data', BUCKET);
|
||||
export const TMP_DIR = path.join(root, 'tmp');
|
||||
export const CERTS_DIR = path.join(root, 'certs');
|
||||
export const CA_CERT = path.join(CERTS_DIR, 'ca.pem');
|
||||
export const SERVER_KEY = path.join(CERTS_DIR, 'server-key.pem');
|
||||
export const SERVER_CERT = path.join(CERTS_DIR, 'server-cert.pem');
|
||||
|
||||
export function s3BaseUrl() {
|
||||
return `s3://${HOST}:${PORT}/${BUCKET}`;
|
||||
}
|
||||
|
||||
export function httpsBaseUrl() {
|
||||
return `https://${HOST}:${PORT}`;
|
||||
}
|
||||
197
scripts/s3-backup/ensure-certs.mjs
Normal file
197
scripts/s3-backup/ensure-certs.mjs
Normal 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 };
|
||||
}
|
||||
311
scripts/s3-backup/server.mjs
Normal file
311
scripts/s3-backup/server.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
90
scripts/s3-backup/sigv4.mjs
Normal file
90
scripts/s3-backup/sigv4.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function hmac(key, data, encoding) {
|
||||
return crypto.createHmac('sha256', key).update(data, 'utf8').digest(encoding);
|
||||
}
|
||||
|
||||
function hash(data) {
|
||||
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function parseAuthHeader(header) {
|
||||
const parts = Object.fromEntries(
|
||||
header.replace(/^AWS4-HMAC-SHA256\s+/, '').split(',').map((part) => {
|
||||
const idx = part.indexOf('=');
|
||||
const key = part.slice(0, idx).trim();
|
||||
const value = part.slice(idx + 1).trim().replace(/^"|"$/g, '');
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
const credential = parts.Credential.split('/');
|
||||
return {
|
||||
accessKey: credential[0],
|
||||
date: credential[1],
|
||||
region: credential[2],
|
||||
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')}"`;
|
||||
}
|
||||
62
server.js
62
server.js
@@ -36,33 +36,14 @@ function truncateUrl(url) {
|
||||
return `${url.slice(0, CONSOLE_URL_MAX_LENGTH)}...`;
|
||||
}
|
||||
|
||||
const lastInitState = { url: '', result: '', timestamp: 0 };
|
||||
let suppressedCount = 0;
|
||||
let lastLoggedInitUrl = null;
|
||||
let suppressedInitCount = 0;
|
||||
|
||||
setInterval(() => {
|
||||
if (suppressedCount > 0) {
|
||||
logger.info(`Suppressed ${suppressedCount} init log(s) in the last hour`);
|
||||
suppressedCount = 0;
|
||||
function flushSuppressedInitLogs() {
|
||||
if (suppressedInitCount > 0) {
|
||||
logger.info(`Suppressed ${suppressedInitCount} duplicate init log(s)`);
|
||||
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) {
|
||||
@@ -100,18 +81,27 @@ const loggedJtlHandler = async (req, res) => {
|
||||
const durationMs = Date.now() - started;
|
||||
const isInit = req.url.startsWith('/api/v1/init');
|
||||
|
||||
if (!isInit || shouldLogInit(req.url, res.statusCode, durationMs, responseBody)) {
|
||||
logger.info(`${req.socket.remoteAddress} ${req.method} ${truncateUrl(req.url)} ${res.statusCode} ${durationMs}ms`);
|
||||
|
||||
logRequest({
|
||||
remoteAddress: req.socket.remoteAddress,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
statusCode: res.statusCode,
|
||||
durationMs,
|
||||
response: 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`);
|
||||
|
||||
logRequest({
|
||||
remoteAddress: req.socket.remoteAddress,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
statusCode: res.statusCode,
|
||||
durationMs,
|
||||
response: responseBody,
|
||||
});
|
||||
};
|
||||
|
||||
const httpsServer = https.createServer(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sendJson } from '../http.js';
|
||||
import { getMaxExternalId } from '../order-log.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 { getCustomerGroupCount } from '../queries/customer-groups.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 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 }),
|
||||
getCategoryCount({ cursor: categoryCursor }),
|
||||
getCustomerGroupCount({ cursor: customerGroupCursor }),
|
||||
getCompositeProductCount({ cursor: compositeProductCursor }),
|
||||
getDeletedEntityCount({ cursor: deletedEntityCursor }),
|
||||
getMaxOrderIdCount(),
|
||||
]);
|
||||
|
||||
return sendJson(res, 200, {
|
||||
@@ -34,6 +35,6 @@ export async function handle(_req, res, { url }) {
|
||||
configurationGroup_count: '0',
|
||||
configurationItem_count: '0',
|
||||
deletedEntity_count: String(deletedEntityCount),
|
||||
max_orderId_count: getMaxExternalId(),
|
||||
max_orderId_count: String(maxOrderIdCount),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,12 +43,22 @@ export async function handle(req, res) {
|
||||
|
||||
try {
|
||||
const created = await createOrder(order);
|
||||
logger.success(`order ${created.orderNumber} (kAuftrag=${created.orderId}) created for externalId=${externalOrderId}`);
|
||||
results.push({
|
||||
status: 'OK',
|
||||
externalOrderId,
|
||||
message: '',
|
||||
});
|
||||
if (created.alreadyExists) {
|
||||
const msg = `order already mapped, skipped save (kAuftrag=${created.orderId}, ${created.orderNumber})`;
|
||||
logger.error(`order externalId=${externalOrderId} skipped: ${msg}`);
|
||||
results.push({
|
||||
status: 'ERROR',
|
||||
externalOrderId,
|
||||
message: msg,
|
||||
});
|
||||
} else {
|
||||
logger.success(`order ${created.orderNumber} (kAuftrag=${created.orderId}) created for externalId=${externalOrderId}`);
|
||||
results.push({
|
||||
status: 'OK',
|
||||
externalOrderId,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`order externalId=${externalOrderId} failed: ${err.message}`);
|
||||
results.push({
|
||||
@@ -59,5 +69,7 @@ export async function handle(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
return sendJson(res, 200, results);
|
||||
const failed = results.filter((r) => r.status === 'ERROR').length;
|
||||
const httpStatus = failed > 0 ? 500 : 200;
|
||||
return sendJson(res, httpStatus, results);
|
||||
}
|
||||
|
||||
@@ -7,20 +7,13 @@ fs.mkdirSync(path.dirname(ORDER_LOG_FILE), { recursive: true });
|
||||
const stream = fs.createWriteStream(ORDER_LOG_FILE, { flags: 'a' });
|
||||
|
||||
let orderSequence = 0;
|
||||
let maxExternalId = 0;
|
||||
|
||||
export function logOrder(order) {
|
||||
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);
|
||||
|
||||
return maxExternalId;
|
||||
}
|
||||
|
||||
export function getMaxExternalId() {
|
||||
return String(maxExternalId);
|
||||
}
|
||||
|
||||
export function closeOrderLog() {
|
||||
|
||||
@@ -133,32 +133,134 @@ async function allocatePk(transaction, tableName) {
|
||||
return pk;
|
||||
}
|
||||
|
||||
const VERSANDPOSITION_TYPE = 2;
|
||||
const ZAHLUNG_TYPE_ZAHLUNG = 10;
|
||||
const NIST_READONLY_NICHT_AENDERBAR = 2;
|
||||
const NIST_EXTERNE_RECHNUNG_KEINE = 2;
|
||||
|
||||
function parseImportSetting(order) {
|
||||
return toNumber(order.settings?.importSetting, 0);
|
||||
}
|
||||
|
||||
function parseInvoiceSetting(order) {
|
||||
return toNumber(order.settings?.invoiceSetting, 0);
|
||||
}
|
||||
|
||||
/** PosOrderSettingsService: importSetting=0 → NichtAenderbar. */
|
||||
function resolveNIstReadOnly(order) {
|
||||
return parseImportSetting(order) === 0 ? NIST_READONLY_NICHT_AENDERBAR : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* PosOrderSettingsService: importSetting=0 → KeineRechnungKeineAusgabe;
|
||||
* importSetting 2–5 or invoiceSetting Create flag → Wawi invoice.
|
||||
*/
|
||||
function resolveNIstExterneRechnung(order) {
|
||||
const importSetting = parseImportSetting(order);
|
||||
const invoiceSetting = parseInvoiceSetting(order);
|
||||
if (importSetting >= 2 && importSetting <= 5) return 0;
|
||||
if (invoiceSetting & 1) return 0;
|
||||
if (importSetting === 0) return NIST_EXTERNE_RECHNUNG_KEINE;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isVersandposition(item) {
|
||||
return toNumber(item.type, 0) === VERSANDPOSITION_TYPE;
|
||||
}
|
||||
|
||||
function hasVersandposition(items) {
|
||||
return items.some(isVersandposition);
|
||||
}
|
||||
|
||||
function hasNonReturnSaleItems(items) {
|
||||
return items.some((item) => toNumber(item.isReturn, 0) === 0);
|
||||
}
|
||||
|
||||
function shouldInjectSelbstabholerShipping(items) {
|
||||
if (hasVersandposition(items)) return false;
|
||||
return hasNonReturnSaleItems(items);
|
||||
}
|
||||
|
||||
async function lookupVersandArt(transaction, shippingName) {
|
||||
const name = String(shippingName ?? '').trim() || 'Selbstabholer';
|
||||
const tryLookup = async (cName) => {
|
||||
const result = await new sql.Request(transaction)
|
||||
.input('cName', sql.NVarChar, cName)
|
||||
.query(`
|
||||
SELECT TOP 1 kVersandArt, cName, fPrice, fMwSt
|
||||
FROM dbo.tVersandArt
|
||||
WHERE cName = @cName
|
||||
`);
|
||||
return result.recordset[0] ?? null;
|
||||
};
|
||||
|
||||
let row = await tryLookup(name);
|
||||
if (!row && name !== 'Selbstabholer') {
|
||||
row = await tryLookup('Selbstabholer');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function syntheticShippingItem(versandArt) {
|
||||
const vat = toNumber(versandArt.fMwSt, 19);
|
||||
const gross = toNumber(versandArt.fPrice, 0);
|
||||
const net = gross / (1 + vat / 100);
|
||||
return {
|
||||
type: String(VERSANDPOSITION_TYPE),
|
||||
quantity: '1',
|
||||
name: versandArt.cName,
|
||||
priceGross: String(gross),
|
||||
priceNet: String(net),
|
||||
vat: String(vat),
|
||||
isReturn: '0',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a Zahlungsart by name; creates it on the fly (including its PK
|
||||
* from dbo.tpk) when it does not exist yet.
|
||||
*/
|
||||
async function resolveZahlungsart(transaction, name, cache) {
|
||||
const key = String(name || 'Bar');
|
||||
const cached = cache.get(key.toLowerCase());
|
||||
const lookupName = String(name || 'Bar');
|
||||
const cacheKey = lookupName.toLowerCase();
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const existing = await new sql.Request(transaction)
|
||||
.input('cName', sql.NVarChar, key)
|
||||
let row = null;
|
||||
const exact = await new sql.Request(transaction)
|
||||
.input('cName', sql.NVarChar, lookupName)
|
||||
.query('SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE cName = @cName');
|
||||
row = exact.recordset[0] ?? null;
|
||||
|
||||
if (!row) {
|
||||
const lang = await new sql.Request(transaction)
|
||||
.input('cName', sql.NVarChar, lookupName)
|
||||
.query(`
|
||||
SELECT TOP 1 z.kZahlungsart, z.cName
|
||||
FROM dbo.tZahlungsArtSprache zs
|
||||
INNER JOIN dbo.tZahlungsart z ON z.kZahlungsart = zs.kZahlungsart
|
||||
WHERE zs.cName = @cName
|
||||
`);
|
||||
row = lang.recordset[0] ?? null;
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
const ci = await new sql.Request(transaction)
|
||||
.input('cName', sql.NVarChar, lookupName)
|
||||
.query('SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE UPPER(cName) = UPPER(@cName)');
|
||||
row = ci.recordset[0] ?? null;
|
||||
}
|
||||
|
||||
let zahlungsart;
|
||||
if (existing.recordset[0]) {
|
||||
zahlungsart = {
|
||||
kZahlungsart: existing.recordset[0].kZahlungsart,
|
||||
cName: existing.recordset[0].cName,
|
||||
};
|
||||
if (row) {
|
||||
zahlungsart = { kZahlungsart: row.kZahlungsart, cName: row.cName };
|
||||
} else {
|
||||
const kZahlungsart = await allocatePk(transaction, 'tZahlungsart');
|
||||
await new sql.Request(transaction)
|
||||
.input('kZahlungsart', sql.Int, kZahlungsart)
|
||||
.input('cName', sql.NVarChar, key)
|
||||
.input('cName', sql.NVarChar, lookupName)
|
||||
.query(`
|
||||
INSERT INTO dbo.tZahlungsart
|
||||
(kZahlungsart, cName, cPrtString, nLastschrift, cPrtStringVor, cPaymentOption, cKonto,
|
||||
@@ -166,10 +268,10 @@ async function resolveZahlungsart(transaction, name, cache) {
|
||||
nMatchingOptionen, nIstStandard, nAktiv)
|
||||
VALUES (@kZahlungsart, @cName, '', 0, '', '', '', 0, 0, 0, 0, 0, 0, 0, 1)
|
||||
`);
|
||||
zahlungsart = { kZahlungsart, cName: key };
|
||||
zahlungsart = { kZahlungsart, cName: lookupName };
|
||||
}
|
||||
|
||||
cache.set(key.toLowerCase(), zahlungsart);
|
||||
cache.set(cacheKey, zahlungsart);
|
||||
return zahlungsart;
|
||||
}
|
||||
|
||||
@@ -182,7 +284,7 @@ async function nextCustomerNumber(transaction) {
|
||||
* the search tables). Columns of the table type are named explicitly because
|
||||
* 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 iso = (a.countryIso || 'DE').toUpperCase();
|
||||
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('cISO', sql.NVarChar, iso)
|
||||
.input('cBundesland', sql.NVarChar, a.state || '')
|
||||
.input('cHerkunft', sql.NVarChar, isPosCustomer ? 'Kasse' : 'POS-API')
|
||||
.input('cKassenKunde', sql.Char(1), isPosCustomer ? 'Y' : 'N')
|
||||
.input('cHerkunft', sql.NVarChar, 'Kasse')
|
||||
.input('cKassenKunde', sql.Char(1), 'Y')
|
||||
.input('nDebitorennr', sql.Int, Number(a.debtorNumber) || 0)
|
||||
.query(`
|
||||
DECLARE @returnValue INT;
|
||||
DECLARE @p1 dbo.TYPE_spkundeInsert;
|
||||
INSERT INTO @p1
|
||||
DECLARE @kunde_daten dbo.TYPE_spkundeInsert;
|
||||
INSERT INTO @kunde_daten
|
||||
(kInetKunde, kKundenKategorie, cKundenNr, cFirma, cAnrede, cTitel, cVorname, cName,
|
||||
cStrasse, cPLZ, cOrt, cLand, cTel, cFax, cEMail, dErstellt, cMobil, fRabatt, cUSTID, cNewsletter,
|
||||
cZusatz, cEbayName, kBuyer, cAdressZusatz, cGeburtstag, cWWW, cSperre, cPostID, kKundenGruppe,
|
||||
@@ -229,7 +331,7 @@ async function createCustomer(transaction, { customerNumber, address, isPosCusto
|
||||
0, @kSprache, @cISO, @cBundesland, @cHerkunft, @cKassenKunde, N'', 0,
|
||||
@nDebitorennr, N'', 0, 0, 0, 0, 0,
|
||||
NULL, 0, 0, 0);
|
||||
EXEC @returnValue = Kunde.spKundeInsert @daten = @p1;
|
||||
EXEC @returnValue = Kunde.spKundeInsert @daten = @kunde_daten;
|
||||
SELECT @returnValue AS kKunde;
|
||||
`);
|
||||
|
||||
@@ -240,46 +342,59 @@ async function createCustomer(transaction, { customerNumber, address, isPosCusto
|
||||
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
|
||||
* does not exist. Orders without a customer number use (or create) the
|
||||
* Kassenkunde (walk-in customer).
|
||||
* does not exist. Walk-in orders use (or create) the Kassenkunde.
|
||||
*/
|
||||
async function resolveCustomer(transaction, order, defaults) {
|
||||
const customerNumber = String(order.customerNumber || '').trim();
|
||||
|
||||
if (customerNumber) {
|
||||
const result = await new sql.Request(transaction)
|
||||
.input('cKundenNr', sql.NVarChar, customerNumber)
|
||||
.query('SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKundenNr = @cKundenNr');
|
||||
if (result.recordset[0]) {
|
||||
return {
|
||||
kKunde: result.recordset[0].kKunde,
|
||||
kKundengruppe: result.recordset[0].kKundenGruppe || defaults.kKundengruppe,
|
||||
};
|
||||
}
|
||||
if (isWalkInOrder(order)) {
|
||||
const kassenKunde = await lookupKassenkunde(transaction, defaults);
|
||||
if (kassenKunde) return kassenKunde;
|
||||
return createCustomer(transaction, {
|
||||
customerNumber,
|
||||
customerNumber: await nextCustomerNumber(transaction),
|
||||
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]) {
|
||||
const customerNumber = String(order.customerNumber || '').trim();
|
||||
const result = await new sql.Request(transaction)
|
||||
.input('cKundenNr', sql.NVarChar, customerNumber)
|
||||
.query('SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKundenNr = @cKundenNr');
|
||||
if (result.recordset[0]) {
|
||||
return {
|
||||
kKunde: kassenKunde.recordset[0].kKunde,
|
||||
kKundengruppe: kassenKunde.recordset[0].kKundenGruppe || defaults.kKundengruppe,
|
||||
kKunde: result.recordset[0].kKunde,
|
||||
kKundengruppe: result.recordset[0].kKundenGruppe || defaults.kKundengruppe,
|
||||
};
|
||||
}
|
||||
|
||||
return createCustomer(transaction, {
|
||||
customerNumber: await nextCustomerNumber(transaction),
|
||||
customerNumber,
|
||||
address: order.billingAddress,
|
||||
isPosCustomer: true,
|
||||
defaults,
|
||||
});
|
||||
}
|
||||
@@ -328,15 +443,19 @@ async function insertOrderItem(transaction, kAuftrag, item) {
|
||||
const discount = toNumber(item.discountPercent, 0);
|
||||
const kSteuerklasse = steuerklasseForVat(vat);
|
||||
const sku = String(item.sku || '').trim();
|
||||
const positionType = isVersandposition(item) ? VERSANDPOSITION_TYPE : toNumber(item.type, 0);
|
||||
|
||||
let kArtikel = null;
|
||||
if (sku) {
|
||||
if (sku && positionType !== VERSANDPOSITION_TYPE) {
|
||||
const result = await new sql.Request(transaction)
|
||||
.input('cArtNr', sql.NVarChar, sku)
|
||||
.query('SELECT TOP 1 kArtikel FROM dbo.tArtikel WHERE cArtNr = @cArtNr');
|
||||
kArtikel = result.recordset[0]?.kArtikel ?? null;
|
||||
}
|
||||
|
||||
const nType = positionType === VERSANDPOSITION_TYPE ? VERSANDPOSITION_TYPE : (kArtikel ? 1 : 0);
|
||||
const nReserviert = nType === VERSANDPOSITION_TYPE ? 0 : 1;
|
||||
|
||||
const result = await new sql.Request(transaction)
|
||||
.input('kArtikel', sql.Int, kArtikel)
|
||||
.input('kAuftrag', sql.Int, kAuftrag)
|
||||
@@ -347,7 +466,8 @@ async function insertOrderItem(transaction, kAuftrag, item) {
|
||||
.input('fVkNetto', sql.Float, priceNet)
|
||||
.input('fMwSt', sql.Float, vat)
|
||||
.input('kSteuerklasse', sql.Int, kSteuerklasse)
|
||||
.input('nType', sql.Int, kArtikel ? 1 : 0)
|
||||
.input('nType', sql.Int, nType)
|
||||
.input('nReserviert', sql.Int, nReserviert)
|
||||
.input('cEinheit', sql.NVarChar, item.unit || '')
|
||||
.input('fRabatt', sql.Float, discount)
|
||||
.query(`
|
||||
@@ -356,7 +476,7 @@ async function insertOrderItem(transaction, kAuftrag, item) {
|
||||
(kArtikel, kAuftrag, cArtNr, nReserviert, cName, cHinweis, fAnzahl, fVkNetto, fMwSt,
|
||||
cNameStandard, kSteuerklasse, nType, cEinheit, fFaktor, kSteuerschluessel, fRabatt)
|
||||
OUTPUT inserted.kAuftragPosition INTO @t
|
||||
VALUES (@kArtikel, @kAuftrag, @cArtNr, 1, @cName, @cHinweis, @fAnzahl, @fVkNetto, @fMwSt,
|
||||
VALUES (@kArtikel, @kAuftrag, @cArtNr, @nReserviert, @cName, @cHinweis, @fAnzahl, @fVkNetto, @fMwSt,
|
||||
@cName, @kSteuerklasse, @nType, @cEinheit, 1.0, 3, @fRabatt);
|
||||
SELECT kAuftragPosition FROM @t;
|
||||
`);
|
||||
@@ -364,15 +484,62 @@ async function insertOrderItem(transaction, kAuftrag, item) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Links a JTL-Wawi order (and its positions) back to the originating
|
||||
* POS order/position ids via Pos.tAuftragMapping / Pos.tAuftragPositionMapping,
|
||||
* so the POS system can recognise orders it already pushed into JTL-Wawi.
|
||||
* JTL PosOrderCreationService.CheckIfOrderExists — skip re-import when the POS
|
||||
* order id is already mapped to a Wawi order for this subshop with the same
|
||||
* 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();
|
||||
if (!Number.isInteger(kPosAuftrag) || !kShopSubShop) {
|
||||
return;
|
||||
}
|
||||
await new sql.Request(transaction)
|
||||
.input('kPosAuftrag', sql.Int, kPosAuftrag)
|
||||
.input('kShopSubShop', sql.Int, kShopSubShop)
|
||||
.query(`
|
||||
DELETE FROM Pos.tAuftragMapping
|
||||
WHERE kPosAuftrag = @kPosAuftrag
|
||||
AND kShopSubShop = @kShopSubShop
|
||||
`);
|
||||
await new sql.Request(transaction)
|
||||
.input('kAuftrag', sql.Int, kAuftrag)
|
||||
.input('kPosAuftrag', sql.Int, kPosAuftrag)
|
||||
@@ -418,11 +585,43 @@ function isOrderDelivered(order) {
|
||||
return deliver === true || String(deliver) === '1' || String(deliver).toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
async function recalculateAuftragEckdaten(transaction, kAuftrag) {
|
||||
await new sql.Request(transaction).input('kAuftrag', sql.Int, kAuftrag).query(`
|
||||
DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;
|
||||
INSERT INTO @eckdaten_calc VALUES (@kAuftrag);
|
||||
EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc;
|
||||
`);
|
||||
}
|
||||
|
||||
async function getOffenerAuftragswert(transaction, kAuftrag) {
|
||||
const result = await new sql.Request(transaction)
|
||||
.input('kAuftrag', sql.Int, kAuftrag)
|
||||
.query(`
|
||||
SELECT ROUND(tAuftragEckdaten.fOffenerWertOhneStorno, 2) AS fOffenerAuftragswert
|
||||
FROM Verkauf.tAuftrag
|
||||
LEFT JOIN Verkauf.tAuftragEckdaten ON tAuftragEckdaten.kAuftrag = tAuftrag.kAuftrag
|
||||
WHERE tAuftrag.kAuftrag = @kAuftrag
|
||||
`);
|
||||
const value = result.recordset[0]?.fOffenerAuftragswert;
|
||||
return value == null ? null : toNumber(value, 0);
|
||||
}
|
||||
|
||||
function isNewPayment(payment) {
|
||||
const paymentId = toNumber(payment.paymentId, 0);
|
||||
return paymentId <= 0;
|
||||
}
|
||||
|
||||
async function insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache) {
|
||||
const zahlungsart = await resolveZahlungsart(transaction, payment.paymentMethodName || order.paymentMethodName, zahlungsartCache);
|
||||
const kZahlung = await allocatePk(transaction, 'tZahlung');
|
||||
|
||||
await new sql.Request(transaction)
|
||||
await recalculateAuftragEckdaten(transaction, kAuftrag);
|
||||
const fOffenerWert = await getOffenerAuftragswert(transaction, kAuftrag);
|
||||
if (fOffenerWert == null) {
|
||||
throw new Error(`no open order amount for kAuftrag=${kAuftrag}`);
|
||||
}
|
||||
|
||||
const result = await new sql.Request(transaction)
|
||||
.input('kZahlung', sql.Int, kZahlung)
|
||||
.input('cName', sql.NVarChar, zahlungsart.cName)
|
||||
.input('dDatum', sql.DateTime, orderDate)
|
||||
@@ -431,16 +630,42 @@ async function insertPayment(transaction, kAuftrag, payment, order, orderDate, z
|
||||
.input('kBenutzer', sql.Int, config.kBenutzer)
|
||||
.input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart)
|
||||
.input('cExternalTransactionId', sql.NVarChar, order.externalOrderNumber || '')
|
||||
.input('fOffenerWert', sql.Float, fOffenerWert)
|
||||
.query(`
|
||||
INSERT INTO dbo.tZahlung
|
||||
(kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart,
|
||||
nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung)
|
||||
VALUES (@kZahlung, @cName, @dDatum, @fBetrag, @kBestellung, @kBenutzer, 0, '', @kZahlungsart,
|
||||
0, @cExternalTransactionId, 0, 0, '', 0)
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM Verkauf.tAuftragEckdaten
|
||||
WHERE kAuftrag = @kBestellung
|
||||
AND ROUND(fOffenerWertOhneStorno, 2) = ROUND(@fOffenerWert, 2)
|
||||
)
|
||||
BEGIN
|
||||
INSERT INTO dbo.tZahlung
|
||||
(kZahlung, cName, dDatum, fBetrag, kBestellung, kBenutzer, nAnzahlung, cHinweis, kZahlungsart,
|
||||
nKeinExport, cExternalTransactionId, nZuweisungstyp, nZahlungstyp, cZuweisungsinfo, nZuweisungswertung)
|
||||
VALUES (@kZahlung, @cName, @dDatum, @fBetrag, @kBestellung, @kBenutzer, 0, '', @kZahlungsart,
|
||||
0, @cExternalTransactionId, 0, ${ZAHLUNG_TYPE_ZAHLUNG}, '', 0);
|
||||
END
|
||||
SELECT @@ROWCOUNT AS inserted;
|
||||
`);
|
||||
|
||||
if (!result.recordset[0]?.inserted) {
|
||||
throw new Error(`payment insert skipped: open amount changed for kAuftrag=${kAuftrag}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrder(order) {
|
||||
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 transaction = new sql.Transaction(getPool());
|
||||
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 cAuftragsNr = await nextOrderNumber(transaction, orderDate);
|
||||
|
||||
const orderItems = [...(order.orderItems || [])];
|
||||
const versandArt = await lookupVersandArt(transaction, order.shippingName);
|
||||
let kVersandArt = versandArt?.kVersandArt ?? defaults.kVersandArt;
|
||||
if (shouldInjectSelbstabholerShipping(orderItems) && versandArt) {
|
||||
orderItems.push(syntheticShippingItem(versandArt));
|
||||
}
|
||||
|
||||
const nIstReadOnly = resolveNIstReadOnly(order);
|
||||
const nIstExterneRechnung = resolveNIstExterneRechnung(order);
|
||||
|
||||
const resultAuftrag = await new sql.Request(transaction)
|
||||
.input('cAuftragsNr', sql.NVarChar, cAuftragsNr)
|
||||
.input('dErstellt', sql.DateTime, orderDate)
|
||||
@@ -466,62 +701,67 @@ export async function createOrder(order) {
|
||||
.input('cWaehrung', sql.NVarChar, order.currencyIso || 'EUR')
|
||||
.input('kPlattform', sql.Int, defaults.kPlattform)
|
||||
.input('kShop', sql.Int, getActiveShopId() || null)
|
||||
.input('cKundenNr', sql.NVarChar, String(order.customerNumber || ''))
|
||||
.input('cKundenNr', sql.NVarChar, resolveAuftragCKundenNr(order))
|
||||
.input('cVersandlandISO', sql.NVarChar, (order.shippingAddress?.countryIso || 'DE').toUpperCase())
|
||||
.input('kVersandArt', sql.Int, defaults.kVersandArt)
|
||||
.input('kVersandArt', sql.Int, kVersandArt)
|
||||
.input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart)
|
||||
.input('kKundengruppe', sql.Int, kKundengruppe)
|
||||
.input('cExterneAuftragsnummer', sql.NVarChar, order.externalOrderNumber || '')
|
||||
.input('nIstExterneRechnung', sql.Int, nIstExterneRechnung)
|
||||
.input('nIstReadOnly', sql.Int, nIstReadOnly)
|
||||
.query(`
|
||||
DECLARE @t TABLE ([kAuftrag] INT);
|
||||
INSERT INTO Verkauf.tAuftrag
|
||||
(cAuftragsNr, dErstellt, nKomplettAusgeliefert, kBenutzer, kKunde, kBenutzerErstellt, nType, fFaktor,
|
||||
kFirmaHistory, kSprache, cVersandlandWaehrung, fVersandlandWaehrungFaktor, fFinanzierungskosten,
|
||||
cWaehrung, kPlattform, kShop, cKundenNr, cVersandlandISO, kVersandArt, kZahlungsart, kKundengruppe,
|
||||
cExterneAuftragsnummer)
|
||||
cExterneAuftragsnummer, nIstExterneRechnung, cInet, nIstReadOnly, kShopauftrag, nLieferPrioritaet)
|
||||
OUTPUT inserted.kAuftrag INTO @t
|
||||
VALUES (@cAuftragsNr, @dErstellt, 0, @kBenutzer, @kKunde, @kBenutzer, 1, 1.0,
|
||||
@kFirmaHistory, @kSprache, @cWaehrung, 1.0, 0.0,
|
||||
@cWaehrung, @kPlattform, @kShop, @cKundenNr, @cVersandlandISO, @kVersandArt, @kZahlungsart, @kKundengruppe,
|
||||
@cExterneAuftragsnummer);
|
||||
@cExterneAuftragsnummer, @nIstExterneRechnung, 'Y', @nIstReadOnly, 0, 10);
|
||||
SELECT kAuftrag FROM @t;
|
||||
`);
|
||||
|
||||
const kAuftrag = resultAuftrag.recordset[0].kAuftrag;
|
||||
|
||||
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.billingAddress, 1);
|
||||
|
||||
const deliveredItems = [];
|
||||
for (const item of order.orderItems || []) {
|
||||
for (const item of orderItems) {
|
||||
const kAuftragPosition = await insertOrderItem(transaction, kAuftrag, item);
|
||||
const kPosAuftragPosition = Number.parseInt(item.externalId, 10);
|
||||
await insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition);
|
||||
if (kAuftragPosition != null) {
|
||||
if (Number.isInteger(kPosAuftragPosition)) {
|
||||
await insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition);
|
||||
}
|
||||
if (kAuftragPosition != null && !isVersandposition(item)) {
|
||||
deliveredItems.push({ kAuftragPosition, quantity: toNumber(item.quantity, 1) });
|
||||
}
|
||||
}
|
||||
|
||||
for (const payment of order.payments || []) {
|
||||
if (!isNewPayment(payment)) continue;
|
||||
await insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache);
|
||||
}
|
||||
|
||||
if (isOrderDelivered(order)) {
|
||||
await deliverOrder(transaction, config.kBenutzer, kAuftrag, defaults.kVersandArt, deliveredItems);
|
||||
await deliverOrder(transaction, config.kBenutzer, kAuftrag, kVersandArt, deliveredItems);
|
||||
}
|
||||
|
||||
await new sql.Request(transaction).input('kAuftrag', sql.Int, kAuftrag).query(`
|
||||
DECLARE @p1 Verkauf.TYPE_spAuftragEckdatenBerechnen;
|
||||
INSERT INTO @p1 VALUES (@kAuftrag);
|
||||
EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @p1;
|
||||
DECLARE @eckdaten_calc Verkauf.TYPE_spAuftragEckdatenBerechnen;
|
||||
INSERT INTO @eckdaten_calc VALUES (@kAuftrag);
|
||||
EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @eckdaten_calc;
|
||||
`);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return { orderId: String(kAuftrag), orderNumber: cAuftragsNr };
|
||||
return { orderId: String(kAuftrag), orderNumber: cAuftragsNr, alreadyExists: false };
|
||||
} catch (err) {
|
||||
try {
|
||||
await transaction.rollback();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { openSession, discardSession, closeSession } from './session.js';
|
||||
import { getOutgoingWarehouse } from './warehouse.js';
|
||||
import { getOutgoingWarehouse, getWarehousePlace } from './warehouse.js';
|
||||
import { reservePositions } from './reserve.js';
|
||||
import { bookStockShortfallsAndRereserve } from './stock-shortage.js';
|
||||
import { commitPicklists } from './commit.js';
|
||||
import { deliverPicklists } from './deliver.js';
|
||||
|
||||
@@ -23,10 +24,19 @@ export async function deliverOrder(transaction, kBenutzer, kAuftrag, kVersandArt
|
||||
}
|
||||
|
||||
const kWarenLager = await getOutgoingWarehouse(transaction);
|
||||
const kWarenLagerPlatz = await getWarehousePlace(transaction, kWarenLager);
|
||||
const kSessionId = await openSession(transaction, kBenutzer);
|
||||
|
||||
try {
|
||||
await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, deliveredItems);
|
||||
await bookStockShortfallsAndRereserve(
|
||||
transaction,
|
||||
kBenutzer,
|
||||
kSessionId,
|
||||
kWarenLager,
|
||||
kWarenLagerPlatz,
|
||||
deliveredItems,
|
||||
);
|
||||
await commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag);
|
||||
await deliverPicklists(transaction, kBenutzer, kSessionId, kAuftrag, kVersandArt);
|
||||
} finally {
|
||||
|
||||
112
src/queries/delivery/stock-shortage.js
Normal file
112
src/queries/delivery/stock-shortage.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import sql from 'mssql';
|
||||
import { reservePositions } from './reserve.js';
|
||||
|
||||
const POS_SHORTAGE_COMMENT = 'Korrekturbuchung erstellt durch POS-Abgleich';
|
||||
const BUCHUNGSART_WARENEINGANG = 10;
|
||||
|
||||
async function getReservedQuantity(transaction, kSessionId, kAuftragPosition) {
|
||||
const result = await new sql.Request(transaction)
|
||||
.input('kSessionId', sql.Int, kSessionId)
|
||||
.input('kBestellPos', sql.Int, kAuftragPosition)
|
||||
.query(`
|
||||
SELECT ISNULL(SUM(pp.fAnzahl), 0) AS reserved
|
||||
FROM dbo.tPicklistePos pp
|
||||
INNER JOIN dbo.tPickliste p ON p.kPickliste = pp.kPickliste
|
||||
WHERE p.kSessionId = @kSessionId
|
||||
AND pp.kBestellPos = @kBestellPos
|
||||
`);
|
||||
return Number(result.recordset[0]?.reserved ?? 0);
|
||||
}
|
||||
|
||||
async function getPositionArtikel(transaction, kAuftragPosition) {
|
||||
const result = await new sql.Request(transaction)
|
||||
.input('kAuftragPosition', sql.Int, kAuftragPosition)
|
||||
.query(`
|
||||
SELECT kArtikel
|
||||
FROM Verkauf.tAuftragPosition
|
||||
WHERE kAuftragPosition = @kAuftragPosition
|
||||
`);
|
||||
return result.recordset[0]?.kArtikel ?? 0;
|
||||
}
|
||||
|
||||
async function bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtikel, fehlmenge) {
|
||||
await new sql.Request(transaction)
|
||||
.input('kArtikel', sql.Int, kArtikel)
|
||||
.input('kWarenLagerPlatz', sql.Int, kWarenLagerPlatz)
|
||||
.input('kBenutzer', sql.Int, kBenutzer)
|
||||
.input('fAnzahl', sql.Float, fehlmenge)
|
||||
.input('cKommentar', sql.NVarChar, POS_SHORTAGE_COMMENT)
|
||||
.input('kBuchungsart', sql.Int, BUCHUNGSART_WARENEINGANG)
|
||||
.query(`
|
||||
DECLARE @kWarenlagerEingang INT;
|
||||
EXEC dbo.spWarenlagerEingangSchreiben
|
||||
@kArtikel = @kArtikel,
|
||||
@kWarenLagerPlatz = @kWarenLagerPlatz,
|
||||
@kLieferantenBestellungPos = 0,
|
||||
@kBenutzer = @kBenutzer,
|
||||
@fAnzahl = @fAnzahl,
|
||||
@fEkEinzel = 0,
|
||||
@cLieferscheinNr = '',
|
||||
@cChargenNr = NULL,
|
||||
@dMHD = NULL,
|
||||
@dGeliefertAm = NULL,
|
||||
@cKommentar = @cKommentar,
|
||||
@kGutschriftPos = 0,
|
||||
@kLHM = 0,
|
||||
@kSessionId = 0,
|
||||
@kBuchungsart = @kBuchungsart,
|
||||
@kBestellPosUmlagerung = 0,
|
||||
@kRMRetourePos = 0,
|
||||
@nHistorieNichtSchreiben = 0,
|
||||
@kWarenlagerEingang = @kWarenlagerEingang OUTPUT;
|
||||
SELECT @kWarenlagerEingang AS kWarenlagerEingang;
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* PosStockPositionService.FehlbestandEinbuchen — book missing stock, then re-reserve.
|
||||
*/
|
||||
export async function bookStockShortfallsAndRereserve(
|
||||
transaction,
|
||||
kBenutzer,
|
||||
kSessionId,
|
||||
kWarenLager,
|
||||
kWarenLagerPlatz,
|
||||
positions,
|
||||
) {
|
||||
const rereserve = [];
|
||||
|
||||
for (const { kAuftragPosition, quantity } of positions) {
|
||||
if (!kAuftragPosition || quantity <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reserved = await getReservedQuantity(transaction, kSessionId, kAuftragPosition);
|
||||
const shortage = quantity - reserved;
|
||||
if (shortage <= 0.0001) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const kArtikel = await getPositionArtikel(transaction, kAuftragPosition);
|
||||
if (!kArtikel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtikel, shortage);
|
||||
rereserve.push({ kAuftragPosition, quantity: shortage });
|
||||
}
|
||||
|
||||
if (rereserve.length) {
|
||||
await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, rereserve);
|
||||
}
|
||||
|
||||
for (const { kAuftragPosition, quantity } of positions) {
|
||||
if (!kAuftragPosition || quantity <= 0) {
|
||||
continue;
|
||||
}
|
||||
const reserved = await getReservedQuantity(transaction, kSessionId, kAuftragPosition);
|
||||
if (reserved + 0.0001 < quantity) {
|
||||
throw new Error(`insufficient stock after POS shortage booking for kBestellPos=${kAuftragPosition}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import sql from 'mssql';
|
||||
|
||||
/** DB-resolved (or env-overridden) outgoing warehouse, cached after first lookup. */
|
||||
let cachedWarenLager = null;
|
||||
let cachedWarenLagerPlatz = null;
|
||||
|
||||
/**
|
||||
* Resolves the local warehouse (dbo.tWarenLager.nFulfillment = 0) to book
|
||||
@@ -36,3 +37,34 @@ export async function getOutgoingWarehouse(transaction) {
|
||||
cachedWarenLager = row.kWarenLager;
|
||||
return cachedWarenLager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default pick place for the outgoing warehouse (dbo.tWarenLagerPlatz).
|
||||
*/
|
||||
export async function getWarehousePlace(transaction, kWarenLager) {
|
||||
if (cachedWarenLagerPlatz != null) {
|
||||
return cachedWarenLagerPlatz;
|
||||
}
|
||||
|
||||
const configured = Number(process.env.JTL_KWARENLAGERPLATZ);
|
||||
if (Number.isInteger(configured) && configured > 0) {
|
||||
cachedWarenLagerPlatz = configured;
|
||||
return cachedWarenLagerPlatz;
|
||||
}
|
||||
|
||||
const result = await new sql.Request(transaction)
|
||||
.input('kWarenLager', sql.Int, kWarenLager)
|
||||
.query(`
|
||||
SELECT TOP 1 kWarenLagerPlatz
|
||||
FROM dbo.tWarenLagerPlatz
|
||||
WHERE kWarenLager = @kWarenLager
|
||||
AND ISNULL(nGesperrt, 0) = 0
|
||||
ORDER BY nPrio, kWarenLagerPlatz
|
||||
`);
|
||||
const row = result.recordset[0];
|
||||
if (!row) {
|
||||
throw new Error(`No warehouse place found for kWarenLager=${kWarenLager}; set JTL_KWARENLAGERPLATZ explicitly.`);
|
||||
}
|
||||
cachedWarenLagerPlatz = row.kWarenLagerPlatz;
|
||||
return cachedWarenLagerPlatz;
|
||||
}
|
||||
|
||||
23
src/queries/max-order-id.js
Normal file
23
src/queries/max-order-id.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user