Files
jtlPosSync/delivery.md
2026-07-13 03:40:14 +02:00

38 KiB
Raw Permalink Blame History

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

-- 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/spReserviereBestellpositionenAuslieferung.spPicklistenUebernehmenAuslieferung.spPicklistenAusliefernVerkauf.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):
    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) —
    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:
    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.ExecuteAsyncPosFrontend.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)

    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:

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):

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):

<!-- @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):

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):

<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:
    <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:

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):

<!-- @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:
    <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.