Compare commits
2 Commits
6312eaec48
...
6abe2032e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6abe2032e8 | ||
|
|
5951e10ea5 |
@@ -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
32
API.md
@@ -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
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';
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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