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:
@@ -1,7 +1,14 @@
|
|||||||
# 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
32
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
|
## `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
51
nginx.conf.example
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -15,6 +16,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';
|
||||||
|
|
||||||
@@ -112,6 +114,8 @@ const httpsServer = https.createServer(
|
|||||||
loggedJtlHandler
|
loggedJtlHandler
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const httpServer = http.createServer(loggedJtlHandler);
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
try {
|
try {
|
||||||
const pool = await connectDb();
|
const pool = await connectDb();
|
||||||
@@ -129,11 +133,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();
|
||||||
|
|||||||
@@ -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
27
src/endpoints/newpin.js
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,6 +19,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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
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 = {
|
||||||
@@ -449,8 +450,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);
|
||||||
@@ -742,6 +753,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 || []) {
|
||||||
@@ -749,6 +761,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user