Compare commits

...

2 Commits

Author SHA1 Message Date
seb
6abe2032e8 Merge remote-tracking branch 'origin/main' 2026-08-13 13:38:19 +02:00
seb
5951e10ea5 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).
2026-08-13 13:19:47 +02:00
14 changed files with 211 additions and 10 deletions

View File

@@ -3,8 +3,15 @@ DEMO_MODE=false
# HTTPS POS server # HTTPS POS server
PORT=4443 PORT=4443
# Plain HTTP POS server (no TLS) - same API, unencrypted
HTTP_PORT=8087
AUTH_TOKEN=df40ad2067954646abb0499548a52241 AUTH_TOKEN=df40ad2067954646abb0499548a52241
PAIRING_CODE=307018 PAIRING_CODE=307018
# Allow generating fresh pairing codes on demand via GET /v1/newpin (default: false)
NEWPIN_ENABLED=false
LOG_FILE=logs/requests.log LOG_FILE=logs/requests.log
ORDER_LOG_FILE=logs/orders.log ORDER_LOG_FILE=logs/orders.log

32
API.md
View File

@@ -92,6 +92,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 ## `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"`. Returns how many entities changed since each cursor. Poll this; only fetch list endpoints when the matching `*_count` is greater than `"0"`.

51
nginx.conf.example Normal file
View File

@@ -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;
}

View File

@@ -1,4 +1,5 @@
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https'; import https from 'node:https';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -18,6 +19,7 @@ import { fetchActiveShop } from './src/shop.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT) || 4443; 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 AUTH_TOKEN = process.env.AUTH_TOKEN || 'df40ad2067954646abb0499548a52241';
const PAIRING_CODE = process.env.PAIRING_CODE || '307018'; const PAIRING_CODE = process.env.PAIRING_CODE || '307018';
@@ -122,6 +124,8 @@ const httpsServer = https.createServer(
loggedJtlHandler loggedJtlHandler
); );
const httpServer = http.createServer(loggedJtlHandler);
async function start() { async function start() {
if (isDemoMode()) { if (isDemoMode()) {
const stats = await loadDemoCatalog(); const stats = await loadDemoCatalog();
@@ -147,11 +151,16 @@ async function start() {
logger.info(`Pairing code: ${PAIRING_CODE}`); logger.info(`Pairing code: ${PAIRING_CODE}`);
logger.info(`Auth token: ${AUTH_TOKEN}`); 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() { async function shutdown() {
logger.info('Shutting down...'); logger.info('Shutting down...');
httpsServer.close(); httpsServer.close();
httpServer.close();
await closeDb(); await closeDb();
closeRequestLog(); closeRequestLog();
closeOrderLog(); closeOrderLog();

View File

@@ -4,9 +4,10 @@ import * as client from './client.js';
import * as customergroup from './customergroup.js'; import * as customergroup from './customergroup.js';
import * as deletedEntity from './deleted-entity.js'; import * as deletedEntity from './deleted-entity.js';
import * as init from './init.js'; import * as init from './init.js';
import * as newpin from './newpin.js';
import * as order from './order.js'; import * as order from './order.js';
import * as pimage from './pimage.js'; import * as pimage from './pimage.js';
import * as product from './product.js'; import * as product from './product.js';
import * as productcomposite from './productcomposite.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];

27
src/endpoints/newpin.js Normal file
View File

@@ -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(),
});
}

View File

@@ -10,6 +10,7 @@ function buildConfig(config = {}) {
mandantId: config.mandantId || process.env.MANDANT_ID || '1', mandantId: config.mandantId || process.env.MANDANT_ID || '1',
mandantName: config.mandantName || process.env.MANDANT_NAME || 'eB-Standard', mandantName: config.mandantName || process.env.MANDANT_NAME || 'eB-Standard',
mandantDatabase: config.mandantDatabase || process.env.MANDANT_DATABASE || 'eazybusiness', mandantDatabase: config.mandantDatabase || process.env.MANDANT_DATABASE || 'eazybusiness',
newPinEnabled: config.newPinEnabled ?? process.env.NEWPIN_ENABLED === 'true',
}; };
} }

View File

@@ -3,6 +3,7 @@ import { isDemoMode } from '../demo/mode.js';
import { createDemoOrder } from '../demo/store.js'; import { createDemoOrder } from '../demo/store.js';
import { getPool } from '../db.js'; import { getPool } from '../db.js';
import { getActiveShopId, getActiveShopSubshopId } from '../shop.js'; import { getActiveShopId, getActiveShopSubshopId } from '../shop.js';
import { logger } from '../logger.js';
import { deliverOrder } from './delivery/index.js'; import { deliverOrder } from './delivery/index.js';
const config = { const config = {
@@ -451,8 +452,18 @@ async function insertOrderItem(transaction, kAuftrag, item) {
if (sku && positionType !== VERSANDPOSITION_TYPE) { if (sku && positionType !== VERSANDPOSITION_TYPE) {
const result = await new sql.Request(transaction) const result = await new sql.Request(transaction)
.input('cArtNr', sql.NVarChar, sku) .input('cArtNr', sql.NVarChar, sku)
.query('SELECT TOP 1 kArtikel FROM dbo.tArtikel WHERE cArtNr = @cArtNr'); .query('SELECT TOP 1 kArtikel, nIstVater FROM dbo.tArtikel WHERE cArtNr = @cArtNr');
kArtikel = result.recordset[0]?.kArtikel ?? null; 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); const nType = positionType === VERSANDPOSITION_TYPE ? VERSANDPOSITION_TYPE : (kArtikel ? 1 : 0);
@@ -748,6 +759,7 @@ export async function createOrder(order) {
if (kAuftragPosition != null && !isVersandposition(item)) { if (kAuftragPosition != null && !isVersandposition(item)) {
deliveredItems.push({ kAuftragPosition, quantity: toNumber(item.quantity, 1) }); 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 || []) { for (const payment of order.payments || []) {
@@ -755,6 +767,15 @@ export async function createOrder(order) {
await insertPayment(transaction, kAuftrag, payment, order, orderDate, zahlungsartCache); 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)) { if (isOrderDelivered(order)) {
await deliverOrder(transaction, config.kBenutzer, kAuftrag, kVersandArt, deliveredItems); await deliverOrder(transaction, config.kBenutzer, kAuftrag, kVersandArt, deliveredItems);
} }

View File

@@ -1,5 +1,6 @@
import sql from 'mssql'; import sql from 'mssql';
import { xmlTag, xmlElement } from './xml.js'; import { xmlTag, xmlElement } from './xml.js';
import { logger } from '../../logger.js';
/** /**
* Commits this session's reservations into "real" Picklisten (bumps * 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) { export async function commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag) {
const bestellungen = xmlElement('Bestellung', [xmlTag('kBestellung', kAuftrag)]); const bestellungen = xmlElement('Bestellung', [xmlTag('kBestellung', kAuftrag)]);
logger.info(`commit: kSessionId=${kSessionId} kAuftrag=${kAuftrag} nTeillieferung=false bestellungen=${bestellungen}`);
await new sql.Request(transaction) await new sql.Request(transaction)
.input('Bestellungen', sql.NVarChar(sql.MAX), bestellungen) .input('Bestellungen', sql.NVarChar(sql.MAX), bestellungen)
.input('kBenutzer', sql.Int, kBenutzer) .input('kBenutzer', sql.Int, kBenutzer)
@@ -31,4 +34,6 @@ export async function commitPicklists(transaction, kBenutzer, kSessionId, kAuftr
@kSessionId = @kSessionId, @kSessionId = @kSessionId,
@xResult = @xResult OUTPUT; @xResult = @xResult OUTPUT;
`); `);
logger.info(`commit: done kSessionId=${kSessionId} kAuftrag=${kAuftrag}`);
} }

View File

@@ -1,5 +1,6 @@
import sql from 'mssql'; import sql from 'mssql';
import { xmlTag, xmlElement } from './xml.js'; import { xmlTag, xmlElement } from './xml.js';
import { logger } from '../../logger.js';
// PicklistenAusliefernOptionen.VersandSetzen (0x0002). DeliveryStep.Run always sets // PicklistenAusliefernOptionen.VersandSetzen (0x0002). DeliveryStep.Run always sets
// AuslieferungAusliefernContext.Optionen.VersandSetzen = true for POS/API orders // 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 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) const result = await new sql.Request(transaction)
.input('Pakete', sql.NVarChar(sql.MAX), pakete) .input('Pakete', sql.NVarChar(sql.MAX), pakete)
.input('nOptions', sql.Int, AUSLIEFERN_OPTIONS) .input('nOptions', sql.Int, AUSLIEFERN_OPTIONS)
@@ -52,5 +55,8 @@ export async function deliverPicklists(transaction, kBenutzer, kSessionId, kAuft
@xResult = @xResult OUTPUT; @xResult = @xResult OUTPUT;
SELECT @xResult AS xResult; 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;
} }

View File

@@ -4,6 +4,7 @@ import { reservePositions } from './reserve.js';
import { bookStockShortfallsAndRereserve } from './stock-shortage.js'; import { bookStockShortfallsAndRereserve } from './stock-shortage.js';
import { commitPicklists } from './commit.js'; import { commitPicklists } from './commit.js';
import { deliverPicklists } from './deliver.js'; import { deliverPicklists } from './deliver.js';
import { logger } from '../../logger.js';
/** /**
* Delivers the full ordered quantity of every position of an order, exactly * 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; return;
} }
logger.info(`deliverOrder: kAuftrag=${kAuftrag} kBenutzer=${kBenutzer} kVersandArt=${kVersandArt} deliveredItems=${JSON.stringify(deliveredItems)}`);
const kWarenLager = await getOutgoingWarehouse(transaction); const kWarenLager = await getOutgoingWarehouse(transaction);
const kWarenLagerPlatz = await getWarehousePlace(transaction, kWarenLager); const kWarenLagerPlatz = await getWarehousePlace(transaction, kWarenLager);
logger.info(`deliverOrder: kWarenLager=${kWarenLager} kWarenLagerPlatz=${kWarenLagerPlatz}`);
const kSessionId = await openSession(transaction, kBenutzer); const kSessionId = await openSession(transaction, kBenutzer);
try { try {
@@ -39,6 +43,10 @@ export async function deliverOrder(transaction, kBenutzer, kAuftrag, kVersandArt
); );
await commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag); await commitPicklists(transaction, kBenutzer, kSessionId, kAuftrag);
await deliverPicklists(transaction, kBenutzer, kSessionId, kAuftrag, kVersandArt); 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 { } finally {
// Safe to run unconditionally (success or error): spPicklistenVerwerfen only // Safe to run unconditionally (success or error): spPicklistenVerwerfen only
// ever removes not-yet-delivered (nStatus < 10) Picklisten for this session. // ever removes not-yet-delivered (nStatus < 10) Picklisten for this session.

View File

@@ -1,5 +1,6 @@
import sql from 'mssql'; import sql from 'mssql';
import { xmlTag, xmlElement } from './xml.js'; import { xmlTag, xmlElement } from './xml.js';
import { logger } from '../../logger.js';
// ReserviereBestellpositionenOptionen: ChargenVorbelegen (0x0002) | Stücklistenkorrektur (0x0100). // ReserviereBestellpositionenOptionen: ChargenVorbelegen (0x0002) | Stücklistenkorrektur (0x0100).
// Matches jtlCore's AuslieferungContext ctor + the StoredProcedures.cs wrapper for // 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), 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) await new sql.Request(transaction)
.input('Bestellpositionen', sql.NVarChar(sql.MAX), bestellpositionen) .input('Bestellpositionen', sql.NVarChar(sql.MAX), bestellpositionen)
.input('Laeger', sql.NVarChar(sql.MAX), laeger) .input('Laeger', sql.NVarChar(sql.MAX), laeger)
@@ -51,4 +56,6 @@ export async function reservePositions(transaction, kBenutzer, kSessionId, kWare
@kBenutzer = @kBenutzer, @kBenutzer = @kBenutzer,
@kSessionId = @kSessionId; @kSessionId = @kSessionId;
`); `);
logger.info(`reserve: done kSessionId=${kSessionId} positions=${JSON.stringify(positions)}`);
} }

View File

@@ -1,4 +1,5 @@
import sql from 'mssql'; import sql from 'mssql';
import { logger } from '../../logger.js';
/** /**
* Opens a JTL "Auslieferung" session by inserting a row into dbo.tSessionId, * 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())); VALUES (@cRechnername, @kBenutzer, DATEADD(day, 10, GETDATE()));
SELECT kSessionId FROM @t; 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. * a successful delivery and on the error path.
*/ */
export async function discardSession(transaction, kBenutzer, kSessionId) { export async function discardSession(transaction, kBenutzer, kSessionId) {
logger.info(`session: discard kSessionId=${kSessionId} kBenutzer=${kBenutzer}`);
await new sql.Request(transaction) await new sql.Request(transaction)
.input('kBenutzer', sql.Int, kBenutzer) .input('kBenutzer', sql.Int, kBenutzer)
.input('kSessionId', sql.Int, kSessionId) .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. * Wawi GUI's "who's editing what" bookkeeping), but keeps the table tidy.
*/ */
export async function closeSession(transaction, kSessionId) { export async function closeSession(transaction, kSessionId) {
logger.info(`session: close kSessionId=${kSessionId}`);
await new sql.Request(transaction) await new sql.Request(transaction)
.input('kSessionId', sql.Int, kSessionId) .input('kSessionId', sql.Int, kSessionId)
.query('DELETE FROM dbo.tSessionId WHERE kSessionId = @kSessionId'); .query('DELETE FROM dbo.tSessionId WHERE kSessionId = @kSessionId');

View File

@@ -1,5 +1,6 @@
import sql from 'mssql'; import sql from 'mssql';
import { reservePositions } from './reserve.js'; import { reservePositions } from './reserve.js';
import { logger } from '../../logger.js';
const POS_SHORTAGE_COMMENT = 'Korrekturbuchung erstellt durch POS-Abgleich'; const POS_SHORTAGE_COMMENT = 'Korrekturbuchung erstellt durch POS-Abgleich';
const BUCHUNGSART_WARENEINGANG = 10; const BUCHUNGSART_WARENEINGANG = 10;
@@ -15,18 +16,23 @@ async function getReservedQuantity(transaction, kSessionId, kAuftragPosition) {
WHERE p.kSessionId = @kSessionId WHERE p.kSessionId = @kSessionId
AND pp.kBestellPos = @kBestellPos 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) { async function getPositionArtikel(transaction, kAuftragPosition) {
const result = await new sql.Request(transaction) const result = await new sql.Request(transaction)
.input('kAuftragPosition', sql.Int, kAuftragPosition) .input('kAuftragPosition', sql.Int, kAuftragPosition)
.query(` .query(`
SELECT kArtikel SELECT ap.kArtikel, ap.nType, ap.nReserviert, a.cLagerAktiv, a.cLagerArtikel, a.cArtNr
FROM Verkauf.tAuftragPosition FROM Verkauf.tAuftragPosition ap
WHERE kAuftragPosition = @kAuftragPosition 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) { async function bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtikel, fehlmenge) {
@@ -74,30 +80,41 @@ export async function bookStockShortfallsAndRereserve(
kWarenLagerPlatz, kWarenLagerPlatz,
positions, positions,
) { ) {
logger.info(`stockShortage: bookStockShortfallsAndRereserve kBenutzer=${kBenutzer} kSessionId=${kSessionId} kWarenLager=${kWarenLager} kWarenLagerPlatz=${kWarenLagerPlatz} positions=${JSON.stringify(positions)}`);
const rereserve = []; const rereserve = [];
for (const { kAuftragPosition, quantity } of positions) { for (const { kAuftragPosition, quantity } of positions) {
if (!kAuftragPosition || quantity <= 0) { if (!kAuftragPosition || quantity <= 0) {
logger.info(`stockShortage: skip position kBestellPos=${kAuftragPosition} (no kAuftragPosition or qty<=0)`);
continue; continue;
} }
const reserved = await getReservedQuantity(transaction, kSessionId, kAuftragPosition); const reserved = await getReservedQuantity(transaction, kSessionId, kAuftragPosition);
const shortage = quantity - reserved; const shortage = quantity - reserved;
logger.info(`stockShortage: kBestellPos=${kAuftragPosition} quantity=${quantity} reserved=${reserved} shortage=${shortage}`);
if (shortage <= 0.0001) { if (shortage <= 0.0001) {
logger.info(`stockShortage: kBestellPos=${kAuftragPosition} no shortage, skip`);
continue; continue;
} }
const kArtikel = await getPositionArtikel(transaction, kAuftragPosition); const kArtikel = await getPositionArtikel(transaction, kAuftragPosition);
logger.info(`stockShortage: kBestellPos=${kAuftragPosition} kArtikel=${kArtikel}`);
if (!kArtikel) { if (!kArtikel) {
logger.info(`stockShortage: kBestellPos=${kAuftragPosition} no kArtikel (free position / Pfand?), skip shortage booking`);
continue; continue;
} }
await bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtikel, shortage); await bookWareneingang(transaction, kBenutzer, kWarenLagerPlatz, kArtikel, shortage);
logger.info(`stockShortage: booked Wareneingang kArtikel=${kArtikel} kWarenLagerPlatz=${kWarenLagerPlatz} fehlmenge=${shortage}`);
rereserve.push({ kAuftragPosition, quantity: shortage }); rereserve.push({ kAuftragPosition, quantity: shortage });
} }
if (rereserve.length) { if (rereserve.length) {
logger.info(`stockShortage: re-reserving ${JSON.stringify(rereserve)}`);
await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, rereserve); await reservePositions(transaction, kBenutzer, kSessionId, kWarenLager, rereserve);
} else {
logger.info('stockShortage: no re-reserve needed');
} }
for (const { kAuftragPosition, quantity } of positions) { for (const { kAuftragPosition, quantity } of positions) {
@@ -105,8 +122,12 @@ export async function bookStockShortfallsAndRereserve(
continue; continue;
} }
const reserved = await getReservedQuantity(transaction, kSessionId, kAuftragPosition); const reserved = await getReservedQuantity(transaction, kSessionId, kAuftragPosition);
logger.info(`stockShortage: final check kBestellPos=${kAuftragPosition} need=${quantity} reserved=${reserved}`);
if (reserved + 0.0001 < quantity) { 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}`); throw new Error(`insufficient stock after POS shortage booking for kBestellPos=${kAuftragPosition}`);
} }
} }
logger.info('stockShortage: all positions fully reserved after shortage booking');
} }