delivery seemds to work.
This commit is contained in:
@@ -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(`
|
||||
|
||||
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}>`;
|
||||
}
|
||||
Reference in New Issue
Block a user