delivery seemds to work.

This commit is contained in:
seb
2026-07-13 03:40:14 +02:00
parent 03dde8eff8
commit 955fe2c164
12 changed files with 985 additions and 573 deletions

View File

@@ -1,6 +1,7 @@
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,
@@ -389,13 +390,12 @@ async function insertPosOrderPositionMapping(transaction, kAuftragPosition, kPos
* 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. JTL's real implementation books stock via
* the full Auslieferung/warehouse engine; we reproduce its DB end-state
* directly via the same stored procedures it ultimately relies on
* (Versand.spLieferscheinErstellen / spLieferscheinPosErstellen), which also
* call Verkauf.spAuftragEckdatenBerechnen and are what actually derive
* nKomplettAusgeliefert. Digital-voucher partial delivery (IstGutscheinDigital)
* is intentionally not reproduced.
* 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;
@@ -405,48 +405,6 @@ function isOrderDelivered(order) {
return deliver === true || String(deliver) === '1' || String(deliver).toLowerCase() === 'true';
}
async function createLieferschein(transaction, kAuftrag) {
const request = new sql.Request(transaction);
request.input('kBestellung', sql.Int, kAuftrag);
request.input('kBenutzer', sql.Int, config.kBenutzer);
request.input('cLieferscheinNr', sql.NVarChar(50), null);
request.input('cHinweis', sql.NVarChar(255), null);
request.input('dMailVersand', sql.DateTime, null);
request.input('dGedruckt', sql.DateTime, null);
request.input('nFulfillment', sql.Int, null);
request.input('kLieferantenBestellung', sql.Int, null);
request.input('kSessionId', sql.Int, null);
request.output('kLieferschein', sql.Int);
const result = await request.execute('Versand.spLieferscheinErstellen');
return result.output.kLieferschein;
}
async function createLieferscheinPos(transaction, kLieferschein, kAuftragPosition, quantity) {
const request = new sql.Request(transaction);
request.input('kLieferschein', sql.Int, kLieferschein);
request.input('kBestellPos', sql.Int, kAuftragPosition);
request.input('fAnzahl', sql.Decimal(25, 13), quantity);
request.input('cHinweis', sql.NVarChar(4000), '');
request.output('kLieferscheinPos', sql.Int);
await request.execute('Versand.spLieferscheinPosErstellen');
}
/**
* Delivers the full ordered quantity of every position (no partial delivery),
* mirroring DeliveryStep's non-voucher path. Uses the server's current time
* throughout (see createOrder), so there is never a past order date to
* backdate Versanddatum against.
*/
async function deliverOrder(transaction, kAuftrag, deliveredItems) {
if (!deliveredItems.length) {
return;
}
const kLieferschein = await createLieferschein(transaction, kAuftrag);
for (const { kAuftragPosition, quantity } of deliveredItems) {
await createLieferscheinPos(transaction, kLieferschein, kAuftragPosition, quantity);
}
}
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');
@@ -539,7 +497,7 @@ export async function createOrder(order) {
}
if (isOrderDelivered(order)) {
await deliverOrder(transaction, kAuftrag, deliveredItems);
await deliverOrder(transaction, config.kBenutzer, kAuftrag, defaults.kVersandArt, deliveredItems);
}
await new sql.Request(transaction).input('kAuftrag', sql.Int, kAuftrag).query(`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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