This commit is contained in:
seb
2026-07-06 01:31:11 +02:00
commit 917930d0fa
34 changed files with 2912 additions and 0 deletions

34
.env.example Normal file
View File

@@ -0,0 +1,34 @@
# HTTPS POS server
PORT=4443
AUTH_TOKEN=df40ad2067954646abb0499548a52241
PAIRING_CODE=307018
LOG_FILE=logs/requests.log
ORDER_LOG_FILE=logs/orders.log
# TLS certificate metadata returned during pairing
CERTIFICATE_FINGERPRINT=BC2114CF407A42724BEEF417960F76DCBF9DE879
CERTIFICATE_SERIAL_NUMBER=00BFC8BEACDB981B165210EF111CB9D3
SERVER_FINGERPRINT=39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE
# Mandant
MANDANT_ID=1
MANDANT_NAME=eB-Standard
MANDANT_DATABASE=eazybusiness
ROOT_CATEGORY_ID=1
# Category sync (tKategorieSprache / tKategoriebildPlattform lookups)
LANGUAGE_ID=1
IMAGE_PLATFORM_ID=1
IMAGE_SHOP_ID=0
# Product sync (tSteuerzone.cName used to look up tax rates per tSteuerklasse)
TAX_ZONE_NAME=Zone-EU
# MSSQL (connection data for JTL-Wawi database)
MSSQL_SERVER=localhost
MSSQL_PORT=1433
MSSQL_DATABASE=eazybusiness
MSSQL_USER=sa
MSSQL_PASSWORD=
MSSQL_ENCRYPT=false
MSSQL_TRUST_SERVER_CERTIFICATE=true

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.env
certs/
logs/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
loglevel=silent

24
generate-cert.js Normal file
View File

@@ -0,0 +1,24 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { logger } from './src/logger.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const certsDir = path.join(__dirname, 'certs');
const keyPath = path.join(certsDir, 'key.pem');
const certPath = path.join(certsDir, 'cert.pem');
fs.mkdirSync(certsDir, { recursive: true });
const subject = '/CN=localhost/O=JTL POS Sync/C=DE';
const san = 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0';
execSync(
`openssl req -x509 -newkey rsa:2048 -nodes -keyout "${keyPath}" -out "${certPath}" -days 3650 -subj "${subject}" -addext "${san}"`,
{ stdio: 'inherit' }
);
logger.success(`Wrote ${keyPath}`);
logger.success(`Wrote ${certPath}`);

1542
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "posSync",
"version": "1.0.0",
"description": "sync server",
"license": "0BSD",
"author": "",
"type": "module",
"main": "server.js",
"scripts": {
"cert": "node generate-cert.js",
"start": "node server.js",
"test:client": "node test-client.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"chalk": "^5.6.2",
"dotenv": "^17.4.2",
"mssql": "^12.7.0",
"sharp": "^0.35.3"
}
}

124
server.js Normal file
View File

@@ -0,0 +1,124 @@
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import 'dotenv/config';
import { connectDb, closeDb } from './src/db.js';
import { createJtlPosServer } from './src/jtl-server.js';
import { createPairingStore } from './src/pairing.js';
import { closeOrderLog } from './src/order-log.js';
import { closeRequestLog, logRequest } from './src/request-log.js';
import { logger } from './src/logger.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT) || 4443;
const AUTH_TOKEN = process.env.AUTH_TOKEN || 'df40ad2067954646abb0499548a52241';
const PAIRING_CODE = process.env.PAIRING_CODE || '307018';
const certsDir = path.join(__dirname, 'certs');
const keyPath = path.join(certsDir, 'key.pem');
const certPath = path.join(certsDir, 'cert.pem');
if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
logger.error('Missing TLS certificate. Run: npm run cert');
process.exit(1);
}
const CONSOLE_URL_MAX_LENGTH = 100;
function truncateUrl(url) {
if (url.length <= CONSOLE_URL_MAX_LENGTH) {
return url;
}
return `${url.slice(0, CONSOLE_URL_MAX_LENGTH)}...`;
}
function formatBody(buffer) {
if (!buffer.length) {
return '(empty)';
}
const text = buffer.toString('utf8');
if (/^[\x09\x0A\x0D\x20-\x7E\u0080-\uFFFF]*$/.test(text)) {
return text;
}
return `[binary ${buffer.length} bytes]`;
}
const pairingStore = createPairingStore();
pairingStore.setPairingCode(PAIRING_CODE, 'JTL-POS');
pairingStore.registerDevice(AUTH_TOKEN, 'JTL-POS');
const jtlHandler = createJtlPosServer(pairingStore, { authToken: AUTH_TOKEN });
const loggedJtlHandler = async (req, res) => {
const started = Date.now();
let responseBuffer = Buffer.alloc(0);
const originalEnd = res.end.bind(res);
res.end = (chunk, ...args) => {
if (chunk) {
responseBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
}
return originalEnd(chunk, ...args);
};
await jtlHandler(req, res);
const responseBody = formatBody(responseBuffer);
const durationMs = Date.now() - started;
logger.info(`${req.socket.remoteAddress} ${req.method} ${truncateUrl(req.url)} ${res.statusCode} ${durationMs}ms`);
logRequest({
remoteAddress: req.socket.remoteAddress,
method: req.method,
url: req.url,
statusCode: res.statusCode,
durationMs,
response: responseBody,
});
};
const httpsServer = https.createServer(
{
key: fs.readFileSync(keyPath),
cert: fs.readFileSync(certPath),
},
loggedJtlHandler
);
async function start() {
try {
await connectDb();
logger.success(`MSSQL connected: ${process.env.MSSQL_SERVER}/${process.env.MSSQL_DATABASE}`);
} catch (err) {
logger.warn(`MSSQL connection skipped: ${err.message}`);
logger.warn('POS handshake will still work; sync from database is not available yet.');
}
httpsServer.listen(PORT, '0.0.0.0', () => {
logger.success(`HTTPS POS server listening on https://0.0.0.0:${PORT}`);
logger.info(`Certificate: ${certPath}`);
logger.info(`Pairing code: ${PAIRING_CODE}`);
logger.info(`Auth token: ${AUTH_TOKEN}`);
logger.info('Import cert.pem into JTL POS trust store if required.');
});
}
async function shutdown() {
logger.info('Shutting down...');
httpsServer.close();
await closeDb();
closeRequestLog();
closeOrderLog();
}
process.on('SIGINT', () => {
shutdown().finally(() => process.exit(0));
});
process.on('SIGTERM', () => {
shutdown().finally(() => process.exit(0));
});
start();

49
src/db.js Normal file
View File

@@ -0,0 +1,49 @@
import sql from 'mssql';
import 'dotenv/config';
const config = {
server: process.env.MSSQL_SERVER || 'localhost',
port: Number(process.env.MSSQL_PORT) || 1433,
database: process.env.MSSQL_DATABASE || 'eazybusiness',
user: process.env.MSSQL_USER,
password: process.env.MSSQL_PASSWORD,
options: {
encrypt: process.env.MSSQL_ENCRYPT !== 'false',
trustServerCertificate: process.env.MSSQL_TRUST_SERVER_CERTIFICATE !== 'false',
},
};
/** @type {sql.ConnectionPool | null} */
let pool = null;
export function getPool() {
if (!pool) {
throw new Error('MSSQL pool is not connected. Call connectDb() first.');
}
return pool;
}
export async function connectDb() {
if (pool) {
return pool;
}
if (!config.user) {
throw new Error('MSSQL_USER is not set in environment');
}
pool = await sql.connect(config);
return pool;
}
export async function closeDb() {
if (pool) {
await pool.close();
pool = null;
}
}
export async function pingDb() {
const result = await getPool().request().query('SELECT 1 AS ok');
return result.recordset[0]?.ok === 1;
}

23
src/endpoints/category.js Normal file
View File

@@ -0,0 +1,23 @@
import { sendJson, serverTimestamp } from '../http.js';
import { getCategoryList } from '../queries/category-list.js';
export const method = 'GET';
export const path = '/v1/category';
export async function handle(_req, res, { url }) {
const cursor = Number(url.searchParams.get('lastChangedCategory')) || 0;
const limit = Number(url.searchParams.get('limit')) || 20;
const categories = await getCategoryList({ cursor, limit });
const timestamp = serverTimestamp();
return sendJson(
res,
200,
categories.map((category) => ({
...category,
updated_at: timestamp,
created_at: timestamp,
}))
);
}

6
src/endpoints/cimage.js Normal file
View File

@@ -0,0 +1,6 @@
import { createImageHandler } from './image-handler.js';
export const method = 'GET';
export const path = '/v1/cimage';
export const handle = createImageHandler();

71
src/endpoints/client.js Normal file
View File

@@ -0,0 +1,71 @@
import { sendJson, serverTimestamp } from '../http.js';
export const method = 'GET';
export const path = '/v1/client';
function buildClientStep1(config) {
const {
authToken,
certificateFingerprint,
certificateSerialNumber,
mandantId,
serverFingerprint,
} = config;
return {
authCode: null,
authToken,
certificateFingerprint,
certificateSerialNumber,
mandantId,
mandantName: null,
mandantDatabase: null,
serverFingerprint,
name: null,
serverTimestamp: serverTimestamp(),
};
}
function buildClientStep2(authCode, config) {
const {
authToken,
certificateFingerprint,
certificateSerialNumber,
mandantId,
mandantName,
mandantDatabase,
} = config;
return {
authCode,
authToken,
certificateFingerprint,
certificateSerialNumber,
mandantId,
mandantName,
mandantDatabase,
serverFingerprint: null,
name: null,
serverTimestamp: serverTimestamp(),
};
}
export function handle(req, res, { url, pairingStore, config }) {
const authCode = url.searchParams.get('authCode') || '';
const name = url.searchParams.get('name') || 'JTL-POS';
if (authCode.length <= 4 && authCode.length > 0) {
return sendJson(res, 200, buildClientStep1(config));
}
if (authCode.length === 6) {
if (pairingStore.hasPairingCode(authCode)) {
pairingStore.revokePairingCode(authCode);
pairingStore.registerDevice(config.authToken, name);
return sendJson(res, 200, buildClientStep2(authCode, config));
}
return sendJson(res, 400, { Message: 'Der Authentifizierungscode ist falsch.' });
}
return sendJson(res, 400, { Message: 'Keinen passenden Authentifizierungscode gefunden.' });
}

View File

@@ -0,0 +1,13 @@
import { sendJson } from '../http.js';
import { getCustomerGroupList } from '../queries/customer-groups.js';
export const method = 'GET';
export const path = '/v1/customergroup';
export async function handle(_req, res, { url }) {
const cursor = Number(url.searchParams.get('lastChangedCustomerGroup')) || 0;
const customerGroups = await getCustomerGroupList({ cursor });
return sendJson(res, 200, customerGroups);
}

View File

@@ -0,0 +1,21 @@
import { sendBinary, sendJson } from '../http.js';
import { resizeImage } from '../image-resize.js';
import { getImageByHash } from '../queries/image.js';
export function createImageHandler() {
return async function handle(_req, res, { url }) {
const path = url.searchParams.get('path');
if (!path) {
return sendJson(res, 400, { Message: "Missing required query parameter 'path'." });
}
const image = await getImageByHash(path, '200');
if (!image) {
return sendJson(res, 404, { Message: `No image was found for path '${path}'.` });
}
const buffer = await resizeImage(image.buffer);
return sendBinary(res, 200, buffer, image.contentType);
};
}

10
src/endpoints/index.js Normal file
View File

@@ -0,0 +1,10 @@
import * as category from './category.js';
import * as cimage from './cimage.js';
import * as client from './client.js';
import * as customergroup from './customergroup.js';
import * as init from './init.js';
import * as order from './order.js';
import * as pimage from './pimage.js';
import * as product from './product.js';
export const endpoints = [client, init, category, product, pimage, cimage, customergroup, order];

33
src/endpoints/init.js Normal file
View File

@@ -0,0 +1,33 @@
import { sendJson } from '../http.js';
import { getMaxExternalId } from '../order-log.js';
import { getCategoryCount } from '../queries/category-count.js';
import { getCustomerGroupCount } from '../queries/customer-groups.js';
import { getProductCount } from '../queries/product-count.js';
export const method = 'GET';
export const path = '/v1/init';
export async function handle(_req, res, { url }) {
const productCursor = Number(url.searchParams.get('lastChangedProduct')) || 0;
const categoryCursor = Number(url.searchParams.get('lastChangedCategory')) || 0;
const customerGroupCursor = Number(url.searchParams.get('lastChangedCustomerGroup')) || 0;
const [productCount, categoryCount, customerGroupCount] = await Promise.all([
getProductCount({ cursor: productCursor }),
getCategoryCount({ cursor: categoryCursor }),
getCustomerGroupCount({ cursor: customerGroupCursor }),
]);
return sendJson(res, 200, {
version: '1.10.12.0',
product_count: String(productCount),
category_count: String(categoryCount),
customer_count: '0',
customerGroup_count: String(customerGroupCount),
compositeProduct_count: '0',
configurationGroup_count: '0',
configurationItem_count: '0',
deletedEntity_count: '0',
max_orderId_count: getMaxExternalId(),
});
}

48
src/endpoints/order.js Normal file
View File

@@ -0,0 +1,48 @@
import { sendJson } from '../http.js';
import { logOrder } from '../order-log.js';
export const method = 'POST';
export const path = '/v1/order';
function parseBody(buffer) {
if (!buffer || !buffer.length) {
return null;
}
try {
return JSON.parse(buffer.toString('utf8'));
} catch {
return undefined;
}
}
function getOrders(body) {
if (!body || typeof body !== 'object') {
return [];
}
if (Array.isArray(body.orders)) {
return body.orders;
}
return [];
}
export async function handle(req, res) {
const body = parseBody(req.rawBody);
if (body === undefined) {
return sendJson(res, 500, []);
}
const orders = getOrders(body);
const results = orders.map((order) => {
logOrder(order);
const externalOrderId = String(order?.externalId ?? '');
return {
status: 'OK',
externalOrderId,
message: '',
};
});
return sendJson(res, 200, results);
}

6
src/endpoints/pimage.js Normal file
View File

@@ -0,0 +1,6 @@
import { createImageHandler } from './image-handler.js';
export const method = 'GET';
export const path = '/v1/pimage';
export const handle = createImageHandler();

67
src/endpoints/product.js Normal file
View File

@@ -0,0 +1,67 @@
import { sendJson } from '../http.js';
import { getProductList } from '../queries/product-list.js';
export const method = 'GET';
export const path = '/v1/product';
const STATIC_DEFAULTS = {
sort: '0',
p_price: '0.00',
discountable: '0',
deposit: '0',
discount: '',
d_price: '0.0',
tax_rate2: '',
use_in_out_tax: '0',
barcode: null,
use_stock: '0',
q_div: '0',
quantity: '0',
unit: null,
single_bookable: '0',
annotation: '',
status: '0',
tags: '',
is_parent: '0',
parent: '0',
variants: '',
print_kitchen_receipt: '0',
deposit_name: '',
updated_at: '0001-01-01 00:00:00',
isCompositeProduct: '0',
attributes: [],
configurationGroups: '',
options: null,
hasBestBeforeDate: '0',
hasLotNumber: '0',
hasSerialNumber: '0',
PLU: '',
short_description: '',
minStock: '0',
container: [],
reservedQuantity: '0.00',
deliveryDetails: [],
isbn: '',
manufacturerName: null,
han: null,
productType: '0',
voucherData: null,
inputPrice: '0',
inputQuantity: '0',
};
export async function handle(_req, res, { url }) {
const cursor = Number(url.searchParams.get('lastChangedProduct')) || 0;
const limit = Number(url.searchParams.get('limit')) || 20;
const products = await getProductList({ cursor, limit });
return sendJson(
res,
200,
products.map((product) => ({
...STATIC_DEFAULTS,
...product,
}))
);
}

35
src/http.js Normal file
View File

@@ -0,0 +1,35 @@
export function serverTimestamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
export function sendJson(res, statusCode, body) {
const responseBody = JSON.stringify(body);
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(responseBody),
});
res.end(responseBody);
}
export function sendBinary(res, statusCode, buffer, contentType) {
res.writeHead(statusCode, {
'Content-Type': contentType,
'Content-Length': buffer.length,
});
res.end(buffer);
}
export function normalizePath(pathname) {
return pathname.replace(/^\/api(?=\/v1\/)/, '');
}

9
src/image-resize.js Normal file
View File

@@ -0,0 +1,9 @@
import sharp from 'sharp';
const MAX_DIMENSION = 200;
export async function resizeImage(buffer) {
return sharp(buffer)
.resize(MAX_DIMENSION, MAX_DIMENSION, { fit: 'inside', withoutEnlargement: true })
.toBuffer();
}

52
src/jtl-server.js Normal file
View File

@@ -0,0 +1,52 @@
import { endpoints } from './endpoints/index.js';
import { normalizePath, readBody, sendJson } from './http.js';
function buildConfig(config = {}) {
return {
authToken: config.authToken || process.env.AUTH_TOKEN || 'df40ad2067954646abb0499548a52241',
certificateFingerprint:
config.certificateFingerprint ||
process.env.CERTIFICATE_FINGERPRINT ||
'BC2114CF407A42724BEEF417960F76DCBF9DE879',
certificateSerialNumber:
config.certificateSerialNumber ||
process.env.CERTIFICATE_SERIAL_NUMBER ||
'00BFC8BEACDB981B165210EF111CB9D3',
serverFingerprint:
config.serverFingerprint ||
process.env.SERVER_FINGERPRINT ||
'39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE',
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',
};
}
export function createJtlPosServer(pairingStore, config = {}) {
const resolvedConfig = buildConfig(config);
const routes = new Map(endpoints.map((endpoint) => [`${endpoint.method} ${endpoint.path}`, endpoint]));
async function handle(req, res) {
const url = new URL(req.url, 'https://localhost');
const pathname = normalizePath(url.pathname);
const routeKey = `${req.method} ${pathname}`;
const endpoint = routes.get(routeKey);
if (endpoint) {
return await endpoint.handle(req, res, { url, pairingStore, config: resolvedConfig });
}
return sendJson(res, 404, {
Message: `No HTTP resource was found that matches the request URI '${url}'.`,
});
}
return async function requestListener(req, res) {
req.rawBody = await readBody(req);
try {
await handle(req, res);
} catch (err) {
sendJson(res, 500, { Message: err.message });
}
};
}

20
src/logger.js Normal file
View File

@@ -0,0 +1,20 @@
import chalk from 'chalk';
function timestamp() {
return chalk.gray(new Date().toISOString());
}
export const logger = {
info(...args) {
console.log(timestamp(), chalk.cyan('INFO'), ...args);
},
success(...args) {
console.log(timestamp(), chalk.green('OK'), ...args);
},
warn(...args) {
console.warn(timestamp(), chalk.yellow('WARN'), ...args);
},
error(...args) {
console.error(timestamp(), chalk.red('ERROR'), ...args);
},
};

28
src/order-log.js Normal file
View File

@@ -0,0 +1,28 @@
import fs from 'node:fs';
import path from 'node:path';
const ORDER_LOG_FILE = process.env.ORDER_LOG_FILE || path.join('logs', 'orders.log');
fs.mkdirSync(path.dirname(ORDER_LOG_FILE), { recursive: true });
const stream = fs.createWriteStream(ORDER_LOG_FILE, { flags: 'a' });
let orderSequence = 0;
let maxExternalId = 0;
export function logOrder(order) {
orderSequence += 1;
maxExternalId += 1;
const line = `${new Date().toISOString()} #${orderSequence} externalId=${maxExternalId} ${JSON.stringify(order)}\n`;
stream.write(line);
return maxExternalId;
}
export function getMaxExternalId() {
return String(maxExternalId);
}
export function closeOrderLog() {
stream.end();
}

24
src/pairing.js Normal file
View File

@@ -0,0 +1,24 @@
export function createPairingStore() {
/** @type {Record<string, { code: string; name: string; createdAt: number }>} */
const authCodes = {};
/** @type {Record<string, { name: string; token: string; createdAt: number }>} */
const pairedDevices = {};
return {
setPairingCode(code, name = 'JTL-POS') {
authCodes[code] = { code, name, createdAt: Date.now() };
},
revokePairingCode(code) {
delete authCodes[code];
},
hasPairingCode(code) {
return Boolean(authCodes[code]);
},
registerDevice(token, name = 'JTL-POS') {
pairedDevices[token] = { name, token, createdAt: Date.now() };
},
getPairedDevices() {
return { ...pairedDevices };
},
};
}

View File

@@ -0,0 +1,18 @@
import sql from 'mssql';
import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
const CATEGORY_COUNT_SQL = `
${CATEGORY_TREE_CTE}
SELECT COUNT(*) AS CategoryCount
FROM dbo.tKategorie k
WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree)
AND CONVERT(BIGINT, k.bRowversion) > @cursor;
`;
export async function getCategoryCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor)
.query(CATEGORY_COUNT_SQL);
return result.recordset[0]?.CategoryCount ?? 0;
}

View File

@@ -0,0 +1,48 @@
import sql from 'mssql';
import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1;
const IMAGE_PLATFORM_ID = Number(process.env.IMAGE_PLATFORM_ID) || 1;
const IMAGE_SHOP_ID = Number(process.env.IMAGE_SHOP_ID) || 0;
const CATEGORY_LIST_SQL = `
${CATEGORY_TREE_CTE}
SELECT TOP (@limit)
k.kKategorie AS id,
k.kOberKategorie AS pid,
k.nSort AS sort,
ks.cName AS name,
b.cHash AS imgHash,
CONVERT(BIGINT, k.bRowversion) AS lastChanged
FROM dbo.tKategorie k
INNER JOIN dbo.tKategorieSprache ks ON ks.kKategorie = k.kKategorie AND ks.kSprache = @languageId
LEFT JOIN dbo.tKategoriebildPlattform kbp
ON kbp.kKategorie = k.kKategorie AND kbp.kPlattform = @imagePlatformId AND kbp.kShop = @imageShopId
LEFT JOIN dbo.tBild b ON b.kBild = kbp.kBild
WHERE k.kKategorie IN (SELECT kKategorie FROM CategoryTree WHERE kKategorie <> @rootCategoryId)
AND k.cAktiv = 'Y'
AND CONVERT(BIGINT, k.bRowversion) > @cursor
ORDER BY lastChanged ASC;
`;
export async function getCategoryList({ cursor = 0, limit = 20, rootCategoryId = getRootCategoryId() } = {}) {
const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor)
.input('limit', sql.Int, limit)
.input('languageId', sql.Int, LANGUAGE_ID)
.input('imagePlatformId', sql.Int, IMAGE_PLATFORM_ID)
.input('imageShopId', sql.Int, IMAGE_SHOP_ID)
.query(CATEGORY_LIST_SQL);
return result.recordset.map((row) => ({
_id: String(row.id),
imghash: row.imgHash ?? null,
imgsrc: row.imgHash ?? null,
name: row.name,
pid: String(row.pid),
discounts: [],
sort: String(row.sort),
lastChanged: String(row.lastChanged),
}));
}

View File

@@ -0,0 +1,18 @@
import sql from 'mssql';
export const CATEGORY_TREE_CTE = `
WITH CategoryTree AS (
SELECT kKategorie FROM dbo.tKategorie WHERE kKategorie = @rootCategoryId
UNION ALL
SELECT t.kKategorie
FROM dbo.tKategorie t
INNER JOIN CategoryTree ct ON t.kOberKategorie = ct.kKategorie
)`;
export function getRootCategoryId() {
return Number(process.env.ROOT_CATEGORY_ID) || 1;
}
export function categoryTreeRequest(pool, rootCategoryId = getRootCategoryId()) {
return pool.request().input('rootCategoryId', sql.Int, rootCategoryId);
}

View File

@@ -0,0 +1,48 @@
import sql from 'mssql';
import { getPool } from '../db.js';
const CUSTOMER_GROUP_IDS_SQL = `
SELECT kKundenGruppe
FROM dbo.tKundenGruppe
ORDER BY kKundenGruppe;
`;
const CUSTOMER_GROUP_LIST_SQL = `
SELECT
kKundenGruppe AS id,
cName AS name,
nStandard AS standard,
fRabatt AS discountPercent,
CONVERT(BIGINT, bRowversion) AS lastChanged
FROM dbo.tKundenGruppe
WHERE CONVERT(BIGINT, bRowversion) > @cursor
ORDER BY lastChanged ASC;
`;
const CUSTOMER_GROUP_COUNT_SQL = `
SELECT COUNT(*) AS CustomerGroupCount
FROM dbo.tKundenGruppe
WHERE CONVERT(BIGINT, bRowversion) > @cursor;
`;
export async function getCustomerGroupIds() {
const result = await getPool().request().query(CUSTOMER_GROUP_IDS_SQL);
return result.recordset.map((row) => row.kKundenGruppe);
}
export async function getCustomerGroupList({ cursor = 0 } = {}) {
const result = await getPool().request().input('cursor', sql.BigInt, cursor).query(CUSTOMER_GROUP_LIST_SQL);
return result.recordset.map((row) => ({
customerGroupId: String(row.id),
name: row.name,
standard: String(row.standard),
discountPercent: Number(row.discountPercent).toFixed(2),
lastChanged: String(row.lastChanged),
}));
}
export async function getCustomerGroupCount({ cursor = 0 } = {}) {
const result = await getPool().request().input('cursor', sql.BigInt, cursor).query(CUSTOMER_GROUP_COUNT_SQL);
return result.recordset[0]?.CustomerGroupCount ?? 0;
}

43
src/queries/image.js Normal file
View File

@@ -0,0 +1,43 @@
import sql from 'mssql';
import { getPool } from '../db.js';
const IMAGE_BY_HASH_SQL = `
SELECT bBild, bVorschauBild, nBreite, nHoehe, nVorschauBreite, nVorschauHoehe, cQuelle
FROM dbo.tBild
WHERE cHash = @hash;
`;
function contentTypeFor(cQuelle) {
const extension = (cQuelle || '').split('.').pop()?.toLowerCase();
switch (extension) {
case 'png':
return 'image/png';
case 'gif':
return 'image/gif';
case 'webp':
return 'image/webp';
case 'jpg':
case 'jpeg':
default:
return 'image/jpeg';
}
}
export async function getImageByHash(hash, size) {
const result = await getPool().request().input('hash', sql.NVarChar, hash).query(IMAGE_BY_HASH_SQL);
const row = result.recordset[0];
if (!row) {
return null;
}
const previewMaxDimension = Math.max(row.nVorschauBreite || 0, row.nVorschauHoehe || 0);
const useFull = !previewMaxDimension || Number(size) > previewMaxDimension;
const buffer = useFull ? row.bBild : row.bVorschauBild;
if (!buffer) {
return null;
}
return { buffer, contentType: contentTypeFor(row.cQuelle) };
}

View File

@@ -0,0 +1,149 @@
const PFAND_ATTR_IDS = [
'Pfandartikel',
'Pfandart (Bezeichnung auf Ausdruck)',
'Pfandbetrag',
];
let pfandMetaCache = null;
let activeShopsCache = null;
async function getPfandMetadata(pool) {
if (!pfandMetaCache) {
const result = await pool.request().query(`
SELECT
a.kAttribut,
a.cAttributId,
a.nSortierung,
a.kFeldTyp,
a.cGruppeName,
s.cName
FROM dbo.tAttribut a
INNER JOIN dbo.tAttributSprache s ON s.kAttribut = a.kAttribut AND s.kSprache = 0
WHERE a.cGruppeName = 'JTL-POS'
AND a.cAttributId IN ('Pfandartikel', 'Pfandart (Bezeichnung auf Ausdruck)', 'Pfandbetrag')
`);
pfandMetaCache = result.recordset;
}
return pfandMetaCache;
}
async function getActiveShops(pool) {
if (!activeShopsCache) {
const result = await pool.request().query(`
WITH ActiveShops AS (
SELECT ss.kShop, s.kKategorie AS rootKategorie
FROM dbo.tShopSubshop ss
INNER JOIN dbo.tShop s ON s.kShop = ss.kShop
WHERE ss.nGesperrt = 0
)
SELECT DISTINCT ash.kShop FROM ActiveShops ash
`);
activeShopsCache = result.recordset.map((row) => row.kShop);
}
return activeShopsCache;
}
function attributesSql(articleIds, activeShops, pfandKAttribute) {
const shopList = activeShops.length ? activeShops.join(',') : 'NULL';
const pfandList = pfandKAttribute.length ? pfandKAttribute.join(',') : 'NULL';
return `
SELECT
aa.kArtikel AS articleId,
aa.kAttribut,
aa.kShop,
at.nSortierung,
at.kFeldTyp,
at.cGruppeName,
at.cAttributId,
ats.cName,
aas.cWertVarchar,
aas.nWertInt,
aas.fWertDecimal
FROM dbo.tArtikelAttribut aa
INNER JOIN dbo.tArtikelAttributSprache aas
ON aas.kArtikelAttribut = aa.kArtikelAttribut AND aas.kSprache = 0
INNER JOIN dbo.tAttribut at ON at.kAttribut = aa.kAttribut
INNER JOIN dbo.tAttributSprache ats ON ats.kAttribut = at.kAttribut AND ats.kSprache = 0
WHERE aa.kArtikel IN (${articleIds.join(',')})
AND (
aa.kShop = 0
OR (
aa.kShop IN (${shopList})
AND aa.kAttribut IN (${pfandList})
)
)
ORDER BY aa.kArtikel, at.nSortierung, aa.kShop;
`;
}
function mergeArticleAttributes(rows) {
const byAttribute = new Map();
for (const row of rows) {
byAttribute.set(row.kAttribut, row);
}
return [...byAttribute.values()].sort((a, b) => a.nSortierung - b.nSortierung);
}
function toPosAttribute(row) {
return {
aname: row.cName,
aprice: '0.0',
asort: String(row.nSortierung),
atype: String(row.kFeldTyp),
agroup: row.cGruppeName ?? '',
};
}
function deriveDepositFields(rows) {
const byId = new Map(rows.map((row) => [row.cAttributId, row]));
const pfandArtikel = byId.get('Pfandartikel');
const pfandArt = byId.get('Pfandart (Bezeichnung auf Ausdruck)');
const pfandBetrag = byId.get('Pfandbetrag');
if (!pfandArtikel && !pfandArt && !pfandBetrag) {
return null;
}
return {
deposit: pfandArtikel?.nWertInt === 1 ? '1' : '0',
deposit_name: pfandArt?.cWertVarchar ?? '',
d_price:
pfandBetrag?.fWertDecimal != null
? Number(pfandBetrag.fWertDecimal).toFixed(2)
: '0.0',
};
}
export async function getProductAttributes(pool, articleIds) {
if (!articleIds.length) {
return new Map();
}
const [pfandMeta, activeShops] = await Promise.all([
getPfandMetadata(pool),
getActiveShops(pool),
]);
const pfandKAttribute = pfandMeta.map((row) => row.kAttribut);
const result = await pool.request().query(attributesSql(articleIds, activeShops, pfandKAttribute));
const rowsByArticle = new Map();
for (const row of result.recordset) {
if (!rowsByArticle.has(row.articleId)) {
rowsByArticle.set(row.articleId, []);
}
rowsByArticle.get(row.articleId).push(row);
}
const attributesByArticle = new Map();
for (const [articleId, rows] of rowsByArticle) {
const merged = mergeArticleAttributes(rows);
attributesByArticle.set(articleId, {
attributes: merged.map(toPosAttribute),
deposit: deriveDepositFields(merged),
});
}
return attributesByArticle;
}

View File

@@ -0,0 +1,20 @@
import sql from 'mssql';
import { getPool } from '../db.js';
import { CATEGORY_TREE_CTE, categoryTreeRequest, getRootCategoryId } from './category-tree.js';
const PRODUCT_COUNT_SQL = `
${CATEGORY_TREE_CTE}
SELECT COUNT(DISTINCT a.kArtikel) AS ProductCount
FROM dbo.tArtikel a
INNER JOIN dbo.tKategorieArtikel ka ON ka.kArtikel = a.kArtikel
WHERE a.cAktiv = 'Y'
AND ka.kKategorie IN (SELECT kKategorie FROM CategoryTree)
AND CONVERT(BIGINT, a.bRowversion) > @cursor;
`;
export async function getProductCount({ cursor = 0, rootCategoryId = getRootCategoryId() } = {}) {
const result = await categoryTreeRequest(getPool(), rootCategoryId)
.input('cursor', sql.BigInt, cursor)
.query(PRODUCT_COUNT_SQL);
return result.recordset[0]?.ProductCount ?? 0;
}

151
src/queries/product-list.js Normal file
View File

@@ -0,0 +1,151 @@
import sql from 'mssql';
import { getPool } from '../db.js';
import { getCustomerGroupIds } from './customer-groups.js';
import { getProductAttributes } from './product-attributes.js';
const LANGUAGE_ID = Number(process.env.LANGUAGE_ID) || 1;
const TAX_ZONE_NAME = process.env.TAX_ZONE_NAME || 'Zone-EU';
const IMAGE_PLATFORM_ID = Number(process.env.IMAGE_PLATFORM_ID) || 1;
const IMAGE_SHOP_ID = Number(process.env.IMAGE_SHOP_ID) || 0;
const PRODUCT_LIST_SQL = `
WITH TaxRates AS (
SELECT kSteuerklasse, fSteuersatz
FROM dbo.tSteuersatz
WHERE kSteuerzone IN (SELECT kSteuerzone FROM dbo.tSteuerzone WHERE cName = @taxZoneName)
)
SELECT TOP (@limit)
a.kArtikel AS id,
a.cArtNr AS sku,
ab.cName AS name,
a.fVKNetto AS netPrice,
tr.fSteuersatz AS taxRate,
a.dErstelldatum AS createdAt,
CONVERT(BIGINT, a.bRowversion) AS lastChanged,
(
SELECT TOP 1 img.cHash
FROM dbo.tArtikelbildPlattform abp
INNER JOIN dbo.tBild img ON img.kBild = abp.kBild
WHERE abp.kArtikel = a.kArtikel
AND abp.kPlattform = @imagePlatformId
AND abp.kShop = @imageShopId
ORDER BY abp.nNr
) AS imgHash,
(
SELECT STRING_AGG(CAST(ka.kKategorie AS varchar(20)), ',')
FROM dbo.tkategorieartikel ka
WHERE ka.kArtikel = a.kArtikel
) AS categoryIds,
a.nIstVater AS isParent,
a.kVaterArtikel AS parentArticleId,
(
SELECT TOP 1 pv.cVariantName
FROM Pos.vProductVariant pv
WHERE pv.kProduct = a.kArtikel
) AS variantName
FROM dbo.tArtikel a
INNER JOIN dbo.tArtikelBeschreibung ab ON ab.kArtikel = a.kArtikel AND ab.kSprache = @languageId
LEFT JOIN TaxRates tr ON tr.kSteuerklasse = a.kSteuerklasse
WHERE a.cAktiv = 'Y'
AND CONVERT(BIGINT, a.bRowversion) > @cursor
ORDER BY lastChanged ASC;
`;
function priceOverridesSql(articleIds) {
const idList = articleIds.join(',');
return `
SELECT p.kArtikel AS articleId, p.kKundenGruppe AS customerGroupId, MIN(pd.fNettoPreis) AS netPrice
FROM dbo.tPreis p
INNER JOIN dbo.tPreisDetail pd ON pd.kPreis = p.kPreis
WHERE p.kArtikel IN (${idList}) AND p.kShop = 0 AND pd.nAnzahlAb = 0
GROUP BY p.kArtikel, p.kKundenGruppe;
`;
}
function formatDateTime(date) {
if (!date) {
return '0001-01-01 00:00:00';
}
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function grossPrice(netPrice, taxRate) {
return (Number(netPrice) * (1 + Number(taxRate || 0) / 100)).toFixed(2);
}
export async function getProductList({ cursor = 0, limit = 20 } = {}) {
const pool = getPool();
const [productResult, customerGroupIds] = await Promise.all([
pool
.request()
.input('cursor', sql.BigInt, cursor)
.input('limit', sql.Int, limit)
.input('languageId', sql.Int, LANGUAGE_ID)
.input('taxZoneName', sql.NVarChar, TAX_ZONE_NAME)
.input('imagePlatformId', sql.Int, IMAGE_PLATFORM_ID)
.input('imageShopId', sql.Int, IMAGE_SHOP_ID)
.query(PRODUCT_LIST_SQL),
getCustomerGroupIds(),
]);
const products = productResult.recordset;
const articleIds = products.map((p) => p.id);
const overridesByArticle = new Map();
let attributesByArticle = new Map();
if (articleIds.length > 0) {
const [overrideResult, attributeMap] = await Promise.all([
pool.request().query(priceOverridesSql(articleIds)),
getProductAttributes(pool, articleIds),
]);
attributesByArticle = attributeMap;
for (const row of overrideResult.recordset) {
if (!overridesByArticle.has(row.articleId)) {
overridesByArticle.set(row.articleId, new Map());
}
overridesByArticle.get(row.articleId).set(row.customerGroupId, row.netPrice);
}
}
return products.map((product) => {
const overrides = overridesByArticle.get(product.id);
const basePrice = grossPrice(product.netPrice, product.taxRate);
const prices = customerGroupIds.map((customerGroupId) => {
const overrideNetPrice = overrides?.get(customerGroupId);
const price =
overrideNetPrice !== undefined ? grossPrice(overrideNetPrice, product.taxRate) : basePrice;
return {
customerGroupId: String(customerGroupId),
customerId: '0',
price,
quantity: '0',
};
});
const categoryIds = product.categoryIds ? product.categoryIds.split(',') : [];
const articleAttributes = attributesByArticle.get(product.id);
return {
_id: String(product.id),
imghash: product.imgHash ?? null,
imgsrc: product.imgHash ?? null,
sku: product.sku,
name: product.name,
tax_rate: String(Math.round(Number(product.taxRate || 0))),
price: basePrice,
created_at: formatDateTime(product.createdAt),
lastChanged: String(product.lastChanged),
categories_id: categoryIds[0] ?? '0',
categories: categoryIds.map((categoryId) => ({ categoryId })),
prices,
is_parent: product.isParent ? '1' : '0',
parent: product.parentArticleId > 0 ? String(product.parentArticleId) : '0',
variants: product.variantName ?? '',
attributes: articleAttributes?.attributes ?? [],
...(articleAttributes?.deposit ?? {}),
};
});
}

20
src/request-log.js Normal file
View File

@@ -0,0 +1,20 @@
import fs from 'node:fs';
import path from 'node:path';
const LOG_FILE = process.env.LOG_FILE || path.join('logs', 'requests.log');
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
const stream = fs.createWriteStream(LOG_FILE, { flags: 'a' });
function isoTimestamp() {
return new Date().toISOString();
}
export function logRequest({ remoteAddress, method, url, statusCode, durationMs, response }) {
const line = `${isoTimestamp()} ${remoteAddress} ${method} ${url} ${statusCode} ${durationMs}ms ${response}\n`;
stream.write(line);
}
export function closeRequestLog() {
stream.end();
}

130
test-client.js Normal file
View File

@@ -0,0 +1,130 @@
import https from 'node:https';
import { gunzipSync } from 'node:zlib';
const HOST = process.env.JTL_HOST || '127.0.0.1';
const PORT = Number(process.env.JTL_PORT) || Number(process.env.PORT) || 4443;
const INIT_QUERY =
'mandantId=1&lastChangedCategory=0&lastChangedCustomer=0&lastChangedCustomerGroup=0&lastChangedProduct=0&lastChangedConfigurationGroup=0&lastChangedConfigurationItem=0&lastChangedCompositeProduct=0&lastChangedDeletedEntity=0';
function usage() {
console.error('Usage:');
console.error(' node test-client.js <authCode> [name] # 6-digit: step1+2, 4-digit: step1 only');
console.error(' node test-client.js init <authToken>');
process.exit(1);
}
function posHeaders(authToken) {
const headers = {
accept: 'application/json',
'content-type': 'application/json',
'cache-control': 'no-cache',
version: '1.0.11.14',
system: 'JTL-POS',
charset: 'utf-8',
connection: 'Keep-Alive',
'user-agent': 'Dalvik/2.1.0 (Linux; U; Android 13; SM-T970 Build/TP1A.220624.014)',
'accept-encoding': 'gzip',
};
if (authToken) {
headers.authorization = `Bearer ${authToken}`;
}
return headers;
}
function decodeBody(buffer, headers) {
if (headers['content-encoding'] === 'gzip') {
return gunzipSync(buffer);
}
return buffer;
}
function request(path, authToken) {
return new Promise((resolve, reject) => {
const req = https.request(
{
hostname: HOST,
port: PORT,
path,
method: 'GET',
headers: posHeaders(authToken),
rejectUnauthorized: false,
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks);
resolve({
statusCode: res.statusCode,
headers: res.headers,
body: decodeBody(raw, res.headers),
});
});
}
);
req.on('error', reject);
req.end();
});
}
function printResponse(res) {
console.log(`status: ${res.statusCode}`);
console.log('headers:', JSON.stringify(res.headers, null, 2));
console.log('body:');
try {
console.log(JSON.stringify(JSON.parse(res.body.toString('utf8')), null, 2));
} catch {
console.log(res.body.toString('utf8'));
}
}
async function clientRequest(authCode, name, label) {
const path = `/api/v1/client?authCode=${encodeURIComponent(authCode)}&name=${encodeURIComponent(name)}`;
console.log(`${label} GET https://${HOST}:${PORT}${path}\n`);
const res = await request(path);
printResponse(res);
console.log();
return res;
}
async function main() {
const mode = process.argv[2];
if (!mode) {
usage();
}
if (mode === 'init') {
const authToken = process.argv[3] || process.env.AUTH_TOKEN;
if (!authToken) {
usage();
}
const path = `/v1/init?${INIT_QUERY}`;
console.log(`GET https://${HOST}:${PORT}${path}\n`);
const res = await request(path, authToken);
printResponse(res);
return;
}
const name = process.argv[3] || process.env.CLIENT_NAME || '001';
const fullCode = mode;
if (fullCode.length === 6) {
await clientRequest(fullCode.slice(0, 4), name, '=== step 1 (4 digits) ===');
await clientRequest(fullCode, name, '=== step 2 (6 digits) ===');
return;
}
await clientRequest(fullCode, name, '=== step 1 (4 digits) ===');
}
main().catch((err) => {
console.error('request failed:', err.message);
process.exit(1);
});