Compare commits

..

8 Commits

Author SHA1 Message Date
seb
955fe2c164 delivery seemds to work. 2026-07-13 03:40:14 +02:00
seb
03dde8eff8 u 2026-07-13 02:55:43 +02:00
seb
8d55da79b0 close 2026-07-13 02:30:04 +02:00
seb
53a7924c7f u 2026-07-12 23:19:40 +02:00
seb
cbc4b8315e u 2026-07-12 23:12:23 +02:00
seb
23c5b76027 u 2026-07-12 21:55:10 +02:00
seb
ee09434ea9 u 2026-07-12 21:46:49 +02:00
seb
7f6095013b u 2026-07-12 21:14:43 +02:00
24 changed files with 1715 additions and 159 deletions

View File

@@ -16,14 +16,14 @@ MANDANT_NAME=eB-Standard
MANDANT_DATABASE=eazybusiness MANDANT_DATABASE=eazybusiness
ROOT_CATEGORY_ID=1 ROOT_CATEGORY_ID=1
# Category sync (tKategorieSprache / tKategoriebildPlattform lookups) # Category sync (tKategorieSprache lookups)
LANGUAGE_ID=1 LANGUAGE_ID=1
IMAGE_PLATFORM_ID=1
IMAGE_SHOP_ID=0
# Product sync (tSteuerzone.cName used to look up tax rates per tSteuerklasse) # Product sync (tSteuerzone.cName used to look up tax rates per tSteuerklasse)
TAX_ZONE_NAME=Zone-EU TAX_ZONE_NAME=Zone-EU
# Shop filter: active shop is queried from tShopSubshop at startup
# MSSQL (connection data for JTL-Wawi database) # MSSQL (connection data for JTL-Wawi database)
MSSQL_SERVER=localhost MSSQL_SERVER=localhost
MSSQL_PORT=1433 MSSQL_PORT=1433

View File

@@ -0,0 +1,101 @@
---
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: pending
- id: tls-http
content: TLS layer (OpenSSL memory-BIO over uv_tcp) + llhttp request parsing
status: pending
- id: router
content: Router, pairing store, client/init endpoints (no DB)
status: pending
- id: odbc
content: ODBC connection pool + uv_queue_work plumbing + simple queries
status: pending
- id: queries
content: Port remaining list queries and JSON shaping (product, category, composite, deleted, attributes)
status: pending
- id: images
content: "Image endpoints: blob fetch + libvips thumbnail in worker threads"
status: pending
- id: polish
content: Request/order logging, init suppression, graceful shutdown, side-by-side verification vs Node server
status: pending
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.

666
delivery.md Normal file
View File

@@ -0,0 +1,666 @@
# Delivery investigation
## Problem
Orders created via `jtlsrv` (`AU-202607-10025`) get `nKomplettAusgeliefert = 1` and a
`dbo.tLieferschein` / `dbo.tLieferscheinPos` row, but **no** `dbo.tPickliste` /
`dbo.tPicklistePos` rows and no real warehouse stock booking. Orders created by the
real JTL-Wawi/POS stack (`AU-202607-10026`) get all of that correctly (Pickliste with
proper status progression 5 → 10 → 40, a numbered Lieferschein like
`AU-202607-10026-001`, etc).
Root cause: our code (`src/queries/create-order.js`) calls the low-level
`Versand.spLieferscheinErstellen` / `Versand.spLieferscheinPosErstellen` procedures
directly. These only insert the Lieferschein rows and call
`Verkauf.spAuftragEckdatenBerechnen` (which derives `nKomplettAusgeliefert`). They
never touch the Pickliste/warehouse-reservation subsystem, so nothing is actually
picked/booked out of stock. The real JTL code path is much deeper and goes through
the same "Auslieferung" (fulfillment) engine used by the Wawi GUI's "Auftrag
ausliefern" feature. This document records that engine in full, down to exact SQL
parameter names/types and XML schemas, gathered from:
- Decompiled C# (`ilspycmd`) of `jtlCore.dll` (obfuscated string literals, but
control flow / call graph is readable) and `jtlDatabase.dll` (**not** obfuscated —
this is where the actual ADO.NET parameter wiring lives).
- `sp_helptext` of the actual stored procedures in `eazybusiness` (SQL Server ground
truth — these fully document their own XML input schemas in comments and in the
`.nodes()`/`.value()` shredding code).
## 0. Step-by-step SQL to issue for one delivery (ready to run/adapt)
This is the literal sequence of statements to execute, in order, inside the same
transaction as the rest of order creation, to deliver one order
(`@kAuftrag`) for one user (`@kBenutzer`). Everything here is derived 1:1 from the
stored-procedure signatures and XML schemas documented in §3; treat this section as
the concrete checklist, §3 as the "why"/evidence.
Placeholders to fill in per order: `@kAuftrag`, `@kBenutzer`, `@kWarenLager` (see
§7 — outgoing warehouse, still unresolved), and one `<Bestellposition>`/`<Lager>`
pair per order position (only positions with real, stock-tracked articles need to go
through the reservation dance below at all — see §7 for free positions).
```sql
-- Step 1: open a session (mirrors jtlCore's SessionManager / dbo.tSessionId row)
DECLARE @kSessionId INT;
INSERT INTO dbo.tSessionId (cRechnername, kBenutzer, dLastAction)
VALUES ('jtlsrv', @kBenutzer, DATEADD(day, 10, GETDATE()));
SET @kSessionId = SCOPE_IDENTITY();
-- Step 2: reserve every order position against the outgoing warehouse
-- (one call per position, or batch them all into @Bestellpositionen and call
-- Auslieferung.spReserviereBestellpositionen once for the whole order — same effect)
DECLARE @Bestellpositionen XML = '
<Bestellposition><kBestellPos>{kAuftragPosition}</kBestellPos><fAnzahl>{menge}</fAnzahl></Bestellposition>
<!-- repeat one <Bestellposition> per order position -->
';
DECLARE @Laeger XML = '
<Lager><kWarenlager>{kWarenLager}</kWarenlager><nPrio>0</nPrio><kLieferant>0</kLieferant><kAnsprechpartner>0</kAnsprechpartner></Lager>
';
EXEC Auslieferung.spReserviereBestellpositionen
@Bestellpositionen = @Bestellpositionen,
@Laeger = @Laeger,
@Warenlagereingaenge = NULL,
@nOptions = 0x0102, -- ChargenVorbelegen (0x0002) | Stücklistenkorrektur (0x0100)
@kBenutzer = @kBenutzer,
@kSessionId = @kSessionId;
-- -> creates dbo.tPickliste (kSessionId = @kSessionId) + dbo.tPicklistePos rows, status 0 then 5
-- Step 3: commit the session's reservations into "real" Picklisten
DECLARE @Bestellungen XML = '<Bestellung><kBestellung>{kAuftrag}</kBestellung></Bestellung>';
DECLARE @xResultUebernehmen XML;
EXEC Auslieferung.spPicklistenUebernehmen
@Bestellungen = @Bestellungen,
@kBenutzer = @kBenutzer,
@nTeillieferung = 0, -- 0 = partial delivery forbidden (we always deliver in full)
@kSessionId = @kSessionId,
@xResult = @xResultUebernehmen OUTPUT;
-- -> bumps dbo.tPicklistePos.nStatus from <10 to 10 (or 20 for position types 18/20) for this session
-- Step 4: actually deliver — creates the numbered Lieferschein, decrements real
-- stock, marks positions delivered
--
-- IMPORTANT: @Pakete must NOT be NULL/empty if you want dbo.tVersand populated.
-- The nested Auslieferung.spPicklistenAusliefern_PaketeErzeugen builds a temp
-- table straight from @Pakete.nodes('/Paket'); if @Pakete is NULL that temp
-- table stays empty and NOT A SINGLE dbo.tVersand row gets written for ANY
-- Lieferschein in the session (not even the "no Paket given" fallback row,
-- because even that fallback branch is keyed off a per-kBestellung JOIN
-- against the temp table). So pass one <Paket> per order, at minimum with
-- <kBestellung>/<kVersandart>/<fGewicht> (fGewicht=0 is the sentinel the proc
-- itself later replaces with an article-weight-based value; the column is
-- NOT NULL so it can't be omitted).
DECLARE @Pakete XML = '<Paket><kBestellung>{kAuftrag}</kBestellung><kVersandart>{kVersandArt}</kVersandart><fGewicht>0</fGewicht></Paket>';
DECLARE @xResultAusliefern XML;
EXEC Auslieferung.spPicklistenAusliefern
@xHinweise = NULL, -- optional <Hinweis><kBestellPos>/<kBestellung></kBestellung><cHinweis>...
@Pakete = @Pakete,
@nOptions = 0x0002, -- VersandSetzen (DeliveryStep always sets this); OR 0x0001 for one Lieferschein per warehouse
@kBenutzer = @kBenutzer,
@kSessionId = @kSessionId,
@xResult = @xResultAusliefern OUTPUT;
-- -> creates dbo.tLieferschein/tLieferscheinPos (properly numbered "<AuftragsNr>-NNN"),
-- links kLieferscheinPos back onto tPicklistePos, calls dbo.spWarenlagerAusgangPicklistePos
-- (real stock decrement), bumps tPicklistePos.nStatus to 40, AND (via
-- spPicklistenAusliefern_PaketeErzeugen) inserts a dbo.tVersand row per
-- Lieferschein carrying {kVersandArt} and, since VersandSetzen is set and this
-- is a local (non-fulfillment) warehouse, dVersendet = GETDATE()
-- Step 5: recalculate order eckdaten (nKomplettAusgeliefert etc.) — already implemented today
DECLARE @p1 Verkauf.TYPE_spAuftragEckdatenBerechnen;
INSERT INTO @p1 VALUES (@kAuftrag);
EXEC Verkauf.spAuftragEckdatenBerechnen @auftrag = @p1;
-- Step 6: discard anything left in this session that never got committed/delivered.
-- Safe to call unconditionally (success AND error path) — its body is just
-- `DELETE FROM dbo.tPickliste WHERE kSessionId = @kSessionId AND nStatus < 10`,
-- so it never touches already-delivered (nStatus > 10) Picklisten. On the error
-- path it also cleans up whatever Step 2 managed to reserve before the failure.
EXEC Auslieferung.spPicklistenVerwerfen @kBenutzer = @kBenutzer, @kSessionId = @kSessionId;
-- Step 7 (optional cleanup, mirrors SessionManager.Dispose()):
DELETE FROM dbo.tSessionId WHERE kSessionId = @kSessionId;
```
Notes on the XML construction above (see §3 for full schema/behavior detail):
- `{kAuftragPosition}` = `Verkauf.tAuftragPosition.kAuftragPosition` (what our code
calls `kAuftragPosition`), `{menge}` = ordered quantity, `{kWarenLager}` = the
resolved outgoing warehouse (§7, open question), `{kAuftrag}` =
`Verkauf.tAuftrag.kAuftrag`.
- `@Bestellpositionen`/`@Laeger` are XML *fragments* — SQL Server's `xml` type
happily holds multiple top-level elements, so just concatenate one
`<Bestellposition>...</Bestellposition>` per position (same for `<Lager>` if ever
passing multiple candidate warehouses).
- Steps 2-4 must run inside the *same* transaction/connection context as order
creation so `@kSessionId` and the temp Pickliste rows are visible to each other and
everything rolls back together on failure.
## 0.1 Cross-check against another (incomplete) implementation
A second, independently-written `deliverOrder()` implementation was reviewed. It was
apparently built by SQL-tracing manual "Auftrag ausliefern" actions in the Wawi GUI
(comments like `// Based on Statement 30 from JTL dump`). Cross-checking it against
the decompiled C#/`sp_helptext` ground truth above:
**Confirms our findings:**
- Same overall call order: `Auslieferung.spReserviereBestellungen`/`spReserviereBestellpositionen`
`Auslieferung.spPicklistenUebernehmen``Auslieferung.spPicklistenAusliefern`
`Verkauf.spAuftragEckdatenBerechnen`.
- Session handling via a `dbo.tSessionId` row (`kSessionId`), matching §2 exactly.
- `@nOptions = 258` (`0x102`) for `Auslieferung.spReserviereBestellpositionen` — exact
match with our decompiled-C#-derived value in §3.1.
- `@nOptions = 2` (no `Stücklistenkorrektur` bit) for `Auslieferung.spReserviereBestellungen`
— matches the decompiled C# wrapper for that specific overload, which (unlike the
`...Bestellpositionen` wrapper) does **not** OR in `Stücklistenkorrektur`.
- Real example `@Laeger` XML with multiple prioritised warehouses (`kWarenlager` 1,
4, 5, 6, 7) plus dropshipping-supplier fallback rows (`kLieferant` 3/1/2/`n`,
i.e. default/cheapest/fastest/specific supplier) — confirms the magic-value scheme
documented in §3.1 with a concrete real-world example, and confirms
`@Laeger` supports arbitrarily many `<Lager>` candidates, not just one.
- After delivery, Picklisten/`tPicklistePos` rows are deliberately **kept** (not
deleted) — their `cleanupSessions()` only ever deletes `dbo.tSessionId` (the
`picklistId` branch that would delete `tPickliste`/`tPicklistePos` is called with
`null` and never actually runs). Matches expectations: the Pickliste is the audit
trail the Wawi GUI still shows after delivery, so it must survive.
- Calls `Auslieferung.spPicklistenVerwerfen` again at the very end, even after a
successful `spPicklistenAusliefern`. Checked its SQL body
(`Auslieferung.spPicklistenVerwerfen`, `/tmp/sp_verwerfen.sql`):
```sql
DELETE FROM dbo.tPickliste WHERE kSessionId = @kSessionId AND nStatus < 10
```
It only ever removes *uncommitted* (`nStatus < 10`) Picklisten for the session, so
calling it unconditionally at the end of a successful delivery is harmless/a good
defensive habit (cleans up any leftover reservation that didn't make it into the
delivery, e.g. a position that failed stock checks), not just an error-path call
as §6 step 6 assumed. **Recommendation: call it unconditionally after step 4/5,
not only on error.**
**Contradicts/corrects our findings — resolved in favor of the ground truth:**
- It resolves order positions through legacy `dbo.tBestellung`/`dbo.tBestellPos`
tables, joined back to `Verkauf.tAuftragPosition` by **matching `kArtikel`**
(`ap.kArtikel = bp.tArtikel_kArtikel AND ap.kAuftrag = a.kAuftrag`) rather than by
ID. This is unnecessary and fragile (breaks for two order lines with the same
SKU/article). Checked directly: `Auslieferung.vBestellPos`
(`/tmp/vbestellpos.sql`) —
```sql
CREATE VIEW [Auslieferung].[vBestellPos] AS
SELECT tAuftragPosition.kAuftragPosition AS kBestellPos,
tAuftrag.kAuftrag AS kBestellung, ...
```
confirms **`kBestellPos` *is* `Verkauf.tAuftragPosition.kAuftragPosition` and
`kBestellung` *is* `Verkauf.tAuftrag.kAuftrag`, directly** — "Bestellung"/"BestellPos"
is just the historical/legacy parameter naming carried over from pre-3.0 JTL, not a
separate table to join through. Our `create-order.js` already has these ids
directly (`kAuftrag`, `kAuftragPosition`) — no extra lookup needed, use them as-is
in all the `@Bestellpositionen`/`@Bestellungen`/`@Laeger` XML.
- It calls `Auslieferung.spPicklistenAusliefern` with **`@nOptions = 0`** (no
`VersandSetzen`). This contradicts `DeliveryStep.cs` (POS/API order path), which
explicitly does:
```csharp
auslieferungAusliefernContext.Optionen.VersandSetzen = true;
```
(`decompiledReference/decompiled/jtlCore/jtlCore.Classes.Sync.PosOrderSteps/DeliveryStep.cs:213`,
confirmed present, not just inferred). The other implementation was likely traced
from a manual GUI delivery where the "Versand setzen" checkbox happened to be
unchecked. **For reproducing POS/API-style immediate delivery, use `@nOptions = 2`
(`VersandSetzen`), not `0`, per DeliveryStep's actual behavior — keep §0/§3.3 as
documented.**
- It also creates a `dbo.tUserSession` row (`createJTLSessions`) alongside
`dbo.tSessionId`, mirroring a full GUI login. No stored procedure body read so far
(§3, plus `spWarenlagerAusgangPicklistePos`, `spPicklistenVerwerfen`,
`vBestellPos`) references or validates against `tUserSession` — the only
server-side check seen is `kBenutzer` existing in `dbo.tbenutzer` (a plain FK-style
check, e.g. inside `spWarenlagerAusgangPicklistePos`). **Recommendation: skip
`tUserSession` entirely** unless a not-yet-encountered procedure turns out to
require it.
- Its partial-delivery path (multiple `spReserviereBestellungen` calls interleaved
with manual `DELETE FROM tPicklistePos`/quantity-adjustment dances) is real
evidence of how the Wawi GUI implements *partial* delivery, but is unnecessary
complexity for us — `create-order.js` always delivers the full ordered quantity
(mirrors `DeliveryStep`'s non-voucher path), so the single-pass §0 sequence
(reserve once, commit with `nTeillieferung = 0`, deliver) is sufficient and matches
what `DeliveryStep.Run` itself does for a new POS order.
## 1. C# call chain: HTTP request → delivery commit
1. **`OrderController.Put`**
(`decompiledReference/JTL.Wawi.PosServer.Controller/OrderController.cs:33-67`)
Receives `PUT /api/v1/order`, delegates to `Q5hG5CfaiXW` (line 101-127), which
builds a `PosFrontend<...>` and calls `.ExecuteAsync(new PosOrderCommand {
Origin = OriginType.Bestellung, ... })`.
2. **`PosFrontend.ExecuteAsync` → `PosFrontend.HandleOrders`**
(`decompiledReference/JTL.Wawi.Sync.Core.decompiled.cs:6168-6122`)
Calls `new PosOrderImportService(...).ImportOrders(...)`.
3. **`PosOrderImportService.ImportOrders`**
(`decompiledReference/JTL.Wawi.Sync.Core.decompiled.cs:6231-6281`)
For a brand-new order (no existing `OrderNumber`/`OrderId` match — our case):
`CreateOrUpdatePosAuftrag(order, list, shopSubShopId)` →
`SyncCoreService.Service.CreateOrUpdatePosAuftrag(...)`.
4. **`SyncCoreService.CreateOrUpdatePosAuftrag`**
(`decompiledReference/decompiled/jtlCore/jtlCore.Classes.Sync/SyncCoreService.cs:77-80`)
Delegates to `new PosOrderCreationService(...).CreateOrUpdatePosAuftrag(...)`.
5. **`PosOrderCreationService.CreateOrUpdatePosAuftrag`**
(`decompiledReference/decompiled/jtlCore/jtlCore.Classes.Sync.Pos/PosOrderCreationService.cs:102-221`)
Creates the order via `importer.TrySaveOrder(...)`, then runs a step pipeline:
```
new RoundingErrorStep(...).Run(jtlAuftrag, orderEntity);
new DeliveryStep(...).Run(jtlAuftrag, orderEntity);
new InvoicePrintStep(...).Run(jtlAuftrag, orderEntity);
new VoucherPrintStep(...).Run(jtlAuftrag, orderEntity);
```
6. **`DeliveryStep.Run`**
(`decompiledReference/decompiled/jtlCore/jtlCore.Classes.Sync.PosOrderSteps/DeliveryStep.cs:184-273`)
`bool flag = orderEntity.Settings?.Deliver ?? true;` — this is where `deliver`
from the POS payload is consumed. When true:
- `PosWarehouseService.FindeWarenlagerFürAusgang()` — resolves the outgoing
warehouse (`kWarenLager`) to use. **Still unresolved in this doc — see "Open
questions" below.**
- `AuslieferungAusliefernContext.CreateForAufträge([kAuftrag], ...)` — opens a
delivery context (constructs an `AuslieferungContext`, which immediately opens
a `SessionManager`/`kSessionId`, see §2).
- `auslieferungContext.LadeBestände()` — loads current stock (read-only, for UI).
- For every position with a real `ArtikelId`:
`PosBookPositionService.BookPosition(...)` →
`auslieferungContext.NeuReservieren(positions, warehouseDetails)` → calls
`Auslieferung.spReserviereBestellpositionen` (§3.1).
- For positions without an `ArtikelId` (shipping, deposit/Pfand lines):
`auslieferungContext.NeuReservieren(...)` directly with the full quantity (same
stored procedure).
- If the order is paid (or the payment method allows delivery-before-payment):
`posStockPositionService.FehlbestandEinbuchen(...)` — books any stock shortfall
so delivery isn't blocked by missing stock.
- **`auslieferungAusliefernContext.Commit();`** — persists everything (§2).
- If backdated: `ApTVuv7PTPl.SetVersanddatum(auftrag.kAuftrag, orderEntity.CreationDate)`.
7. **`AuslieferungAusliefernContext.Commit()`**
(`decompiledReference/decompiled/jtlCore/jtlCore.Classes.Versand.Auslieferung/AuslieferungAusliefernContext.cs:223-321`)
```csharp
Validiere();
PicklistenCommitten(); // -> Auslieferung.spPicklistenUebernehmen (§3.2)
...
auslieferungSpResult = IScXmooPhGf(); // -> Auslieferung.spPicklistenAusliefern (§3.3)
...
kHtXmjSeHbm(T5jXmkcZPKf()); // -> Verkauf.spAuftragEckdatenBerechnen (already replicated)
ExecuteWorkflows(auslieferungSpResult); // UI/event notifications only, no DB writes
```
## 2. The session concept (`kSessionId`)
Every reservation/commit call is scoped by a `kSessionId`. This comes from
`jtlCore.Classes.SessionManager` (`decompiledReference/decompiled/jtlCore/jtlCore.Classes/SessionManager.cs`),
which lazily does, on first use:
```csharp
var s = new jtlSessionid {
cRechnername = Environment.MachineName,
kBenutzer = BenutzerManager.Current.kAngemeldeterBenutzer,
dLastAction = DateTime.Now + TimeSpan.FromDays(10.0)
};
s.Save(); // INSERT INTO dbo.tSessionId (...)
s.Touch();
```
`jtlSessionidBase` (`jtlDatabase.dll`, decompiled) maps to table **`dbo.tSessionId`**:
| Column | Type | Notes |
|---------------|-----------------|-----------------------------------------|
| `kSessionId` | `int` identity | PK, returned via `SCOPE_IDENTITY()` |
| `cRechnername`| `nvarchar(255)` | machine name (cosmetic, can be any string) |
| `kBenutzer` | `int` | FK `dbo.tBenutzer` |
| `dLastAction` | `datetime` | kept alive by a 60s "touch" timer; only relevant for GUI housekeeping/timeouts |
So reproducing this is just: **insert one row into `dbo.tSessionId`, read back
`kSessionId`**, use it for the whole delivery operation, nothing else needed (no need
to periodically "touch" it for a short-lived one-shot API request).
On `Dispose()`, if the context was constructed for "new reservation" mode
(`BQ9XLnEpso5 == true`, i.e. our case, an `AuslieferungAusliefernContext`), and
`Commit()` was never called, it rolls back via `Auslieferung.spPicklistenVerwerfen`
instead. We don't need this if we always commit; keep it in mind for error handling
(wrap in try/catch and call `spPicklistenVerwerfen` on failure to avoid leaving orphan
Picklisten in that session).
## 3. The stored procedures, in call order
All four procedures below live in `jtlDatabase.classes.jtlDBClasses.StoredProcedures`
(decompiled with `ilspycmd -t jtlDatabase.classes.jtlDBClasses.StoredProcedures
jtlDatabase.dll`) and their exact ADO.NET parameter wiring is unobfuscated. Their SQL
bodies were read directly via `sp_helptext` against `eazybusiness`.
### 3.1 `Auslieferung.spReserviereBestellpositionen` — soft-reserve positions
C# wrapper (`StoredProcedures.cs:1413-1440`):
```csharp
private static int? Auslieferung_spReserviereBestellpositionen(
string Bestellpositionen, string Laeger, string Warenlagereingaenge,
int? nOptions, int? kBenutzer, int? kSessionId, IDbConnection connection = null)
```
SQL parameters (`@Bestellpositionen XML, @Laeger XML, @Warenlagereingaenge XML,
@nOptions INT, @kBenutzer INT, @kSessionId INT`):
```xml
<!-- @Bestellpositionen: one per order position to reserve -->
<Bestellposition>
<kBestellPos>1</kBestellPos> <!-- = Verkauf.tAuftragPosition.kAuftragPosition -->
<fAnzahl>1.234</fAnzahl> <!-- quantity to reserve -->
</Bestellposition>
<!-- @Laeger: candidate warehouses, tried in nPrio order -->
<Lager>
<kWarenlager>1</kWarenlager>
<nPrio>0</nPrio>
<kLieferant>0</kLieferant> <!-- 0 = normal; -1/-2/-3 = cheapest/fastest/default supplier (dropshipping) -->
<kAnsprechpartner>0</kAnsprechpartner>
</Lager>
<!-- @Warenlagereingaenge: optional, specific goods-receipt rows (FIFO/batch/serial) — leave empty/NULL for "any" -->
<Warenlagereingang>
<kWarenlagereingang>17</kWarenlagereingang>
<fAnzahl>2.0</fAnzahl>
<nPrio>0</nPrio>
</Warenlagereingang>
```
`nOptions` bitmask (`StoredProcedures.ReserviereBestellpositionenOptionen`):
`0x0001 PicklisteProBestellung`, `0x0002 ChargenVorbelegen`, `0x0100
Stücklistenkorrektur`. The C# `AuslieferungContext` ctor always sets
`ChargenVorbelegen` (`0x0002`), and the low-level SP wrapper always OR's in
`Stücklistenkorrektur` (`0x0100`) — so the effective default is **`0x0102` (258)**
unless "one Pickliste per order" is also wanted (`+0x0001`).
Behavior (from `Auslieferung.spReserviereBestellposition`, called once per position
in a cursor loop, `/tmp/sp_reserviere_single.sql`):
- Resolves candidate stock sources from `@Laeger`/`@Warenlagereingaenge` into a temp
table (`#WARENLAGEREINGANG`), ordered by `nPrio`, `dMHD`, `cCharge`,
`kWarenlagereingang`.
- For each source, in order, until `@fAnzahl` is exhausted:
- Finds or creates a `dbo.tPickliste` row (`kSessionId = @kSessionId`, `nStatus =
0`, `cPicklisteNr` from `dbo.spGetNextNummer('Pickliste', ...)`). Reused across
positions of the same session/warehouse/supplier (one Pickliste per
warehouse+supplier+session, unless `PicklisteProBestellung` requested).
- Inserts a `dbo.tPicklistePos` row: `(kPickliste, kWarenLager, kWarenLagerEingang,
fAnzahl, kBestellPos, kPicklistePosStatus=0, kArtikel, kWarenlagerPlatz,
kPicklistePos_Ursprung=0, kLieferscheinPos=0, kBestellung)`.
- After the loop: deletes zero-quantity `tPicklistePos` rows for this
`kBestellPos`/session, then inserts a `dbo.tPicklistePosStatus` row with
**`nStatus = 5`** ("angelegt"/created) for every newly created `tPicklistePos`
(`kPicklistePosStatus = 0`).
This is the step that's entirely missing from our code — **this is why no Pickliste
exists for jtlsrv orders.**
### 3.2 `Auslieferung.spPicklistenUebernehmen` — commit reservations into "real" Picklisten
C# wrapper (`StoredProcedures.cs:1358-1386`):
```csharp
private static int? Auslieferung_spPicklistenUebernehmen(
string Bestellungen, int? kBenutzer, bool? nTeillieferung, int? kSessionId,
ref string xResult, IDbConnection connection = null)
```
SQL parameters (`@Bestellungen XML, @kBenutzer INT, @nTeillieferung BIT, @kSessionId
INT, @xResult XML OUTPUT`):
```xml
<Bestellung>
<kBestellung>7</kBestellung> <!-- Verkauf.tAuftrag.kAuftrag, one element per order -->
</Bestellung>
```
`@nTeillieferung`: `0` = partial delivery forbidden (raises an error if any reserved
order has both open and already-picked quantity outstanding — not our concern for a
"deliver everything now" POS order, so pass `0`); the C# side derives it from
`AuslieferungTeillieferungOptionen.GetCodeForSql() >= 1`.
Behavior (`/tmp/sp_uebernehmen.sql`):
- If `@nTeillieferung = 0`, verifies no order in `@Bestellungen` would end up
partially delivered; raises `RAISERROR` with an XML error payload otherwise.
- Deletes now-superfluous "Stücklistenvater" pick positions and any now-empty
Picklisten in this session.
- Bumps every `dbo.tPicklistePos` row in this session from status `< 10` to
**`nStatus = 10`** ("übernommen"/committed) via a new `tPicklistePosStatus` row.
- For a subset of position types (`tbestellpos.nType IN (18, 20)`) bumps further to
**`nStatus = 20`**.
- Returns `@xResult`:
```xml
<Result><NewPicklisten><Pickliste><kPickliste>...</kPickliste></Pickliste>...</NewPicklisten></Result>
```
### 3.3 `Auslieferung.spPicklistenAusliefern` — the actual delivery
C# wrapper (`StoredProcedures.cs:1327-1356`) — **this is the procedure the user
correctly identified as missing:**
```csharp
private static int? Auslieferung_spPicklistenAusliefern(
string xHinweise, string Pakete, int? nOptions, int? kBenutzer, int? kSessionId,
ref string xResult, IDbConnection connection = null)
```
SQL parameters (`@xHinweise XML, @Pakete XML, @nOptions INT, @kBenutzer INT,
@kSessionId INT, @xResult XML OUTPUT`):
```xml
<!-- @xHinweise: optional per-position/per-order notes copied onto the Lieferschein(Pos); can be empty/NULL -->
<Hinweis>
<kBestellPos>5</kBestellPos> <!-- OR <kBestellung>7</kBestellung> for the order-level note -->
<cHinweis>blah</cHinweis>
</Hinweis>
<!-- @Pakete: optional shipping/tracking info; can be empty/NULL for "no shipment info yet" -->
<Paket>
<kBestellung>1</kBestellung>
<kVersandart>5</kVersandart>
<dVersanddatum>...</dVersanddatum>
<cTrackingId>trackingid</cTrackingId>
<cEnclosedReturnIdentCode>...</cEnclosedReturnIdentCode>
<cHinweis>...</cHinweis>
</Paket>
```
`@nOptions` bitmask (`StoredProcedures.PicklistenAusliefernOptionen`): `0x0001
LieferscheinProLager` (one Lieferschein per warehouse), `0x0002 VersandSetzen` (also
create a `dbo.tVersandInfo`/package row + set shipped date on local-warehouse
deliveries). `DeliveryStep` always sets `VersandSetzen`, so the effective value is
**`2`** (or `3` if also splitting by warehouse).
Behavior (`/tmp/sp_ausliefern.sql`, delegates most of the work to sub-procedures, all
scoped to `WHERE ... kSessionId = @kSessionId`):
1. Validates (raises on partially-assigned serial numbers / empty serials for
serial-tracked articles — not relevant unless articles use `cLagerArtikel = 'Y'`
serial tracking).
2. **`Auslieferung.spPicklistenAusliefern_LokaleLager`** (`/tmp/sp_lokal.sql`) — the
part that matters for a normal local-warehouse delivery:
- Builds `@xLieferschein` XML for every order that has committed `tPicklistePos`
rows in this session on a local warehouse (`tWarenLager.nFulfillment = 0`), and
calls **`Versand.spLieferscheinErstellen`** (same proc our code already calls,
but now driven from the Pickliste data, with a properly numbered
`cLieferscheinNr` = `<Auftragsnummer>-NNN`).
- Builds `@xLieferscheinPos` XML (`kLieferschein`, `kBestellPos`,
`fAnzahl` summed per position) and calls **`Versand.spLieferscheinPosErstellen`**
(again, same proc, but now the quantities come from the Picklisten, not
directly from the order).
- **Links the new `kLieferscheinPos` back onto the `tPicklistePos` rows**
(`UPDATE dbo.tPicklistePos SET kLieferscheinPos = ...`) — this is the row our
current code never populates, which is presumably one of the visible
differences you saw between order 25 and order 26.
3. `Auslieferung.spPicklistenAusliefern_Fulfillment` /
`..._Dropshipping` — not relevant for local-warehouse orders.
4. Marks serial-numbered stock items (`dbo.tlagerartikel`) with the resulting
`kBestellPos`/`kLieferscheinPos` (only relevant for `cLagerArtikel = 'Y'`
articles).
5. **Real stock decrement**: builds `@xPicklistePos` (`kPicklistePos`, `kBenutzer`,
`cKommentar`) for every committed local-warehouse `tPicklistePos` row and calls
**`dbo.spWarenlagerAusgangPicklistePos`** (`/tmp/sp_ausgang.sql`), which in turn
builds a `WarenAusgang` XML per position (`kWarenLagerEingang`, `kLieferscheinPos`,
`fAnzahl`, `kWarenlagerPlatz`, `kArtikel`, `kBuchungsart = 20`) and calls
**`dbo.spWarenlagerAusgangSchreiben`** — the actual, final stock-decrement
procedure (writes the goods-issue row and reduces
`tWarenLagerEingang.fAnzahlAktuell`/warehouse stock). **This is the step that
explains the "no real stock decrement" symptom.**
6. Bumps `tPicklistePos` status to **`nStatus = 40`** ("ausgeliefert"/delivered).
7. Copies `@xHinweise` notes onto the new `tLieferschein`/`tLieferscheinPos` rows.
8. `Auslieferung.spPicklistenAusliefern_PaketeErzeugen` (`/tmp/sp_paketeerzeugen.sql`) —
creates the `dbo.tVersand` row(s) and, because `VersandSetzen` is set, marks
local-warehouse deliveries as shipped (`dVersendet = GETDATE()`).
- **Gotcha confirmed by testing**: this sub-proc builds a `#XMLPAKET` temp table
straight from `@Pakete.nodes('/Paket')` and then drives *every* `INSERT INTO
dbo.tVersand` off a join against that temp table (keyed by `kBestellung`). If
`@Pakete` is `NULL`/empty, `#XMLPAKET` has zero rows and the join finds
nothing for **any** order in the session — not even the "no explicit Paket"
fallback branch runs, because that branch is also reached only via the
`cLieferschein` cursor which is itself driven by a `JOIN #XMLPAKET`. Net
effect: passing `@Pakete = NULL` silently skips `dbo.tVersand` entirely, no
error, no shipment method recorded — this is exactly the bug the user
reported (rows shown by the user all have `kVersandArt = 2` /
"Selbstabholer" and a real `dVersendet`, which only happens when `@Pakete`
contains a matching `<Paket>` element for that order).
- Fix: always pass at least
`<Paket><kBestellung>{kAuftrag}</kBestellung><kVersandart>{kVersandArt}</kVersandart><fGewicht>0</fGewicht></Paket>`
(using the order's own `Verkauf.tAuftrag.kVersandArt`). `fGewicht` is a
`NOT NULL` column with no default, so it must be present in the XML — `0` is
safe, it's the exact sentinel value this same proc later looks for
(`WHERE dbo.tVersand.fGewicht = 0`) to recompute a real weight from the
order's article weights a few statements later.
- `cIdentCode`/`cTrackingId`, `dVersanddatum`, `cEnclosedReturnIdentCode`,
`cHinweis` are all optional and can be added later (e.g. once real carrier
integration exists) without changing anything else in this flow.
9. `Auslieferung.spPicklistenAusliefern_Umlagerungen` — stock-transfer orders only,
not relevant here.
10. Returns `@xResult`:
```xml
<Result>
<NewDeliveryNotes><DeliveryNote><kLieferschein>...</kLieferschein></DeliveryNote>...</NewDeliveryNotes>
<ProcessedPicklisten><Pickliste><kPickliste>...</kPickliste></Pickliste>...</ProcessedPicklisten>
<NewFulfillmentauftraege>...</NewFulfillmentauftraege>
<NewLieferantenbestellungen>...</NewLieferantenbestellungen>
<DeliveredOrders><Order><kBestellung>...</kBestellung></Order>...</DeliveredOrders>
<DeliveredUmlagerungen>...</DeliveredUmlagerungen>
</Result>
```
(schema confirmed unobfuscated in `StoredProcedures.AuslieferungSpResult`,
`StoredProcedures.cs:52-186`)
### 3.4 `Verkauf.spAuftragEckdatenBerechnen`
Already correctly called by our existing code (`create-order.js`); recalculates
`nKomplettAusgeliefert` and other derived order fields from the (now populated)
Lieferschein/Pickliste data. No change needed here, just needs to run *after* step
3.3 instead of instead of it.
## 4. `tPicklistePos.nStatus` progression (for reference)
| Status | Meaning | Set by |
|--------|---------------------------------------|-------------------------------------------|
| 0 | just inserted | `spReserviereBestellposition` |
| 5 | reserved / "angelegt" | `spReserviereBestellpositionen` (end) |
| 10 | committed / "übernommen" | `spPicklistenUebernehmen` |
| 20 | committed (special position types 18/20) | `spPicklistenUebernehmen` |
| 40 | delivered / "ausgeliefert" | `spPicklistenAusliefern` (via `_LokaleLager`) |
## 5. What our code does today (`src/queries/create-order.js`)
`deliverOrder()` calls `Versand.spLieferscheinErstellen` +
`Versand.spLieferscheinPosErstellen` directly per position — i.e. it starts at
step 3.3's *inner* Lieferschein-creation calls, skipping everything before it
(§3.1 reserve, §3.2 commit) and everything after within 3.3 (Pickliste linking,
real stock decrement, package/ship-date handling). It happens to still flip
`nKomplettAusgeliefert` because `spLieferscheinPosErstellen`/our subsequent call to
`Verkauf.spAuftragEckdatenBerechnen` don't care where the Lieferschein numbers came
from — they just see delivered quantities recorded on `tLieferschein`. Net effect:
- No `dbo.tPickliste` / `dbo.tPicklistePos` rows.
- No real warehouse stock (`dbo.tWarenLagerEingang.fAnzahlAktuell` /
`dbo.tlagerbestand`) decrement.
- `cLieferscheinNr` isn't numbered per-order the way the real flow does it
(`<AuftragsNr>-001`).
## 6. Plan to reproduce exactly
Rewrite `deliverOrder()` in `create-order.js` to, per order:
1. `INSERT INTO dbo.tSessionId (cRechnername, kBenutzer, dLastAction) VALUES
('jtlsrv', @kBenutzer, DATEADD(day, 10, GETDATE()))`, read back `kSessionId`
(`SCOPE_IDENTITY()`).
2. For every order position with a real `kArtikel` (skip pure text/shipping-only
rows if the article resolves to 0 — those go through the "Freiposition" path in
§3.1, which our `@Laeger` XML already supports the same way): build
`@Bestellpositionen` (one `<Bestellposition>` per position, full ordered
quantity) and `@Laeger` (single `<Lager>` with the resolved outgoing
`kWarenlager`, `nPrio=0`, `kLieferant=0`, `kAnsprechpartner=0`), leave
`@Warenlagereingaenge` `NULL`, `@nOptions = 0x0102` (`ChargenVorbelegen |
Stücklistenkorrektur`). Call `Auslieferung.spReserviereBestellpositionen`.
3. Call `Auslieferung.spPicklistenUebernehmen` with `@Bestellungen =
<Bestellung><kBestellung>{kAuftrag}</kBestellung></Bestellung>`,
`@nTeillieferung = 0`, same `@kSessionId`.
4. Call `Auslieferung.spPicklistenAusliefern` with `@xHinweise = NULL`, `@Pakete =
<Paket><kBestellung>{kAuftrag}</kBestellung><kVersandart>{kVersandArt}</kVersandart><fGewicht>0</fGewicht></Paket>`
(using the order's own `Verkauf.tAuftrag.kVersandArt`; **must not be `NULL`** or
`dbo.tVersand` never gets a row for this order at all — see §3.3 step 8),
`@nOptions = 2` (`VersandSetzen`), same `@kSessionId`.
5. Call `Verkauf.spAuftragEckdatenBerechnen(@kAuftrag)` (already implemented).
6. On any failure in 2-4, call `Auslieferung.spPicklistenVerwerfen(@kBenutzer,
@kSessionId)` to discard the session's half-finished Picklisten before
re-throwing.
7. Optionally `DELETE FROM dbo.tSessionId WHERE kSessionId = @kSessionId` at the
end (mirrors `SessionManager.Dispose()`); not strictly required for
correctness since it only affects the GUI's "who's editing what" bookkeeping,
but keeps `tSessionId` from accumulating rows.
## 7. Open questions / remaining unknowns
- **Outgoing warehouse resolution** (`PosWarehouseService.FindeWarenlagerFürAusgang()`,
`jtlCore`, obfuscated): need to determine which `dbo.tWarenLager.kWarenLager` to
put in `@Laeger`. Likely candidates: the single active local warehouse
(`nFulfillment = 0`) if there's only one, or a per-shop/subshop default warehouse
setting somewhere in `dbo.tShopSubshop`/shop config. Needs to be resolved (e.g. by
decompiling `PosWarehouseService` from `jtlCore.dll` with `ilspycmd`, or reading
`sp_helptext` of whatever proc/view it ultimately queries) before step 2 above can
pick the right warehouse automatically instead of hard-coding it.
- **Article resolution / stock articles vs. free positions**: `spReserviereBestellposition`
branches heavily on `tArtikel.cLagerVariation`/`cLagerAktiv`/`cLagerArtikel` and
dropshipping supplier data; our current order items are simple retail SKUs, so the
"Artikel mit Lagerbestand (Warenläger)" branch should apply, but this needs
verification against a real low/zero-stock article to see how shortfalls are
handled (`PosStockPositionService.FehlbestandEinbuchen` in `DeliveryStep`, not yet
traced).
- **Serial-number / batch tracked articles**: out of scope for now (our catalog
doesn't seem to use `cLagerArtikel = 'Y'` serial tracking), but `spPicklistenAusliefern`
will raise an error (`RAISERROR(..., 18, 3)` / `(..., 18, 6)`) if it ever does and we
don't supply serial numbers via `Auslieferung.spReserviereSeriennummern` first.
## 8. Implementation status
Implemented in `src/queries/delivery/` (`session.js`, `warehouse.js`, `reserve.js`,
`commit.js`, `deliver.js`, `index.js`), wired into `create-order.js` in place of the
old `Versand.spLieferscheinErstellen`/`spLieferscheinPosErstellen` shortcut. See
those files' doc comments for the mapping back to the steps in §0/§6.
Verified end-to-end against a live test order in `Mandant_3`: real stock decrement,
`dbo.tPickliste`/`tPicklistePos` created with `nStatus = 40` and a real
`kLieferscheinPos` link, properly numbered `Lieferschein` (`<AuftragsNr>-001`),
`nKomplettAusgeliefert = 1`, and the session row cleaned up afterward.
One gotcha found only through this live testing, not visible from the decompiled
C# alone (**`AuslieferungAusliefernContext`/`PosBookPositionService` build their own
`Paket`-equivalent state in-memory before calling the stored procedure, so this
particular pitfall is specific to calling the raw SQL procedure directly**): passing
`@Pakete = NULL` to `Auslieferung.spPicklistenAusliefern` silently skips `dbo.tVersand`
row creation entirely (see §3.3 step 8 for the full mechanism). Fixed by always
passing a `<Paket>` element with the order's `kVersandArt` (and a `fGewicht = 0`
sentinel, since the column is `NOT NULL`). This is what actually makes
`dbo.tVersand` end up with a row like `kVersandArt = 2` ("Selbstabholer") and a real
`dVersendet`, matching native JTL-Wawi/POS orders.
A second, unrelated tedious/mssql driver limitation was hit and fixed: the `xml` SQL
type does not reliably round-trip through `sql.Xml` typed parameters/output
parameters for these particular procedures (`Implicit conversion from data type xml
to nvarchar is not allowed`). Worked around by passing XML as `NVarChar(MAX)` and
`CONVERT(XML, @param)`-ing it inside the SQL batch itself, and reading `@xResult
OUTPUT` back via a trailing `SELECT` instead of a driver-level output parameter.

2
package-lock.json generated
View File

@@ -16,7 +16,7 @@
"sharp": "^0.35.3" "sharp": "^0.35.3"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=22"
} }
}, },
"node_modules/@azure-rest/core-client": { "node_modules/@azure-rest/core-client": {

View File

@@ -8,7 +8,7 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"cert": "node generate-cert.js", "cert": "node generate-cert.js",
"start": "node server.js", "start": "node --watch server.js",
"test:client": "node test-client.js" "test:client": "node test-client.js"
}, },
"engines": { "engines": {

View File

@@ -10,6 +10,7 @@ import { createPairingStore } from './src/pairing.js';
import { closeOrderLog } from './src/order-log.js'; import { closeOrderLog } from './src/order-log.js';
import { closeRequestLog, logRequest } from './src/request-log.js'; import { closeRequestLog, logRequest } from './src/request-log.js';
import { logger } from './src/logger.js'; import { logger } from './src/logger.js';
import { fetchActiveShop } from './src/shop.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -123,8 +124,10 @@ const httpsServer = https.createServer(
async function start() { async function start() {
try { try {
await connectDb(); const pool = await connectDb();
logger.success(`MSSQL connected: ${process.env.MSSQL_SERVER}/${process.env.MSSQL_DATABASE}`); logger.success(`MSSQL connected: ${process.env.MSSQL_SERVER}/${process.env.MSSQL_DATABASE}`);
const activeShop = await fetchActiveShop(pool);
logger.info(`Active shop ID: ${activeShop}`);
} catch (err) { } catch (err) {
logger.warn(`MSSQL connection skipped: ${err.message}`); logger.warn(`MSSQL connection skipped: ${err.message}`);
logger.warn('POS handshake will still work; sync from database is not available yet.'); logger.warn('POS handshake will still work; sync from database is not available yet.');

View File

@@ -1,5 +1,7 @@
import { sendJson } from '../http.js'; import { sendJson } from '../http.js';
import { logger } from '../logger.js';
import { logOrder } from '../order-log.js'; import { logOrder } from '../order-log.js';
import { createOrder } from '../queries/create-order.js';
export const method = 'POST'; export const method = 'POST';
export const path = '/v1/order'; export const path = '/v1/order';
@@ -33,16 +35,29 @@ export async function handle(req, res) {
} }
const orders = getOrders(body); const orders = getOrders(body);
const results = orders.map((order) => { const results = [];
for (const order of orders) {
logOrder(order); logOrder(order);
const externalOrderId = String(order?.externalId ?? ''); const externalOrderId = String(order?.externalId ?? '');
return { try {
const created = await createOrder(order);
logger.success(`order ${created.orderNumber} (kAuftrag=${created.orderId}) created for externalId=${externalOrderId}`);
results.push({
status: 'OK', status: 'OK',
externalOrderId, externalOrderId,
message: '', message: '',
};
}); });
} catch (err) {
logger.error(`order externalId=${externalOrderId} failed: ${err.message}`);
results.push({
status: 'ERROR',
externalOrderId,
message: err.message,
});
}
}
return sendJson(res, 200, results); return sendJson(res, 200, results);
} }

View File

@@ -7,6 +7,7 @@ ${CATEGORY_TREE_CTE}
SELECT COUNT(*) AS CategoryCount SELECT COUNT(*) AS CategoryCount
FROM dbo.tKategorie k FROM dbo.tKategorie k
WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree) WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree)
AND (@kShop = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks WHERE ks.kKategorie = k.kKategorie AND ks.kShop = @kShop))
AND CONVERT(BIGINT, k.bRowversion) > @cursor; AND CONVERT(BIGINT, k.bRowversion) > @cursor;
`; `;

View File

@@ -3,8 +3,6 @@ import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js'; import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1; const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1;
const IMAGE_PLATFORM_ID = Number(process.env.IMAGE_PLATFORM_ID) || 1;
const IMAGE_SHOP_ID = Number(process.env.IMAGE_SHOP_ID) || 0;
const CATEGORY_LIST_SQL = ` const CATEGORY_LIST_SQL = `
${CATEGORY_TREE_CTE} ${CATEGORY_TREE_CTE}
@@ -18,10 +16,11 @@ SELECT TOP (@limit)
FROM dbo.tKategorie k FROM dbo.tKategorie k
INNER JOIN dbo.tKategorieSprache ks ON ks.kKategorie = k.kKategorie AND ks.kSprache = @languageId INNER JOIN dbo.tKategorieSprache ks ON ks.kKategorie = k.kKategorie AND ks.kSprache = @languageId
LEFT JOIN dbo.tKategoriebildPlattform kbp LEFT JOIN dbo.tKategoriebildPlattform kbp
ON kbp.kKategorie = k.kKategorie AND kbp.kPlattform = @imagePlatformId AND kbp.kShop = @imageShopId ON kbp.kKategorie = k.kKategorie
LEFT JOIN dbo.tBild b ON b.kBild = kbp.kBild LEFT JOIN dbo.tBild b ON b.kBild = kbp.kBild
WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree WHERE kKategorie <> @rootCategoryId) WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree WHERE kKategorie <> @rootCategoryId)
AND k.cAktiv = 'Y' AND k.cAktiv = 'Y'
AND (@kShop = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks2 WHERE ks2.kKategorie = k.kKategorie AND ks2.kShop = @kShop))
AND CONVERT(BIGINT, k.bRowversion) > @cursor AND CONVERT(BIGINT, k.bRowversion) > @cursor
ORDER BY lastChanged ASC; ORDER BY lastChanged ASC;
`; `;
@@ -31,8 +30,6 @@ export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId =
.input('cursor', sql.BigInt, cursor) .input('cursor', sql.BigInt, cursor)
.input('limit', sql.Int, limit) .input('limit', sql.Int, limit)
.input('languageId', sql.Int, LANGUAGE_ID) .input('languageId', sql.Int, LANGUAGE_ID)
.input('imagePlatformId', sql.Int, IMAGE_PLATFORM_ID)
.input('imageShopId', sql.Int, IMAGE_SHOP_ID)
.query(CATEGORY_LIST_SQL); .query(CATEGORY_LIST_SQL);
return result.recordset.map((row) => ({ return result.recordset.map((row) => ({
@@ -40,7 +37,7 @@ export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId =
imghash: row.imgHash ?? null, imghash: row.imgHash ?? null,
imgsrc: row.imgHash ?? null, imgsrc: row.imgHash ?? null,
name: row.name, name: row.name,
pid: String(row.pid), pid: row.pid === rootCategoryId ? '0' : String(row.pid),
discounts: [], discounts: [],
sort: String(row.sort), sort: String(row.sort),
lastChanged: String(row.lastChanged), lastChanged: String(row.lastChanged),

View File

@@ -1,4 +1,5 @@
import sql from 'mssql'; import sql from 'mssql';
import { getActiveShopId } from '../shop.js';
export const CATEGORY_TREE_CTE = ` export const CATEGORY_TREE_CTE = `
WITH CategoryTree AS ( WITH CategoryTree AS (
@@ -14,5 +15,8 @@ export function getRootCategoryId() {
} }
export function categoryTreeRequest(pool, rootCategoryId = getRootCategoryId()) { export function categoryTreeRequest(pool, rootCategoryId = getRootCategoryId()) {
return pool.request().input('rootCategoryId', sql.Int, rootCategoryId); return pool
.request()
.input('rootCategoryId', sql.Int, rootCategoryId)
.input('kShop', sql.Int, getActiveShopId());
} }

View File

@@ -1,11 +1,17 @@
import sql from 'mssql'; import sql from 'mssql';
import { getPool } from '../db.js'; import { getPool } from '../db.js';
import { getActiveShopId } from '../shop.js';
const COMPOSITE_PRODUCT_COUNT_SQL = ` const COMPOSITE_PRODUCT_COUNT_SQL = `
SELECT COUNT(DISTINCT a.kArtikel) AS CompositeProductCount SELECT COUNT(DISTINCT a.kArtikel) AS CompositeProductCount
FROM dbo.tArtikel a FROM dbo.tArtikel a
INNER JOIN dbo.tStueckliste s ON s.kStueckliste = a.kStueckliste INNER JOIN dbo.tStueckliste s ON s.kStueckliste = a.kStueckliste
WHERE a.kStueckliste <> 0 WHERE a.kStueckliste <> 0
AND (@kShop = 0 OR EXISTS (
SELECT 1 FROM dbo.tKategorieArtikel ka
INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie AND ks.kShop = @kShop
WHERE ka.kArtikel = a.kArtikel
))
AND CONVERT(BIGINT, a.bRowversion) > @cursor; AND CONVERT(BIGINT, a.bRowversion) > @cursor;
`; `;
@@ -13,6 +19,7 @@ export async function getCompositeProductCount({ cursor = 0 } = {}) {
const result = await getPool() const result = await getPool()
.request() .request()
.input('cursor', sql.BigInt, cursor) .input('cursor', sql.BigInt, cursor)
.input('kShop', sql.Int, getActiveShopId())
.query(COMPOSITE_PRODUCT_COUNT_SQL); .query(COMPOSITE_PRODUCT_COUNT_SQL);
return result.recordset[0]?.CompositeProductCount ?? 0; return result.recordset[0]?.CompositeProductCount ?? 0;
} }

View File

@@ -1,5 +1,6 @@
import sql from 'mssql'; import sql from 'mssql';
import { getPool } from '../db.js'; import { getPool } from '../db.js';
import { getActiveShopId } from '../shop.js';
const COMPOSITE_PRODUCT_LIST_SQL = ` const COMPOSITE_PRODUCT_LIST_SQL = `
SELECT TOP (@limit) SELECT TOP (@limit)
@@ -10,6 +11,11 @@ SELECT TOP (@limit)
FROM dbo.tStueckliste s FROM dbo.tStueckliste s
INNER JOIN dbo.tArtikel a ON a.kArtikel = s.kVaterArtikel INNER JOIN dbo.tArtikel a ON a.kArtikel = s.kVaterArtikel
WHERE a.kStueckliste <> 0 WHERE a.kStueckliste <> 0
AND (@kShop = 0 OR EXISTS (
SELECT 1 FROM dbo.tKategorieArtikel ka
INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie AND ks.kShop = @kShop
WHERE ka.kArtikel = a.kArtikel
))
AND CONVERT(BIGINT, a.bRowversion) > @cursor AND CONVERT(BIGINT, a.bRowversion) > @cursor
ORDER BY lastChanged ASC; ORDER BY lastChanged ASC;
`; `;
@@ -19,6 +25,7 @@ export async function getCompositeProductList({ cursor = 0, limit = 100 } = {})
.request() .request()
.input('cursor', sql.BigInt, cursor) .input('cursor', sql.BigInt, cursor)
.input('limit', sql.Int, limit) .input('limit', sql.Int, limit)
.input('kShop', sql.Int, getActiveShopId())
.query(COMPOSITE_PRODUCT_LIST_SQL); .query(COMPOSITE_PRODUCT_LIST_SQL);
return result.recordset.map((row) => ({ return result.recordset.map((row) => ({

520
src/queries/create-order.js Normal file
View File

@@ -0,0 +1,520 @@
import sql from 'mssql';
import { getPool } from '../db.js';
import { getActiveShopId, getActiveShopSubshopId } from '../shop.js';
import { deliverOrder } from './delivery/index.js';
const config = {
kBenutzer: Number(process.env.JTL_KBENUTZER) || 1,
kFirmaHistory: Number(process.env.JTL_KFIRMAHISTORY) || 0,
kSprache: Number(process.env.JTL_KSPRACHE) || 1,
kPlattform: Number(process.env.JTL_KPLATTFORM) || 7,
kVersandArt: Number(process.env.JTL_KVERSANDART) || 0,
kKundengruppe: Number(process.env.JTL_KKUNDENGRUPPE) || 0,
// kLaufendeNummer 3 = Auftrag, 6 = Kunde in dbo.tLaufendeNummern
orderNumberSequence: Number(process.env.JTL_ORDER_NUMBER_SEQUENCE) || 3,
customerNumberSequence: Number(process.env.JTL_CUSTOMER_NUMBER_SEQUENCE) || 6,
};
/** DB-dependent foreign keys, resolved once on first order. */
let resolvedDefaults = null;
async function getDefaults() {
if (resolvedDefaults) {
return resolvedDefaults;
}
const result = await getPool().request().input('kPlattform', sql.Int, config.kPlattform).query(`
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 = @kPlattform) THEN @kPlattform ELSE 1 END) AS kPlattform;
`);
const row = result.recordset[0] || {};
resolvedDefaults = {
kFirmaHistory: config.kFirmaHistory || row.kFirmaHistory || 1,
kVersandArt: config.kVersandArt || row.kVersandArt || 1,
kKundengruppe: config.kKundengruppe || row.kKundengruppe || 1,
kPlattform: row.kPlattform || 1,
};
return resolvedDefaults;
}
const COUNTRY_NAMES = {
DE: 'Deutschland',
AT: 'Österreich',
CH: 'Schweiz',
};
function toNumber(value, fallback = 0) {
const n = parseFloat(String(value ?? '').replace(',', '.'));
return Number.isFinite(n) ? n : fallback;
}
// JTL maps VAT rates to Steuerklassen: 19% => 1 (voller Satz), 7% => 2 (ermäßigt)
function steuerklasseForVat(vat) {
if (vat >= 15) return 1;
if (vat > 0) return 2;
return 1;
}
function isoWeek(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
}
/**
* Resolves the Admin > Nummernkreise placeholders used in tLaufendeNummern's
* cPrefix/cSuffix columns: J = Jahr, M = Monat, T = Tag, K = Kalenderwoche.
*/
function formatNumberPlaceholders(template, date) {
if (!template) return '';
const pad2 = (n) => String(n).padStart(2, '0');
return template
.replace(/<J>/g, String(date.getFullYear()))
.replace(/<M>/g, pad2(date.getMonth() + 1))
.replace(/<T>/g, pad2(date.getDate()))
.replace(/<K>/g, pad2(isoWeek(date)));
}
/**
* Atomically increments nNummer for a dbo.tLaufendeNummern row and returns
* the fully formatted number (cPrefix + nNummer + cSuffix, with placeholders
* resolved against `date`).
*/
async function nextNumberFromSequence(transaction, kLaufendeNummer, date) {
const result = await new sql.Request(transaction).input('kLaufendeNummer', sql.Int, kLaufendeNummer).query(`
DECLARE @n INT, @cPrefix NVARCHAR(50), @cSuffix NVARCHAR(50);
UPDATE dbo.tLaufendeNummern
SET @n = nNummer = nNummer + 1, @cPrefix = cPrefix, @cSuffix = cSuffix
WHERE kLaufendeNummer = @kLaufendeNummer;
SELECT @n AS nNummer, @cPrefix AS cPrefix, @cSuffix AS cSuffix;
`);
const row = result.recordset[0];
if (!row || row.nNummer == null) {
throw new Error(`dbo.tLaufendeNummern has no row ${kLaufendeNummer}`);
}
const prefix = formatNumberPlaceholders(row.cPrefix, date);
const suffix = formatNumberPlaceholders(row.cSuffix, date);
return `${prefix}${row.nNummer}${suffix}`;
}
/**
* Allocates the next primary key for a table via dbo.tpk
* (JTL tables like tZahlung/tZahlungsart have no identity column).
*/
async function allocatePk(transaction, tableName) {
const result = await new sql.Request(transaction).input('cName', sql.NVarChar, tableName).query(`
DECLARE @pk INT;
UPDATE dbo.tpk SET @pk = nummer, nummer = nummer + 1, dChanged = GETDATE() WHERE cName = @cName;
SELECT @pk AS pk;
`);
const pk = result.recordset[0]?.pk;
if (pk == null) {
throw new Error(`dbo.tpk has no row for table '${tableName}'`);
}
return pk;
}
/**
* 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());
if (cached) {
return cached;
}
const existing = await new sql.Request(transaction)
.input('cName', sql.NVarChar, key)
.query('SELECT TOP 1 kZahlungsart, cName FROM dbo.tZahlungsart WHERE cName = @cName');
let zahlungsart;
if (existing.recordset[0]) {
zahlungsart = {
kZahlungsart: existing.recordset[0].kZahlungsart,
cName: existing.recordset[0].cName,
};
} else {
const kZahlungsart = await allocatePk(transaction, 'tZahlungsart');
await new sql.Request(transaction)
.input('kZahlungsart', sql.Int, kZahlungsart)
.input('cName', sql.NVarChar, key)
.query(`
INSERT INTO dbo.tZahlungsart
(kZahlungsart, cName, cPrtString, nLastschrift, cPrtStringVor, cPaymentOption, cKonto,
nAusliefernVorZahlung, nPrioritaet, nMahnwesenAktiv, fSkontoWert, nSkontoZeitraum,
nMatchingOptionen, nIstStandard, nAktiv)
VALUES (@kZahlungsart, @cName, '', 0, '', '', '', 0, 0, 0, 0, 0, 0, 0, 1)
`);
zahlungsart = { kZahlungsart, cName: key };
}
cache.set(key.toLowerCase(), zahlungsart);
return zahlungsart;
}
async function nextCustomerNumber(transaction) {
return nextNumberFromSequence(transaction, config.customerNumberSequence, new Date());
}
/**
* Creates a Kunde via Kunde.spKundeInsert (which fills tKunde, tAdresse and
* 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 }) {
const a = address || {};
const iso = (a.countryIso || 'DE').toUpperCase();
const kKundengruppe = Number(a.customerGroupId) || defaults.kKundengruppe;
const result = await new sql.Request(transaction)
.input('cKundenNr', sql.NVarChar, customerNumber)
.input('cFirma', sql.NVarChar, a.company || '')
.input('cAnrede', sql.NVarChar, a.salutation || '')
.input('cTitel', sql.NVarChar, a.title || '')
.input('cVorname', sql.NVarChar, a.firstName || '')
.input('cName', sql.NVarChar, a.lastName || 'Laufkunde')
.input('cStrasse', sql.NVarChar, a.street || '-')
.input('cPLZ', sql.NVarChar, a.zipCode || '')
.input('cOrt', sql.NVarChar, a.city || '-')
.input('cLand', sql.NVarChar, COUNTRY_NAMES[iso] || iso)
.input('cTel', sql.NVarChar, a.phone || '')
.input('cFax', sql.NVarChar, a.fax || '')
.input('cEMail', sql.NVarChar, a.email || '')
.input('cMobil', sql.NVarChar, a.mobile || '')
.input('fRabatt', sql.Decimal(18, 13), toNumber(a.discount, 0))
.input('cAdressZusatz', sql.NVarChar, a.addressAddition || '')
.input('cGeburtstag', sql.NVarChar, a.birthday || '')
.input('kKundenGruppe', sql.Int, kKundengruppe)
.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('nDebitorennr', sql.Int, Number(a.debtorNumber) || 0)
.query(`
DECLARE @returnValue INT;
DECLARE @p1 dbo.TYPE_spkundeInsert;
INSERT INTO @p1
(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, @cKundenNr, @cFirma, @cAnrede, @cTitel, @cVorname, @cName,
@cStrasse, @cPLZ, @cOrt, @cLand, @cTel, @cFax, @cEMail, GETDATE(), @cMobil, @fRabatt, NULL, 'N',
N'', N'', 0, @cAdressZusatz, @cGeburtstag, N'', 'N', NULL, @kKundenGruppe,
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;
SELECT @returnValue AS kKunde;
`);
const kKunde = result.recordset[0]?.kKunde;
if (!kKunde || kKunde <= 0) {
throw new Error(`Kunde.spKundeInsert failed for '${customerNumber}'`);
}
return { kKunde, kKundengruppe };
}
/**
* 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).
*/
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,
};
}
return createCustomer(transaction, {
customerNumber,
address: order.billingAddress,
isPosCustomer: false,
defaults,
});
}
const kassenKunde = await new sql.Request(transaction).query(
"SELECT TOP 1 kKunde, kKundenGruppe FROM dbo.tKunde WHERE cKassenKunde = 'Y' ORDER BY kKunde"
);
if (kassenKunde.recordset[0]) {
return {
kKunde: kassenKunde.recordset[0].kKunde,
kKundengruppe: kassenKunde.recordset[0].kKundenGruppe || defaults.kKundengruppe,
};
}
return createCustomer(transaction, {
customerNumber: await nextCustomerNumber(transaction),
address: order.billingAddress,
isPosCustomer: true,
defaults,
});
}
async function nextOrderNumber(transaction, orderDate) {
return nextNumberFromSequence(transaction, config.orderNumberSequence, orderDate);
}
async function insertOrderAddress(transaction, kAuftrag, kKunde, address, nTyp) {
const a = address || {};
const iso = (a.countryIso || 'DE').toUpperCase();
await new sql.Request(transaction)
.input('kAuftrag', sql.Int, kAuftrag)
.input('kKunde', sql.Int, kKunde)
.input('cFirma', sql.NVarChar, a.company || '')
.input('cAnrede', sql.NVarChar, a.salutation || '')
.input('cTitel', sql.NVarChar, a.title || '')
.input('cVorname', sql.NVarChar, a.firstName || '')
.input('cName', sql.NVarChar, a.lastName || '-')
.input('cStrasse', sql.NVarChar, a.street || '-')
.input('cPLZ', sql.NVarChar, a.zipCode || '')
.input('cOrt', sql.NVarChar, a.city || '-')
.input('cLand', sql.NVarChar, COUNTRY_NAMES[iso] || iso)
.input('cTel', sql.NVarChar, a.phone || '')
.input('cZusatz', sql.NVarChar, a.extraAddressLine || '')
.input('cAdressZusatz', sql.NVarChar, a.addressAddition || '')
.input('cMobil', sql.NVarChar, a.mobile || '')
.input('cMail', sql.NVarChar, a.email || '')
.input('cFax', sql.NVarChar, a.fax || '')
.input('cBundesland', sql.NVarChar, a.state || '')
.input('cISO', sql.NVarChar, iso)
.input('nTyp', sql.Int, nTyp)
.query(`
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 (@kAuftrag, @kKunde, @cFirma, @cAnrede, @cTitel, @cVorname, @cName, @cStrasse, @cPLZ, @cOrt, @cLand,
@cTel, @cZusatz, @cAdressZusatz, @cMobil, @cMail, @cFax, @cBundesland, @cISO, @nTyp, 0)
`);
}
async function insertOrderItem(transaction, kAuftrag, item) {
const vat = toNumber(item.vat, 19);
const quantity = toNumber(item.quantity, 1);
const priceNet = toNumber(item.priceNet, toNumber(item.priceGross) / (1 + vat / 100));
const discount = toNumber(item.discountPercent, 0);
const kSteuerklasse = steuerklasseForVat(vat);
const sku = String(item.sku || '').trim();
let kArtikel = null;
if (sku) {
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 result = await new sql.Request(transaction)
.input('kArtikel', sql.Int, kArtikel)
.input('kAuftrag', sql.Int, kAuftrag)
.input('cArtNr', sql.NVarChar, kArtikel ? sku : null)
.input('cName', sql.NVarChar, item.name || sku || 'Position')
.input('cHinweis', sql.NVarChar, item.note || '')
.input('fAnzahl', sql.Float, quantity)
.input('fVkNetto', sql.Float, priceNet)
.input('fMwSt', sql.Float, vat)
.input('kSteuerklasse', sql.Int, kSteuerklasse)
.input('nType', sql.Int, kArtikel ? 1 : 0)
.input('cEinheit', sql.NVarChar, item.unit || '')
.input('fRabatt', sql.Float, discount)
.query(`
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 (@kArtikel, @kAuftrag, @cArtNr, 1, @cName, @cHinweis, @fAnzahl, @fVkNetto, @fMwSt,
@cName, @kSteuerklasse, @nType, @cEinheit, 1.0, 3, @fRabatt);
SELECT kAuftragPosition FROM @t;
`);
return result.recordset[0]?.kAuftragPosition ?? null;
}
/**
* 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.
*/
async function insertPosOrderMapping(transaction, kAuftrag, kPosAuftrag) {
const kShopSubShop = getActiveShopSubshopId();
if (!Number.isInteger(kPosAuftrag) || !kShopSubShop) {
return;
}
await new sql.Request(transaction)
.input('kAuftrag', sql.Int, kAuftrag)
.input('kPosAuftrag', sql.Int, kPosAuftrag)
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(`
INSERT INTO Pos.tAuftragMapping (kAuftrag, kPosAuftrag, kShopSubShop)
VALUES (@kAuftrag, @kPosAuftrag, @kShopSubShop)
`);
}
async function insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition) {
const kShopSubShop = getActiveShopSubshopId();
if (!Number.isInteger(kAuftragPosition) || !Number.isInteger(kPosAuftragPosition) || !kShopSubShop) {
return;
}
await new sql.Request(transaction)
.input('kAuftragPosition', sql.Int, kAuftragPosition)
.input('kPosAuftragPosition', sql.Int, kPosAuftragPosition)
.input('kShopSubShop', sql.Int, kShopSubShop)
.query(`
INSERT INTO Pos.tAuftragPositionMapping (kAuftragPosition, kPosAuftragPosition, kShopSubShop)
VALUES (@kAuftragPosition, @kPosAuftragPosition, @kShopSubShop)
`);
}
/**
* Mirrors JTL's own PosOrderCreationService -> DeliveryStep.Run(): after a POS
* order is created, `Settings.Deliver` (default true, see decompiled
* jtlCore.Classes.Sync.PosOrderSteps.DeliveryStep:199,
* `bool flag = orderEntity.Settings?.Deliver ?? true;`) decides whether the
* order is delivered immediately. Digital-voucher partial delivery
* (IstGutscheinDigital) is intentionally not reproduced.
*
* The actual delivery mechanics (Pickliste creation, real stock decrement,
* numbered Lieferschein) live in ./delivery/ - see delivery.md for the full
* investigation behind it.
*/
function isOrderDelivered(order) {
const deliver = order.settings?.deliver;
if (deliver === undefined || deliver === null) {
return true;
}
return deliver === true || String(deliver) === '1' || String(deliver).toLowerCase() === 'true';
}
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)
.input('kZahlung', sql.Int, kZahlung)
.input('cName', sql.NVarChar, zahlungsart.cName)
.input('dDatum', sql.DateTime, orderDate)
.input('fBetrag', sql.Float, toNumber(payment.amount, 0))
.input('kBestellung', sql.Int, kAuftrag)
.input('kBenutzer', sql.Int, config.kBenutzer)
.input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart)
.input('cExternalTransactionId', sql.NVarChar, order.externalOrderNumber || '')
.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)
`);
}
export async function createOrder(order) {
const defaults = await getDefaults();
const transaction = new sql.Transaction(getPool());
await transaction.begin(sql.ISOLATION_LEVEL.READ_COMMITTED);
try {
const zahlungsartCache = new Map();
// Always use the server's current time for stored timestamps (dErstellt,
// number-sequence date placeholders, payment date) rather than trusting
// the client-supplied order.creationDate.
const orderDate = new Date();
const { kKunde, kKundengruppe } = await resolveCustomer(transaction, order, defaults);
const zahlungsart = await resolveZahlungsart(transaction, order.paymentMethodName, zahlungsartCache);
const cAuftragsNr = await nextOrderNumber(transaction, orderDate);
const resultAuftrag = await new sql.Request(transaction)
.input('cAuftragsNr', sql.NVarChar, cAuftragsNr)
.input('dErstellt', sql.DateTime, orderDate)
.input('kBenutzer', sql.Int, config.kBenutzer)
.input('kKunde', sql.Int, kKunde)
.input('kFirmaHistory', sql.Int, defaults.kFirmaHistory)
.input('kSprache', sql.Int, config.kSprache)
.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('cVersandlandISO', sql.NVarChar, (order.shippingAddress?.countryIso || 'DE').toUpperCase())
.input('kVersandArt', sql.Int, defaults.kVersandArt)
.input('kZahlungsart', sql.Int, zahlungsart.kZahlungsart)
.input('kKundengruppe', sql.Int, kKundengruppe)
.input('cExterneAuftragsnummer', sql.NVarChar, order.externalOrderNumber || '')
.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)
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);
SELECT kAuftrag FROM @t;
`);
const kAuftrag = resultAuftrag.recordset[0].kAuftrag;
const kPosAuftrag = Number.parseInt(order.externalId, 10);
await insertPosOrderMapping(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 || []) {
const kAuftragPosition = await insertOrderItem(transaction, kAuftrag, item);
const kPosAuftragPosition = Number.parseInt(item.externalId, 10);
await insertPosOrderPositionMapping(transaction, kAuftragPosition, kPosAuftragPosition);
if (kAuftragPosition != null) {
deliveredItems.push({ kAuftragPosition, quantity: toNumber(item.quantity, 1) });
}
}
for (const payment of order.payments || []) {
await insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache);
}
if (isOrderDelivered(order)) {
await deliverOrder(transaction, config.kBenutzer, kAuftrag, defaults.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;
`);
await transaction.commit();
return { orderId: String(kAuftrag), orderNumber: cAuftragsNr };
} catch (err) {
try {
await transaction.rollback();
} catch {
// transaction may already be aborted by the server
}
throw err;
}
}

View File

@@ -0,0 +1,34 @@
import sql from 'mssql';
import { xmlTag, xmlElement } from './xml.js';
/**
* Commits this session's reservations into "real" Picklisten (bumps
* dbo.tPicklistePos.nStatus from <10 to 10/20 for this session). Mirrors
* AuslieferungContext.PicklistenCommitten -> Auslieferung.spPicklistenUebernehmen.
*
* nTeillieferung = 0: partial delivery is forbidden, matching create-order.js
* always delivering the full ordered quantity (see delivery.md §6).
*
* XML is passed as NVarChar and CONVERT()ed to xml inside the batch, and
* @xResult is read back via SELECT rather than a true XML OUTPUT parameter
* (tedious does not reliably round-trip xml OUTPUT params for this proc).
*/
export async function commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag) {
const bestellungen = xmlElement('Bestellung', [xmlTag('kBestellung', kAuftrag)]);
await new sql.Request(transaction)
.input('Bestellungen', sql.NVarChar(sql.MAX), bestellungen)
.input('kBenutzer', sql.Int, kBenutzer)
.input('nTeillieferung', sql.Bit, false)
.input('kSessionId', sql.Int, kSessionId)
.query(`
DECLARE @xBestellungen XML = CONVERT(XML, @Bestellungen);
DECLARE @xResult XML;
EXEC Auslieferung.spPicklistenUebernehmen
@Bestellungen = @xBestellungen,
@kBenutzer = @kBenutzer,
@nTeillieferung = @nTeillieferung,
@kSessionId = @kSessionId,
@xResult = @xResult OUTPUT;
`);
}

View File

@@ -0,0 +1,56 @@
import sql from 'mssql';
import { xmlTag, xmlElement } from './xml.js';
// PicklistenAusliefernOptionen.VersandSetzen (0x0002). DeliveryStep.Run always sets
// AuslieferungAusliefernContext.Optionen.VersandSetzen = true for POS/API orders
// (decompiled jtlCore.Classes.Sync.PosOrderSteps.DeliveryStep.cs:213), so we do too
// -- see delivery.md §0.1 for the cross-check that settled this.
const AUSLIEFERN_OPTIONS = 0x002;
/**
* Actually delivers the session's committed Picklisten: creates the properly
* numbered dbo.tLieferschein/tLieferscheinPos rows, links them back onto
* tPicklistePos, decrements real warehouse stock (dbo.spWarenlagerAusgangSchreiben),
* and bumps tPicklistePos.nStatus to 40. Mirrors
* AuslieferungAusliefernContext.Commit -> Auslieferung.spPicklistenAusliefern.
*
* @Pakete matters even for local (non-fulfillment) warehouses: the nested
* Auslieferung.spPicklistenAusliefern_PaketeErzeugen only creates a
* dbo.tVersand row for a Lieferschein if #XMLPAKET (built straight from
* @Pakete) contains at least one row for its kBestellung - if @Pakete is
* NULL, #XMLPAKET stays empty and NO tVersand row is written for ANY
* Lieferschein in the session, at all. So we always pass one <Paket> per
* order carrying its kVersandArt (e.g. "Selbstabholer"), even though we
* don't have real tracking/weight info to report yet.
*
* Returns the raw @xResult XML (NewDeliveryNotes/ProcessedPicklisten/... - see
* delivery.md §3.3), read back via SELECT since tedious does not reliably
* round-trip xml OUTPUT params for this proc.
*/
export async function deliverPicklists(transaction, kBenutzer, kSessionId, kAuftrag, kVersandArt) {
const pakete = xmlElement('Paket', [
xmlTag('kBestellung', kAuftrag),
xmlTag('kVersandart', kVersandArt),
xmlTag('fGewicht', 0), // NOT NULL column; real weight is recomputed from the order's articles further below in the proc
]);
const result = await new sql.Request(transaction)
.input('Pakete', sql.NVarChar(sql.MAX), pakete)
.input('nOptions', sql.Int, AUSLIEFERN_OPTIONS)
.input('kBenutzer', sql.Int, kBenutzer)
.input('kSessionId', sql.Int, kSessionId)
.query(`
DECLARE @xHinweise XML = NULL;
DECLARE @xPakete XML = CONVERT(XML, @Pakete);
DECLARE @xResult XML;
EXEC Auslieferung.spPicklistenAusliefern
@xHinweise = @xHinweise,
@Pakete = @xPakete,
@nOptions = @nOptions,
@kBenutzer = @kBenutzer,
@kSessionId = @kSessionId,
@xResult = @xResult OUTPUT;
SELECT @xResult AS xResult;
`);
return result.recordset[0]?.xResult ?? null;
}

View File

@@ -0,0 +1,45 @@
import { openSession, discardSession, closeSession } from './session.js';
import { getOutgoingWarehouse } from './warehouse.js';
import { reservePositions } from './reserve.js';
import { commitPicklists } from './commit.js';
import { deliverPicklists } from './deliver.js';
/**
* Delivers the full ordered quantity of every position of an order, exactly
* reproducing JTL-Wawi's own fulfillment engine (jtlCore's
* PosOrderCreationService -> DeliveryStep.Run -> AuslieferungAusliefernContext.Commit())
* instead of the old shortcut of calling Versand.spLieferscheinErstellen /
* spLieferscheinPosErstellen directly. See ../../../delivery.md for the full
* investigation (§0 has the literal SQL sequence this function implements).
*
* Creates real dbo.tPickliste/tPicklistePos rows, decrements real warehouse
* stock, produces a properly numbered Lieferschein ("<AuftragsNr>-NNN"), and
* a dbo.tVersand row carrying the order's Versandart (e.g. "Selbstabholer"),
* matching what the Wawi GUI / native POS sync produce.
*/
export async function deliverOrder(transaction, kBenutzer, kAuftrag, kVersandArt, deliveredItems) {
if (!deliveredItems.length) {
return;
}
const kWarenLager = await getOutgoingWarehouse(transaction);
const kSessionId = await openSession(transaction, kBenutzer);
try {
await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, deliveredItems);
await commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag);
await deliverPicklists(transaction, kBenutzer, kSessionId, kAuftrag, kVersandArt);
} finally {
// Safe to run unconditionally (success or error): spPicklistenVerwerfen only
// ever removes not-yet-delivered (nStatus < 10) Picklisten for this session.
// Swallow cleanup errors so they never mask an earlier, more relevant error
// (the outer transaction rollback in create-order.js is what actually matters
// on failure).
try {
await discardSession(transaction, kBenutzer, kSessionId);
await closeSession(transaction, kSessionId);
} catch {
// best-effort cleanup only
}
}
}

View File

@@ -0,0 +1,54 @@
import sql from 'mssql';
import { xmlTag, xmlElement } from './xml.js';
// ReserviereBestellpositionenOptionen: ChargenVorbelegen (0x0002) | Stücklistenkorrektur (0x0100).
// Matches jtlCore's AuslieferungContext ctor + the StoredProcedures.cs wrapper for
// Auslieferung_spReserviereBestellpositionen (see delivery.md §3.1).
const RESERVIERE_OPTIONS = 0x102;
/**
* Reserves every order position against the outgoing warehouse, creating
* dbo.tPickliste/tPicklistePos rows (status 0 -> 5) for this session.
* Mirrors PosBookPositionService.BookPosition -> AuslieferungContext.NeuReservieren
* -> Auslieferung.spReserviereBestellpositionen.
*
* XML parameters are passed as NVarChar and CONVERT()ed to xml in the SQL
* batch itself (rather than using sql.Xml directly), because tedious does
* not reliably round-trip the `xml` SQL type for these procedures.
*/
export async function reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, positions) {
if (!positions.length) {
return;
}
const bestellpositionen = positions
.map(({ kAuftragPosition, quantity }) =>
xmlElement('Bestellposition', [xmlTag('kBestellPos', kAuftragPosition), xmlTag('fAnzahl', quantity)])
)
.join('');
const laeger = xmlElement('Lager', [
xmlTag('kWarenlager', kWarenLager),
xmlTag('nPrio', 0),
xmlTag('kLieferant', 0),
xmlTag('kAnsprechpartner', 0),
]);
await new sql.Request(transaction)
.input('Bestellpositionen', sql.NVarChar(sql.MAX), bestellpositionen)
.input('Laeger', sql.NVarChar(sql.MAX), laeger)
.input('nOptions', sql.Int, RESERVIERE_OPTIONS)
.input('kBenutzer', sql.Int, kBenutzer)
.input('kSessionId', sql.Int, kSessionId)
.query(`
DECLARE @xBestellpositionen XML = CONVERT(XML, @Bestellpositionen);
DECLARE @xLaeger XML = CONVERT(XML, @Laeger);
EXEC Auslieferung.spReserviereBestellpositionen
@Bestellpositionen = @xBestellpositionen,
@Laeger = @xLaeger,
@Warenlagereingaenge = NULL,
@nOptions = @nOptions,
@kBenutzer = @kBenutzer,
@kSessionId = @kSessionId;
`);
}

View File

@@ -0,0 +1,46 @@
import sql from 'mssql';
/**
* Opens a JTL "Auslieferung" session by inserting a row into dbo.tSessionId,
* mirroring jtlCore.Classes.SessionManager.GetSessionId(). All of the
* Auslieferung.sp* calls in this delivery/ folder are scoped to this
* kSessionId (see ../../../delivery.md §2).
*/
export async function openSession(transaction, kBenutzer, hostname = 'jtlsrv') {
const result = await new sql.Request(transaction)
.input('cRechnername', sql.NVarChar(255), hostname)
.input('kBenutzer', sql.Int, kBenutzer)
.query(`
DECLARE @t TABLE ([kSessionId] INT);
INSERT INTO dbo.tSessionId (cRechnername, kBenutzer, dLastAction)
OUTPUT inserted.kSessionId INTO @t
VALUES (@cRechnername, @kBenutzer, DATEADD(day, 10, GETDATE()));
SELECT kSessionId FROM @t;
`);
return result.recordset[0].kSessionId;
}
/**
* Discards any reservation belonging to this session that never made it into
* a delivered Pickliste. Auslieferung.spPicklistenVerwerfen only ever deletes
* dbo.tPickliste rows with nStatus < 10 for this session, so it's safe (and
* recommended, see delivery.md §0.1) to call this unconditionally, both after
* a successful delivery and on the error path.
*/
export async function discardSession(transaction, kBenutzer, kSessionId) {
await new sql.Request(transaction)
.input('kBenutzer', sql.Int, kBenutzer)
.input('kSessionId', sql.Int, kSessionId)
.execute('Auslieferung.spPicklistenVerwerfen');
}
/**
* Mirrors SessionManager.Dispose(): removes the housekeeping row from
* dbo.tSessionId. Not strictly required for correctness (it only affects the
* Wawi GUI's "who's editing what" bookkeeping), but keeps the table tidy.
*/
export async function closeSession(transaction, kSessionId) {
await new sql.Request(transaction)
.input('kSessionId', sql.Int, kSessionId)
.query('DELETE FROM dbo.tSessionId WHERE kSessionId = @kSessionId');
}

View File

@@ -0,0 +1,38 @@
import sql from 'mssql';
/** DB-resolved (or env-overridden) outgoing warehouse, cached after first lookup. */
let cachedWarenLager = null;
/**
* Resolves the local warehouse (dbo.tWarenLager.nFulfillment = 0) to book
* stock out of. JTL's own PosWarehouseService.FindeWarenlagerFuerAusgang()
* (jtlCore, obfuscated) is not yet decompiled/traced (see delivery.md §7), so
* we approximate it: use JTL_KWARENLAGER if configured, otherwise pick the
* highest-priority active local warehouse. This is correct for the common
* single-warehouse setup; multi-warehouse setups should set JTL_KWARENLAGER
* explicitly until FindeWarenlagerFuerAusgang() is fully traced.
*/
export async function getOutgoingWarehouse(transaction) {
if (cachedWarenLager != null) {
return cachedWarenLager;
}
const configured = Number(process.env.JTL_KWARENLAGER);
if (Number.isInteger(configured) && configured > 0) {
cachedWarenLager = configured;
return cachedWarenLager;
}
const result = await new sql.Request(transaction).query(`
SELECT TOP 1 kWarenLager
FROM dbo.tWarenLager
WHERE nFulfillment = 0 AND ISNULL(nAktiv, 1) = 1
ORDER BY nAuslieferungsPrio, kWarenLager
`);
const row = result.recordset[0];
if (!row) {
throw new Error('No local warehouse (dbo.tWarenLager.nFulfillment = 0) found; set JTL_KWARENLAGER explicitly.');
}
cachedWarenLager = row.kWarenLager;
return cachedWarenLager;
}

View File

@@ -0,0 +1,38 @@
/**
* Minimal helpers for building the small, numeric-only XML fragments the
* Auslieferung.sp* procedures expect (see delivery.md §3 for the exact
* schemas). SQL Server's `xml` type happily holds multiple top-level
* elements, so a "fragment" here is just concatenated <Tag>...</Tag> blocks.
*/
function escapeXml(value) {
return String(value).replace(/[<>&'"]/g, (c) => {
switch (c) {
case '<':
return '&lt;';
case '>':
return '&gt;';
case '&':
return '&amp;';
case "'":
return '&apos;';
case '"':
return '&quot;';
default:
return c;
}
});
}
/** Builds `<tag>value</tag>`, or `<tag/>` when value is null/undefined. */
export function xmlTag(tag, value) {
if (value == null) {
return `<${tag}/>`;
}
return `<${tag}>${escapeXml(value)}</${tag}>`;
}
/** Wraps a set of child tags (already-built strings) in `<tag>...</tag>`. */
export function xmlElement(tag, childrenXml) {
return `<${tag}>${childrenXml.join('')}</${tag}>`;
}

View File

@@ -9,7 +9,16 @@ FROM dbo.tArtikel a
INNER JOIN dbo.tKategorieArtikel ka ON ka.kArtikel = a.kArtikel INNER JOIN dbo.tKategorieArtikel ka ON ka.kArtikel = a.kArtikel
WHERE a.cAktiv = 'Y' WHERE a.cAktiv = 'Y'
AND ka.kKategorie IN (SELECT kKategorie FROM CategoryTree) AND ka.kKategorie IN (SELECT kKategorie FROM CategoryTree)
AND CONVERT(BIGINT, a.bRowversion) > @cursor; AND (@kShop = 0 OR EXISTS (SELECT 1 FROM dbo.tKategorieShop ks WHERE ks.kKategorie = ka.kKategorie AND ks.kShop = @kShop))
AND (
CONVERT(BIGINT, a.bRowversion) > @cursor
OR EXISTS (
SELECT 1 FROM dbo.tArtikelbildPlattform abp
WHERE abp.kArtikel = a.kArtikel
AND abp.kShop = @kShop
AND CONVERT(BIGINT, abp.bRowversion) > @cursor
)
);
`; `;
export async function getProductCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) { export async function getProductCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {

View File

@@ -2,17 +2,22 @@ import sql from 'mssql';
import { getPool } from '../db.js'; import { getPool } from '../db.js';
import { getCustomerGroupIds } from './customer-groups.js'; import { getCustomerGroupIds } from './customer-groups.js';
import { getProductAttributes } from './product-attributes.js'; import { getProductAttributes } from './product-attributes.js';
import { getActiveShopId } from '../shop.js';
const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1; const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1;
const TAX_ZONE_NAME = process.env.TAX_ZONE_NAME || 'Zone-EU'; const TAX_ZONE_NAME = process.env.TAX_ZONE_NAME || 'Zone-EU';
const IMAGE_PLATFORM_ID = Number(process.env.IMAGE_PLATFORM_ID) || 1;
const IMAGE_SHOP_ID = Number(process.env.IMAGE_SHOP_ID) || 0;
const PRODUCT_LIST_SQL = ` const PRODUCT_LIST_SQL = `
WITH TaxRates AS ( WITH TaxRates AS (
SELECT kSteuerklasse, fSteuersatz SELECT kSteuerklasse, fSteuersatz
FROM dbo.tSteuersatz FROM dbo.tSteuersatz
WHERE kSteuerzone IN (SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = @taxZoneName) WHERE kSteuerzone IN (SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = @taxZoneName)
),
ImageRV AS (
SELECT kArtikel, MAX(CONVERT(BIGINT, bRowversion)) AS maxImageRV
FROM dbo.tArtikelbildPlattform
WHERE kShop = @kShop
GROUP BY kArtikel
) )
SELECT TOP (@limit) SELECT TOP (@limit)
a.kArtikel AS id, a.kArtikel AS id,
@@ -21,14 +26,16 @@ SELECT TOP (@limit)
a.fVKNetto AS netPrice, a.fVKNetto AS netPrice,
tr.fSteuersatz AS taxRate, tr.fSteuersatz AS taxRate,
a.dErstelldatum AS createdAt, a.dErstelldatum AS createdAt,
CONVERT(BIGINT, a.bRowversion) AS lastChanged, CASE
WHEN ir.maxImageRV IS NOT NULL AND ir.maxImageRV > CONVERT(BIGINT, a.bRowversion)
THEN ir.maxImageRV
ELSE CONVERT(BIGINT, a.bRowversion)
END AS lastChanged,
( (
SELECT TOP 1 img.cHash SELECT TOP 1 img.cHash
FROM dbo.tArtikelbildPlattform abp FROM dbo.tArtikelbildPlattform abp
INNER JOIN dbo.tBild img ON img.kBild = abp.kBild INNER JOIN dbo.tBild img ON img.kBild = abp.kBild
WHERE abp.kArtikel = a.kArtikel WHERE abp.kArtikel = a.kArtikel
AND abp.kPlattform = @imagePlatformId
AND abp.kShop = @imageShopId
ORDER BY abp.nNr ORDER BY abp.nNr
) AS imgHash, ) AS imgHash,
( (
@@ -47,8 +54,17 @@ SELECT TOP (@limit)
FROM dbo.tArtikel a FROM dbo.tArtikel a
INNER JOIN dbo.tArtikelBeschreibung ab ON ab.kArtikel = a.kArtikel AND ab.kSprache = @languageId INNER JOIN dbo.tArtikelBeschreibung ab ON ab.kArtikel = a.kArtikel AND ab.kSprache = @languageId
LEFT JOIN TaxRates tr ON tr.kSteuerklasse = a.kSteuerklasse LEFT JOIN TaxRates tr ON tr.kSteuerklasse = a.kSteuerklasse
LEFT JOIN ImageRV ir ON ir.kArtikel = a.kArtikel
WHERE a.cAktiv = 'Y' WHERE a.cAktiv = 'Y'
AND CONVERT(BIGINT, a.bRowversion) > @cursor AND (@kShop = 0 OR EXISTS (
SELECT 1 FROM dbo.tKategorieArtikel ka
INNER JOIN dbo.tKategorieShop ks ON ks.kKategorie = ka.kKategorie AND ks.kShop = @kShop
WHERE ka.kArtikel = a.kArtikel
))
AND (
CONVERT(BIGINT, a.bRowversion) > @cursor
OR (ir.maxImageRV IS NOT NULL AND ir.maxImageRV > @cursor)
)
ORDER BY lastChanged ASC; ORDER BY lastChanged ASC;
`; `;
@@ -85,8 +101,7 @@ export async function getProductList({ cursor = 0, limit = 20 } = {}) {
.input('limit', sql.Int, limit) .input('limit', sql.Int, limit)
.input('languageId', sql.Int, LANGUAGE_ID) .input('languageId', sql.Int, LANGUAGE_ID)
.input('taxZoneName', sql.NVarChar, TAX_ZONE_NAME) .input('taxZoneName', sql.NVarChar, TAX_ZONE_NAME)
.input('imagePlatformId', sql.Int, IMAGE_PLATFORM_ID) .input('kShop', sql.Int, getActiveShopId())
.input('imageShopId', sql.Int, IMAGE_SHOP_ID)
.query(PRODUCT_LIST_SQL), .query(PRODUCT_LIST_SQL),
getCustomerGroupIds(), getCustomerGroupIds(),
]); ]);

30
src/shop.js Normal file
View File

@@ -0,0 +1,30 @@
import sql from 'mssql';
let activeShopId = 0;
let activeShopSubshopId = 0;
export function setActiveShop(id) {
activeShopId = id;
}
export function getActiveShopId() {
return activeShopId;
}
export function setActiveShopSubshop(id) {
activeShopSubshopId = id;
}
export function getActiveShopSubshopId() {
return activeShopSubshopId;
}
export async function fetchActiveShop(pool) {
const result = await pool.request().query(`
SELECT TOP 1 kShop, kShopSubshop FROM dbo.tShopSubshop WHERE nGesperrt = 0 ORDER BY kShop
`);
const id = result.recordset[0]?.kShop ?? 0;
setActiveShop(id);
setActiveShopSubshop(result.recordset[0]?.kShopSubshop ?? 0);
return id;
}

View File

@@ -1,130 +0,0 @@
import https from 'node:https';
import { gunzipSync } from 'node:zlib';
const HOST = process.env.JTL_HOST || '127.0.0.1';
const PORT = Number(process.env.JTL_PORT) || Number(process.env.PORT) || 4443;
const INIT_QUERY =
'mandantId=1&lastChangedCategory=0&lastChangedCustomer=0&lastChangedCustomerGroup=0&lastChangedProduct=0&lastChangedConfigurationGroup=0&lastChangedConfigurationItem=0&lastChangedCompositeProduct=0&lastChangedDeletedEntity=0';
function usage() {
console.error('Usage:');
console.error(' node test-client.js <authCode> [name] # 6-digit: step1+2, 4-digit: step1 only');
console.error(' node test-client.js init <authToken>');
process.exit(1);
}
function posHeaders(authToken) {
const headers = {
accept: 'application/json',
'content-type': 'application/json',
'cache-control': 'no-cache',
version: '1.0.11.14',
system: 'JTL-POS',
charset: 'utf-8',
connection: 'Keep-Alive',
'user-agent': 'Dalvik/2.1.0 (Linux; U; Android 13; SM-T970 Build/TP1A.220624.014)',
'accept-encoding': 'gzip',
};
if (authToken) {
headers.authorization = `Bearer ${authToken}`;
}
return headers;
}
function decodeBody(buffer, headers) {
if (headers['content-encoding'] === 'gzip') {
return gunzipSync(buffer);
}
return buffer;
}
function request(path, authToken) {
return new Promise((resolve, reject) => {
const req = https.request(
{
hostname: HOST,
port: PORT,
path,
method: 'GET',
headers: posHeaders(authToken),
rejectUnauthorized: false,
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks);
resolve({
statusCode: res.statusCode,
headers: res.headers,
body: decodeBody(raw, res.headers),
});
});
}
);
req.on('error', reject);
req.end();
});
}
function printResponse(res) {
console.log(`status: ${res.statusCode}`);
console.log('headers:', JSON.stringify(res.headers, null, 2));
console.log('body:');
try {
console.log(JSON.stringify(JSON.parse(res.body.toString('utf8')), null, 2));
} catch {
console.log(res.body.toString('utf8'));
}
}
async function clientRequest(authCode, name, label) {
const path = `/api/v1/client?authCode=${encodeURIComponent(authCode)}&name=${encodeURIComponent(name)}`;
console.log(`${label} GET https://${HOST}:${PORT}${path}\n`);
const res = await request(path);
printResponse(res);
console.log();
return res;
}
async function main() {
const mode = process.argv[2];
if (!mode) {
usage();
}
if (mode === 'init') {
const authToken = process.argv[3] || process.env.AUTH_TOKEN;
if (!authToken) {
usage();
}
const path = `/v1/init?${INIT_QUERY}`;
console.log(`GET https://${HOST}:${PORT}${path}\n`);
const res = await request(path, authToken);
printResponse(res);
return;
}
const name = process.argv[3] || process.env.CLIENT_NAME || '001';
const fullCode = mode;
if (fullCode.length === 6) {
await clientRequest(fullCode.slice(0, 4), name, '=== step 1 (4 digits) ===');
await clientRequest(fullCode, name, '=== step 2 (6 digits) ===');
return;
}
await clientRequest(fullCode, name, '=== step 1 (4 digits) ===');
}
main().catch((err) => {
console.error('request failed:', err.message);
process.exit(1);
});