# 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.
