From 5951e10ea530713f095f5922ddad8b234f80b358 Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 13 Aug 2026 13:19:47 +0200 Subject: [PATCH] fix(delivery): deliver orders with father-articles and no-payment orders - Treat an article that has become a father (nIstVater=1) as a free position (kArtikel=0) with the name kept: VersandIntern.vBestellPosOffen joins tArtikel with nIstVater=0, so such positions could never be reserved and failed with "insufficient stock after POS shortage booking" (e.g. Becks 42000030). - Recalculate tAuftragPositionEckdaten before deliverOrder so orders without payments get their positions reserved too (previously only insertPayment triggered the recalculation). Also adds debug logging across the delivery pipeline (reserve, commit, deliver, session, stock-shortage, create-order). --- .env.example | 7 ++++ API.md | 32 ++++++++++++++++ nginx.conf.example | 51 ++++++++++++++++++++++++++ server.js | 9 +++++ src/endpoints/index.js | 3 +- src/endpoints/newpin.js | 27 ++++++++++++++ src/jtl-server.js | 1 + src/queries/create-order.js | 25 ++++++++++++- src/queries/delivery/commit.js | 5 +++ src/queries/delivery/deliver.js | 8 +++- src/queries/delivery/index.js | 8 ++++ src/queries/delivery/reserve.js | 7 ++++ src/queries/delivery/session.js | 7 +++- src/queries/delivery/stock-shortage.js | 31 +++++++++++++--- 14 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 nginx.conf.example create mode 100644 src/endpoints/newpin.js diff --git a/.env.example b/.env.example index cbb80b5..604f251 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,14 @@ # HTTPS POS server PORT=4443 + +# Plain HTTP POS server (no TLS) - same API, unencrypted +HTTP_PORT=8087 + AUTH_TOKEN=df40ad2067954646abb0499548a52241 PAIRING_CODE=307018 + +# Allow generating fresh pairing codes on demand via GET /v1/newpin (default: false) +NEWPIN_ENABLED=false LOG_FILE=logs/requests.log ORDER_LOG_FILE=logs/orders.log diff --git a/API.md b/API.md index a12bc43..1bb8426 100644 --- a/API.md +++ b/API.md @@ -79,6 +79,38 @@ Discovers the server and completes pairing with a 6-digit code. --- +## `GET /v1/newpin` — Generate a pairing code + +Creates a fresh, usable 6-digit pairing code on demand. Useful when the static `PAIRING_CODE` has already been consumed by a previous pair. + +### Enabling + +Disabled by default for security. Enable it by setting `NEWPIN_ENABLED=true` in the environment (`.env`). When disabled, the endpoint returns `403`. + +### Response (200) + +```json +{ + "pin": "482913", + "name": "JTL-POS", + "serverTimestamp": "2026-08-13 12:00:00" +} +``` + +| Field | Meaning | +|---|---| +| `pin` | Fresh 6-digit code; valid until consumed by `GET /v1/client` pairing | +| `name` | Device name registered on pair (fixed `JTL-POS`) | +| `serverTimestamp` | Server time at generation | + +### Errors + +| Status | When | +|---|---| +| 403 | `NEWPIN_ENABLED` is not `true` | + +--- + ## `GET /v1/init` — Sync status Returns how many entities changed since each cursor. Poll this; only fetch list endpoints when the matching `*_count` is greater than `"0"`. diff --git a/nginx.conf.example b/nginx.conf.example new file mode 100644 index 0000000..f22b1e4 --- /dev/null +++ b/nginx.conf.example @@ -0,0 +1,51 @@ +# nginx example: terminate TLS in nginx and proxy to the JTL POS plain-HTTP listener. +# +# The Node server listens on: +# PORT -> HTTPS (e.g. 4447) +# HTTP_PORT -> plain HTTP (e.g. 8087) +# +# With this setup you only need to expose port 443 (or 4443) through nginx. +# Copy this file to your nginx site config and adjust paths/ports as needed. + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name pos.example.com; + + # Same certificate pair the Node server uses (certs/key.pem + certs/cert.pem). + # For a real deployment, replace with your own cert/chain. + ssl_certificate /home/strainz/src/jtlPosSync/certs/cert.pem; + ssl_certificate_key /home/strainz/src/jtlPosSync/certs/key.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # The JTL POS API can send large request bodies (product/category sync). + client_max_body_size 0; + + location / { + proxy_pass http://127.0.0.1:8087; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Keep alive to the Node backend. + proxy_set_header Connection ""; + + # Longer timeouts for long-running sync requests. + proxy_connect_timeout 60s; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } +} + +# Optional: redirect all plain-HTTP traffic to HTTPS. +server { + listen 80; + listen [::]:80; + server_name pos.example.com; + return 301 https://$host$request_uri; +} diff --git a/server.js b/server.js index d40af10..f44146c 100644 --- a/server.js +++ b/server.js @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import http from 'node:http'; import https from 'node:https'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -15,6 +16,7 @@ import { fetchActiveShop } from './src/shop.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT) || 4443; +const HTTP_PORT = Number(process.env.HTTP_PORT) || 8080; const AUTH_TOKEN = process.env.AUTH_TOKEN || 'df40ad2067954646abb0499548a52241'; const PAIRING_CODE = process.env.PAIRING_CODE || '307018'; @@ -112,6 +114,8 @@ const httpsServer = https.createServer( loggedJtlHandler ); +const httpServer = http.createServer(loggedJtlHandler); + async function start() { try { const pool = await connectDb(); @@ -129,11 +133,16 @@ async function start() { logger.info(`Pairing code: ${PAIRING_CODE}`); logger.info(`Auth token: ${AUTH_TOKEN}`); }); + + httpServer.listen(HTTP_PORT, '0.0.0.0', () => { + logger.success(`HTTP POS server listening on http://0.0.0.0:${HTTP_PORT}`); + }); } async function shutdown() { logger.info('Shutting down...'); httpsServer.close(); + httpServer.close(); await closeDb(); closeRequestLog(); closeOrderLog(); diff --git a/src/endpoints/index.js b/src/endpoints/index.js index bfcd4e5..cded7c8 100644 --- a/src/endpoints/index.js +++ b/src/endpoints/index.js @@ -4,9 +4,10 @@ import * as client from './client.js'; import * as customergroup from './customergroup.js'; import * as deletedEntity from './deleted-entity.js'; import * as init from './init.js'; +import * as newpin from './newpin.js'; import * as order from './order.js'; import * as pimage from './pimage.js'; import * as product from './product.js'; import * as productcomposite from './productcomposite.js'; -export const endpoints = [client, init, category, product, productcomposite, deletedEntity, pimage, cimage, customergroup, order]; +export const endpoints = [client, newpin, init, category, product, productcomposite, deletedEntity, pimage, cimage, customergroup, order]; diff --git a/src/endpoints/newpin.js b/src/endpoints/newpin.js new file mode 100644 index 0000000..0b0aa67 --- /dev/null +++ b/src/endpoints/newpin.js @@ -0,0 +1,27 @@ +import crypto from 'node:crypto'; +import { sendJson, serverTimestamp } from '../http.js'; + +export const method = 'GET'; +export const path = '/v1/newpin'; + +// 6-digit code in [100000, 999999] so it always has exactly 6 digits (no leading zeros) +function generatePin() { + return String(crypto.randomInt(100000, 1000000)); +} + +export function handle(_req, res, { pairingStore, config }) { + if (!config.newPinEnabled) { + return sendJson(res, 403, { + Message: 'This endpoint is disabled. Set NEWPIN_ENABLED=true to enable it.', + }); + } + + const pin = generatePin(); + pairingStore.setPairingCode(pin, 'JTL-POS'); + + return sendJson(res, 200, { + pin, + name: 'JTL-POS', + serverTimestamp: serverTimestamp(), + }); +} diff --git a/src/jtl-server.js b/src/jtl-server.js index 7f3e44b..6dfb4e4 100644 --- a/src/jtl-server.js +++ b/src/jtl-server.js @@ -19,6 +19,7 @@ function buildConfig(config = {}) { mandantId: config.mandantId || process.env.MANDANT_ID || '1', mandantName: config.mandantName || process.env.MANDANT_NAME || 'eB-Standard', mandantDatabase: config.mandantDatabase || process.env.MANDANT_DATABASE || 'eazybusiness', + newPinEnabled: config.newPinEnabled ?? process.env.NEWPIN_ENABLED === 'true', }; } diff --git a/src/queries/create-order.js b/src/queries/create-order.js index 4e71a0c..0a2ad40 100644 --- a/src/queries/create-order.js +++ b/src/queries/create-order.js @@ -1,6 +1,7 @@ import sql from 'mssql'; import { getPool } from '../db.js'; import { getActiveShopId, getActiveShopSubshopId } from '../shop.js'; +import { logger } from '../logger.js'; import { deliverOrder } from './delivery/index.js'; const config = { @@ -449,8 +450,18 @@ async function insertOrderItem(transaction, kAuftrag, item) { if (sku && positionType !== VERSANDPOSITION_TYPE) { const result = await new sql.Request(transaction) .input('cArtNr', sql.NVarChar, sku) - .query('SELECT TOP 1 kArtikel FROM dbo.tArtikel WHERE cArtNr = @cArtNr'); - kArtikel = result.recordset[0]?.kArtikel ?? null; + .query('SELECT TOP 1 kArtikel, nIstVater FROM dbo.tArtikel WHERE cArtNr = @cArtNr'); + const row = result.recordset[0]; + kArtikel = row?.kArtikel ?? null; + // An article that has become a father (nIstVater = 1) can never be picked/delivered + // as a normal article: VersandIntern.vBestellPosOffen joins dbo.tArtikel with + // Art.nIstVater = 0, so the reservation SP can never reserve it and the order + // fails with "insufficient stock after POS shortage booking". Since the POS still + // sells it as a plain SKU, fall back to a free position (kArtikel = 0) with the + // name kept, which reserves/delivers like the Pfand lines do. + if (row?.nIstVater === 1) { + kArtikel = null; + } } const nType = positionType === VERSANDPOSITION_TYPE ? VERSANDPOSITION_TYPE : (kArtikel ? 1 : 0); @@ -742,6 +753,7 @@ export async function createOrder(order) { if (kAuftragPosition != null && !isVersandposition(item)) { deliveredItems.push({ kAuftragPosition, quantity: toNumber(item.quantity, 1) }); } + logger.info(`createOrder: item sku="${String(item.sku ?? '').trim()}" type=${item.type} kAuftragPosition=${kAuftragPosition} qty=${toNumber(item.quantity, 1)} -> delivered=${kAuftragPosition != null && !isVersandposition(item)}`); } for (const payment of order.payments || []) { @@ -749,6 +761,15 @@ export async function createOrder(order) { await insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache); } + // Positions must be reflected in tAuftragPositionEckdaten before the delivery + // reservation runs: VersandIntern.vBestellPosOffen joins tAuftragPositionEckdaten, + // and without a populated row the reservation SP sees fAnzahlZuPicken = 0 and + // reserves nothing ("insufficient stock after POS shortage booking"). insertPayment + // recalculates for paid orders; do it unconditionally here so no-payment orders + // (e.g. externalId=264) are delivered correctly too. + await recalculateAuftragEckdaten(transaction, kAuftrag); + + logger.info(`createOrder: kAuftrag=${kAuftrag} isOrderDelivered=${isOrderDelivered(order)} deliveredItems=${JSON.stringify(deliveredItems)}`); if (isOrderDelivered(order)) { await deliverOrder(transaction, config.kBenutzer, kAuftrag, kVersandArt, deliveredItems); } diff --git a/src/queries/delivery/commit.js b/src/queries/delivery/commit.js index 3869529..ce3ef73 100644 --- a/src/queries/delivery/commit.js +++ b/src/queries/delivery/commit.js @@ -1,5 +1,6 @@ import sql from 'mssql'; import { xmlTag, xmlElement } from './xml.js'; +import { logger } from '../../logger.js'; /** * Commits this session's reservations into "real" Picklisten (bumps @@ -16,6 +17,8 @@ import { xmlTag, xmlElement } from './xml.js'; export async function commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag) { const bestellungen = xmlElement('Bestellung', [xmlTag('kBestellung', kAuftrag)]); + logger.info(`commit: kSessionId=${kSessionId} kAuftrag=${kAuftrag} nTeillieferung=false bestellungen=${bestellungen}`); + await new sql.Request(transaction) .input('Bestellungen', sql.NVarChar(sql.MAX), bestellungen) .input('kBenutzer', sql.Int, kBenutzer) @@ -31,4 +34,6 @@ export async function commitPicklists(transaction, kBenutzer, kSessionId, kAuftr @kSessionId = @kSessionId, @xResult = @xResult OUTPUT; `); + + logger.info(`commit: done kSessionId=${kSessionId} kAuftrag=${kAuftrag}`); } diff --git a/src/queries/delivery/deliver.js b/src/queries/delivery/deliver.js index 3dcc12d..12e5c9f 100644 --- a/src/queries/delivery/deliver.js +++ b/src/queries/delivery/deliver.js @@ -1,5 +1,6 @@ import sql from 'mssql'; import { xmlTag, xmlElement } from './xml.js'; +import { logger } from '../../logger.js'; // PicklistenAusliefernOptionen.VersandSetzen (0x0002). DeliveryStep.Run always sets // AuslieferungAusliefernContext.Optionen.VersandSetzen = true for POS/API orders @@ -34,6 +35,8 @@ export async function deliverPicklists(transaction, kBenutzer, kSessionId, kAuft xmlTag('fGewicht', 0), // NOT NULL column; real weight is recomputed from the order's articles further below in the proc ]); + logger.info(`deliver: kSessionId=${kSessionId} kAuftrag=${kAuftrag} kVersandArt=${kVersandArt} pakete=${pakete}`); + const result = await new sql.Request(transaction) .input('Pakete', sql.NVarChar(sql.MAX), pakete) .input('nOptions', sql.Int, AUSLIEFERN_OPTIONS) @@ -52,5 +55,8 @@ export async function deliverPicklists(transaction, kBenutzer, kSessionId, kAuft @xResult = @xResult OUTPUT; SELECT @xResult AS xResult; `); - return result.recordset[0]?.xResult ?? null; + + const xResult = result.recordset[0]?.xResult ?? null; + logger.info(`deliver: done kSessionId=${kSessionId} kAuftrag=${kAuftrag} xResult=${String(xResult).slice(0, 2000)}`); + return xResult; } diff --git a/src/queries/delivery/index.js b/src/queries/delivery/index.js index c2aa333..50b9ad4 100644 --- a/src/queries/delivery/index.js +++ b/src/queries/delivery/index.js @@ -4,6 +4,7 @@ import { reservePositions } from './reserve.js'; import { bookStockShortfallsAndRereserve } from './stock-shortage.js'; import { commitPicklists } from './commit.js'; import { deliverPicklists } from './deliver.js'; +import { logger } from '../../logger.js'; /** * Delivers the full ordered quantity of every position of an order, exactly @@ -23,8 +24,11 @@ export async function deliverOrder(transaction, kBenutzer, kAuftrag, kVersandArt return; } + logger.info(`deliverOrder: kAuftrag=${kAuftrag} kBenutzer=${kBenutzer} kVersandArt=${kVersandArt} deliveredItems=${JSON.stringify(deliveredItems)}`); + const kWarenLager = await getOutgoingWarehouse(transaction); const kWarenLagerPlatz = await getWarehousePlace(transaction, kWarenLager); + logger.info(`deliverOrder: kWarenLager=${kWarenLager} kWarenLagerPlatz=${kWarenLagerPlatz}`); const kSessionId = await openSession(transaction, kBenutzer); try { @@ -39,6 +43,10 @@ export async function deliverOrder(transaction, kBenutzer, kAuftrag, kVersandArt ); await commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag); await deliverPicklists(transaction, kBenutzer, kSessionId, kAuftrag, kVersandArt); + logger.info(`deliverOrder: delivered kAuftrag=${kAuftrag} kSessionId=${kSessionId}`); + } catch (err) { + logger.error(`deliverOrder: FAILED kAuftrag=${kAuftrag} kSessionId=${kSessionId}: ${err.message}`); + throw err; } finally { // Safe to run unconditionally (success or error): spPicklistenVerwerfen only // ever removes not-yet-delivered (nStatus < 10) Picklisten for this session. diff --git a/src/queries/delivery/reserve.js b/src/queries/delivery/reserve.js index be650ac..fdde7ba 100644 --- a/src/queries/delivery/reserve.js +++ b/src/queries/delivery/reserve.js @@ -1,5 +1,6 @@ import sql from 'mssql'; import { xmlTag, xmlElement } from './xml.js'; +import { logger } from '../../logger.js'; // ReserviereBestellpositionenOptionen: ChargenVorbelegen (0x0002) | Stücklistenkorrektur (0x0100). // Matches jtlCore's AuslieferungContext ctor + the StoredProcedures.cs wrapper for @@ -34,6 +35,10 @@ export async function reservePositions(transaction, kBenutzer, kSessionId, kWare xmlTag('kAnsprechpartner', 0), ]); + logger.info(`reserve: kSessionId=${kSessionId} kWarenLager=${kWarenLager} nOptions=0x${RESERVIERE_OPTIONS.toString(16)} positions=${JSON.stringify(positions)}`); + logger.info(`reserve: XML @Bestellpositionen=${bestellpositionen}`); + logger.info(`reserve: XML @Laeger=${laeger}`); + await new sql.Request(transaction) .input('Bestellpositionen', sql.NVarChar(sql.MAX), bestellpositionen) .input('Laeger', sql.NVarChar(sql.MAX), laeger) @@ -51,4 +56,6 @@ export async function reservePositions(transaction, kBenutzer, kSessionId, kWare @kBenutzer = @kBenutzer, @kSessionId = @kSessionId; `); + + logger.info(`reserve: done kSessionId=${kSessionId} positions=${JSON.stringify(positions)}`); } diff --git a/src/queries/delivery/session.js b/src/queries/delivery/session.js index a0cbbee..18e2631 100644 --- a/src/queries/delivery/session.js +++ b/src/queries/delivery/session.js @@ -1,4 +1,5 @@ import sql from 'mssql'; +import { logger } from '../../logger.js'; /** * Opens a JTL "Auslieferung" session by inserting a row into dbo.tSessionId, @@ -17,7 +18,9 @@ export async function openSession(transaction, kBenutzer, hostname = 'jtlsrv') { VALUES (@cRechnername, @kBenutzer, DATEADD(day, 10, GETDATE())); SELECT kSessionId FROM @t; `); - return result.recordset[0].kSessionId; + const kSessionId = result.recordset[0].kSessionId; + logger.info(`session: opened kSessionId=${kSessionId} kBenutzer=${kBenutzer}`); + return kSessionId; } /** @@ -28,6 +31,7 @@ export async function openSession(transaction, kBenutzer, hostname = 'jtlsrv') { * a successful delivery and on the error path. */ export async function discardSession(transaction, kBenutzer, kSessionId) { + logger.info(`session: discard kSessionId=${kSessionId} kBenutzer=${kBenutzer}`); await new sql.Request(transaction) .input('kBenutzer', sql.Int, kBenutzer) .input('kSessionId', sql.Int, kSessionId) @@ -40,6 +44,7 @@ export async function discardSession(transaction, kBenutzer, kSessionId) { * Wawi GUI's "who's editing what" bookkeeping), but keeps the table tidy. */ export async function closeSession(transaction, kSessionId) { + logger.info(`session: close kSessionId=${kSessionId}`); await new sql.Request(transaction) .input('kSessionId', sql.Int, kSessionId) .query('DELETE FROM dbo.tSessionId WHERE kSessionId = @kSessionId'); diff --git a/src/queries/delivery/stock-shortage.js b/src/queries/delivery/stock-shortage.js index d28bab3..f906b1e 100644 --- a/src/queries/delivery/stock-shortage.js +++ b/src/queries/delivery/stock-shortage.js @@ -1,5 +1,6 @@ import sql from 'mssql'; import { reservePositions } from './reserve.js'; +import { logger } from '../../logger.js'; const POS_SHORTAGE_COMMENT = 'Korrekturbuchung erstellt durch POS-Abgleich'; const BUCHUNGSART_WARENEINGANG = 10; @@ -15,18 +16,23 @@ async function getReservedQuantity(transaction, kSessionId, kAuftragPosition) { WHERE p.kSessionId = @kSessionId AND pp.kBestellPos = @kBestellPos `); - return Number(result.recordset[0]?.reserved ?? 0); + const reserved = Number(result.recordset[0]?.reserved ?? 0); + logger.info(`stockShortage: getReservedQuantity kSessionId=${kSessionId} kBestellPos=${kAuftragPosition} reserved=${reserved}`); + return reserved; } async function getPositionArtikel(transaction, kAuftragPosition) { const result = await new sql.Request(transaction) .input('kAuftragPosition', sql.Int, kAuftragPosition) .query(` - SELECT kArtikel - FROM Verkauf.tAuftragPosition - WHERE kAuftragPosition = @kAuftragPosition + SELECT ap.kArtikel, ap.nType, ap.nReserviert, a.cLagerAktiv, a.cLagerArtikel, a.cArtNr + FROM Verkauf.tAuftragPosition ap + LEFT JOIN dbo.tArtikel a ON a.kArtikel = ap.kArtikel + WHERE ap.kAuftragPosition = @kAuftragPosition `); - return result.recordset[0]?.kArtikel ?? 0; + const row = result.recordset[0]; + logger.info(`stockShortage: getPositionArtikel kBestellPos=${kAuftragPosition} row=${JSON.stringify(row)}`); + return row?.kArtikel ?? 0; } async function bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtikel, fehlmenge) { @@ -74,30 +80,41 @@ export async function bookStockShortfallsAndRereserve( kWarenLagerPlatz, positions, ) { + logger.info(`stockShortage: bookStockShortfallsAndRereserve kBenutzer=${kBenutzer} kSessionId=${kSessionId} kWarenLager=${kWarenLager} kWarenLagerPlatz=${kWarenLagerPlatz} positions=${JSON.stringify(positions)}`); + const rereserve = []; for (const { kAuftragPosition, quantity } of positions) { if (!kAuftragPosition || quantity <= 0) { + logger.info(`stockShortage: skip position kBestellPos=${kAuftragPosition} (no kAuftragPosition or qty<=0)`); continue; } const reserved = await getReservedQuantity(transaction, kSessionId, kAuftragPosition); const shortage = quantity - reserved; + logger.info(`stockShortage: kBestellPos=${kAuftragPosition} quantity=${quantity} reserved=${reserved} shortage=${shortage}`); if (shortage <= 0.0001) { + logger.info(`stockShortage: kBestellPos=${kAuftragPosition} no shortage, skip`); continue; } const kArtikel = await getPositionArtikel(transaction, kAuftragPosition); + logger.info(`stockShortage: kBestellPos=${kAuftragPosition} kArtikel=${kArtikel}`); if (!kArtikel) { + logger.info(`stockShortage: kBestellPos=${kAuftragPosition} no kArtikel (free position / Pfand?), skip shortage booking`); continue; } await bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtikel, shortage); + logger.info(`stockShortage: booked Wareneingang kArtikel=${kArtikel} kWarenLagerPlatz=${kWarenLagerPlatz} fehlmenge=${shortage}`); rereserve.push({ kAuftragPosition, quantity: shortage }); } if (rereserve.length) { + logger.info(`stockShortage: re-reserving ${JSON.stringify(rereserve)}`); await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, rereserve); + } else { + logger.info('stockShortage: no re-reserve needed'); } for (const { kAuftragPosition, quantity } of positions) { @@ -105,8 +122,12 @@ export async function bookStockShortfallsAndRereserve( continue; } const reserved = await getReservedQuantity(transaction, kSessionId, kAuftragPosition); + logger.info(`stockShortage: final check kBestellPos=${kAuftragPosition} need=${quantity} reserved=${reserved}`); if (reserved + 0.0001 < quantity) { + logger.error(`stockShortage: FAIL kBestellPos=${kAuftragPosition} need=${quantity} reserved=${reserved}`); throw new Error(`insufficient stock after POS shortage booking for kBestellPos=${kAuftragPosition}`); } } + + logger.info('stockShortage: all positions fully reserved after shortage booking'); }