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