Compare commits
8 Commits
ca190b2832
...
955fe2c164
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
955fe2c164 | ||
|
|
03dde8eff8 | ||
|
|
8d55da79b0 | ||
|
|
53a7924c7f | ||
|
|
cbc4b8315e | ||
|
|
23c5b76027 | ||
|
|
ee09434ea9 | ||
|
|
7f6095013b |
@@ -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
|
||||||
|
|||||||
101
c++_port_of_jtlsrv_f5ce4ce8.plan.md
Normal file
101
c++_port_of_jtlsrv_f5ce4ce8.plan.md
Normal 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
666
delivery.md
Normal 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
2
package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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 {
|
||||||
status: 'OK',
|
const created = await createOrder(order);
|
||||||
externalOrderId,
|
logger.success(`order ${created.orderNumber} (kAuftrag=${created.orderId}) created for externalId=${externalOrderId}`);
|
||||||
message: '',
|
results.push({
|
||||||
};
|
status: 'OK',
|
||||||
});
|
externalOrderId,
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
520
src/queries/create-order.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/queries/delivery/commit.js
Normal file
34
src/queries/delivery/commit.js
Normal 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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
56
src/queries/delivery/deliver.js
Normal file
56
src/queries/delivery/deliver.js
Normal 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;
|
||||||
|
}
|
||||||
45
src/queries/delivery/index.js
Normal file
45
src/queries/delivery/index.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/queries/delivery/reserve.js
Normal file
54
src/queries/delivery/reserve.js
Normal 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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
46
src/queries/delivery/session.js
Normal file
46
src/queries/delivery/session.js
Normal 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');
|
||||||
|
}
|
||||||
38
src/queries/delivery/warehouse.js
Normal file
38
src/queries/delivery/warehouse.js
Normal 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;
|
||||||
|
}
|
||||||
38
src/queries/delivery/xml.js
Normal file
38
src/queries/delivery/xml.js
Normal 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 '<';
|
||||||
|
case '>':
|
||||||
|
return '>';
|
||||||
|
case '&':
|
||||||
|
return '&';
|
||||||
|
case "'":
|
||||||
|
return ''';
|
||||||
|
case '"':
|
||||||
|
return '"';
|
||||||
|
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}>`;
|
||||||
|
}
|
||||||
@@ -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() } = {}) {
|
||||||
|
|||||||
@@ -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
30
src/shop.js
Normal 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;
|
||||||
|
}
|
||||||
130
test-client.js
130
test-client.js
@@ -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);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user