Genesis
This commit is contained in:
34
.env.example
Normal file
34
.env.example
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
certs/
|
||||
logs/
|
||||
24
generate-cert.js
Normal file
24
generate-cert.js
Normal 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
1542
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
124
server.js
Normal 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
49
src/db.js
Normal 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
23
src/endpoints/category.js
Normal 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
6
src/endpoints/cimage.js
Normal 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
71
src/endpoints/client.js
Normal 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.' });
|
||||
}
|
||||
13
src/endpoints/customergroup.js
Normal file
13
src/endpoints/customergroup.js
Normal 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);
|
||||
}
|
||||
21
src/endpoints/image-handler.js
Normal file
21
src/endpoints/image-handler.js
Normal 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
10
src/endpoints/index.js
Normal 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
33
src/endpoints/init.js
Normal 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
48
src/endpoints/order.js
Normal 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
6
src/endpoints/pimage.js
Normal 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
67
src/endpoints/product.js
Normal 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
35
src/http.js
Normal 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
9
src/image-resize.js
Normal 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
52
src/jtl-server.js
Normal 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
20
src/logger.js
Normal 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
28
src/order-log.js
Normal 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
24
src/pairing.js
Normal 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
18
src/queries/category-count.js
Normal file
18
src/queries/category-count.js
Normal 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;
|
||||
}
|
||||
48
src/queries/category-list.js
Normal file
48
src/queries/category-list.js
Normal 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),
|
||||
}));
|
||||
}
|
||||
18
src/queries/category-tree.js
Normal file
18
src/queries/category-tree.js
Normal 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);
|
||||
}
|
||||
48
src/queries/customer-groups.js
Normal file
48
src/queries/customer-groups.js
Normal 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
43
src/queries/image.js
Normal 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) };
|
||||
}
|
||||
149
src/queries/product-attributes.js
Normal file
149
src/queries/product-attributes.js
Normal 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;
|
||||
}
|
||||
20
src/queries/product-count.js
Normal file
20
src/queries/product-count.js
Normal 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
151
src/queries/product-list.js
Normal 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
20
src/request-log.js
Normal 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
130
test-client.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user