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).
This commit is contained in:
seb
2026-08-13 13:19:47 +02:00
parent 538ecf7a80
commit 5951e10ea5
14 changed files with 211 additions and 10 deletions

View File

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

32
API.md
View File

@@ -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"`.

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 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();

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');

View File

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