Files
getrankekarte/posClient.js
DanielS 1b0726deef
All checks were successful
Build and Deploy / build (push) Successful in 54s
feat: Text 2 als feste Inhaltsangabe und Langtext 1-4 als aufklappbare Details (Hover/Touch)
2026-09-22 23:36:12 +02:00

392 lines
14 KiB
JavaScript

const crypto = require('crypto');
const AdmZip = require('adm-zip');
const xml2js = require('xml2js');
const db = require('./db');
function getConfig() {
const rows = db.prepare('SELECT key, value FROM config').all();
const config = {};
for (const r of rows) {
config[r.key] = r.value;
}
return config;
}
// 1. Authentifizierung & WWSVC-Headers nach Spezifikation
function generateHeaders(appId, passId, algo = 'md5') {
const ts = new Date().toISOString();
const reqId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2);
// WWSVC Hash: MD5 über appId + ts
const rawString = `${appId || ''}${ts}`;
const hash = crypto.createHash(algo === 'sha256' ? 'sha256' : 'md5').update(rawString).digest('hex');
return {
'WWSVC-HASH': hash,
'WWSVC-TS': ts,
'WWSVC-REQID': reqId,
'User-Agent': 'Getraenkekarte-POS-Client/1.0'
};
}
function formatDate(d) {
const pad = n => String(n).padStart(2, '0');
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()}`;
}
function formatTime(d) {
const pad = n => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
// QR-Code Setup Handshake
async function processQrSetup(qrPayload) {
let data = qrPayload;
if (typeof qrPayload === 'string') {
try {
data = JSON.parse(qrPayload.trim());
} catch (e) {
throw new Error('Ungültiges QR-Code JSON-Format');
}
}
if (data.$type !== 'ww-setup') {
throw new Error('Ungültiger QR-Code-Typ: $type muss "ww-setup" sein');
}
const { url, servicepasslink } = data;
const mdcDeviceId = data.mdcDeviceId || data.cr || data.cashRegisterNumber || '1';
if (!url || !servicepasslink) {
throw new Error('Fehlende Pflichtfelder (url oder servicepasslink) im QR-Code');
}
const cleanUrl = url.replace(/\/$/, '');
// 1. GET ServicePass: GET /@LNK{servicepasslink}
const linkUrl = `${cleanUrl}/@LNK${servicepasslink}`;
const res = await fetch(linkUrl, {
method: 'GET',
headers: { 'User-Agent': 'Getraenkekarte-POS-Client/1.0' }
});
if (!res.ok) {
throw new Error(`Fehler beim Abruf von ServicePass (@LNK): ${res.status} ${res.statusText}`);
}
const resJson = await res.json();
const servicePass = resJson.SERVICEPASS || resJson.servicepass || resJson;
const passId = servicePass.PASSID || servicePass.passId || servicePass.id;
const appId = servicePass.APPID || servicePass.appId || servicePass.key;
if (!passId || !appId) {
throw new Error(`Unerwartete ServicePass-Antwort: ${JSON.stringify(resJson)}`);
}
// 2. In lokaler SQLite Datenbank speichern
const upsert = db.prepare('INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
upsert.run('pos_url', cleanUrl);
upsert.run('pos_pass_id', passId);
upsert.run('pos_app_id', appId);
upsert.run('pos_cash_register', String(mdcDeviceId));
db.prepare('INSERT INTO sync_logs (status, message) VALUES (?, ?)')
.run('SUCCESS', `QR-Code Handshake erfolgreich. Pass-ID: ${passId}, Kasse: ${mdcDeviceId}`);
// 3. Direkt automatischen Initial-Sync ausführen
let syncResult = null;
try {
syncResult = await performSync('ERSTEINRICHTUNG');
} catch(syncErr) {
console.warn('Initialer Sync nach Setup Fehler:', syncErr.message);
}
return {
success: true,
url: cleanUrl,
passId,
appId,
mdcDeviceId: String(mdcDeviceId),
syncResult
};
}
// Datenabruf-Ablauf: CASPOS.EXEC (Vorbereitung) & CASPOS.GET (Download) via WEBWARE Slash-Format
async function performSync(syncType = 'ERSTEINRICHTUNG') {
const config = getConfig();
const { pos_url, pos_pass_id, pos_app_id, pos_cash_register, last_sync_time } = config;
if (!pos_url || !pos_pass_id || !pos_app_id) {
throw new Error('POS Cloud Zugangsdaten unvollständig (URL, PassId oder AppId fehlt)');
}
const cleanUrl = pos_url.trim().replace(/\/$/, '');
const devId = pos_cash_register || '1';
// A) Differenzabgleich Vorbereitung wenn ART=AENDERUNGEN
if (syncType === 'AENDERUNGEN') {
let fromDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
if (last_sync_time) {
const parsed = new Date(last_sync_time);
if (!isNaN(parsed.getTime())) fromDate = parsed;
}
const execPath = `/WWSVC/EXECURL/${pos_pass_id}/CASPOS.EXEC/ART=AENDERUNGEN/AENDERUNGEN_ABDATUM=${formatDate(fromDate)}/AENDERUNGEN_ABUHRZEIT=${formatTime(fromDate)}/MDCDEVICEID=${devId}`;
const execHeaders = generateHeaders(pos_app_id, pos_pass_id);
try {
await fetch(`${cleanUrl}${execPath}`, { method: 'GET', headers: execHeaders });
} catch(e) {
console.warn('CASPOS.EXEC Hinweis:', e.message);
}
}
// B) Download mit CASPOS.GET im Webware-Format (/WWSVC/EXECURL/{passId}/CASPOS.GET/ART=.../MDCDEVICEID=...)
const getPath = `/WWSVC/EXECURL/${pos_pass_id}/CASPOS.GET/ART=${syncType}/MDCDEVICEID=${devId}/LAGER=1/LAGERORTE=1/VK1=1/VK2=1/VK3=1/VK4=1/VK5=1/VK6=1`;
const getUrl = `${cleanUrl}${getPath}`;
const headers = generateHeaders(pos_app_id, pos_pass_id);
const response = await fetch(getUrl, {
method: 'GET',
headers: headers
});
if (!response.ok) {
throw new Error(`POS Cloud HTTP Error: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const result = await parseAndStoreZip(buffer);
db.prepare('INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.run('last_sync_time', new Date().toISOString());
return result;
}
// Inhalt ZIP & Dateitypen (JSON & XML Parsing für SoftEngine CASPOS Cloud)
async function parseAndStoreZip(buffer) {
const zip = new AdmZip(buffer);
const zipEntries = zip.getEntries();
let productsCount = 0;
let groupsCount = 0;
let deletionsCount = 0;
const insertGroup = db.prepare(`
INSERT INTO product_groups (id, name, sort_order)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name
`);
const insertProduct = db.prepare(`
INSERT INTO products (id, text1, text2, langtext1, langtext2, langtext3, langtext4, group_id, preis1, unit, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(id) DO UPDATE SET
text1=excluded.text1,
text2=excluded.text2,
langtext1=excluded.langtext1,
langtext2=excluded.langtext2,
langtext3=excluded.langtext3,
langtext4=excluded.langtext4,
group_id=excluded.group_id,
preis1=excluded.preis1,
unit=excluded.unit,
active=1,
updated_at=CURRENT_TIMESTAMP
`);
const deactivateProduct = db.prepare(`
UPDATE products SET active = 0 WHERE id = ?
`);
const parser = new xml2js.Parser({ explicitArray: false, ignoreAttrs: false });
for (const entry of zipEntries) {
const fileName = entry.entryName.toLowerCase();
const rawContent = entry.getData().toString('utf8');
// JSON Format von SoftEngine CASPOS (z.B. Casposdata_Products.json)
if (fileName.endsWith('.json')) {
let json = {};
try { json = JSON.parse(rawContent); } catch(e) {}
// 1. Warengruppen
const groupsData = json.productGroups?.data || (Array.isArray(json.productGroups) ? json.productGroups : []);
for (const g of groupsData) {
const id = String(g.id || g.groupId || '');
const name = g.name || g.text || g.text1 || ('Kategorie ' + id);
if (id) {
insertGroup.run(id, name, 0);
groupsCount++;
}
}
// 2. Artikel
const productsData = json.products?.data || (Array.isArray(json.products) ? json.products : (Array.isArray(json) ? json : []));
for (const item of productsData) {
const id = String(item.id || item.articleNo || '');
const text1 = item.text1 || item.name || item.title || 'Unbekannt';
const text2 = item.text2 || item.description || item.additionalText || '';
const groupId = String(item.groupId || item.warengruppe || '');
// Langtexte aus Attributen / Descriptions / Feldern extrahieren
let langtext1 = item.langtext1 || item.Langtext1 || item.longText1 || item.LongText1 || '';
let langtext2 = item.langtext2 || item.Langtext2 || item.longText2 || item.LongText2 || '';
let langtext3 = item.langtext3 || item.Langtext3 || item.longText3 || item.LongText3 || '';
let langtext4 = item.langtext4 || item.Langtext4 || item.longText4 || item.LongText4 || '';
// Falls in descriptions Array oder attributes Array abgelegt
if (Array.isArray(item.descriptions)) {
if (item.descriptions[0]?.text) langtext1 = item.descriptions[0].text;
if (item.descriptions[1]?.text) langtext2 = item.descriptions[1].text;
if (item.descriptions[2]?.text) langtext3 = item.descriptions[2].text;
if (item.descriptions[3]?.text) langtext4 = item.descriptions[3].text;
}
if (Array.isArray(item.attributes)) {
for (const attr of item.attributes) {
const attrName = (attr.name || attr.title || '').toLowerCase();
const attrVal = attr.value || attr.text || '';
if (attrName.includes('langtext 1') || attrName.includes('langtext1')) langtext1 = attrVal;
if (attrName.includes('langtext 2') || attrName.includes('langtext2')) langtext2 = attrVal;
if (attrName.includes('langtext 3') || attrName.includes('langtext3')) langtext3 = attrVal;
if (attrName.includes('langtext 4') || attrName.includes('langtext4')) langtext4 = attrVal;
}
}
// Preis 1 ermitteln (VK1)
let preis1 = 0;
if (Array.isArray(item.prices) && item.prices.length > 0) {
const vk1Obj = item.prices.find(p => String(p.level) === '1') || item.prices[0];
if (vk1Obj && vk1Obj.value !== undefined) {
preis1 = parseFloat(String(vk1Obj.value).replace(',', '.'));
}
} else if (typeof item.prices === 'object' && item.prices !== null) {
preis1 = parseFloat(String(item.prices.vk1 || item.prices.price1 || 0).replace(',', '.'));
} else if (item.preis1 !== undefined) {
preis1 = parseFloat(String(item.preis1).replace(',', '.'));
} else if (item.price !== undefined) {
preis1 = parseFloat(String(item.price).replace(',', '.'));
}
const unit = item.contentUnit || item.salesUnit || item.unit || '';
if (id) {
insertProduct.run(id, text1, text2, langtext1, langtext2, langtext3, langtext4, groupId, isNaN(preis1) ? 0 : preis1, unit);
productsCount++;
}
}
// 3. Deletions
const deletionsData = json.deletions?.data || (Array.isArray(json.deletions) ? json.deletions : []);
for (const del of deletionsData) {
const id = String(del.id || del.articleNo || del);
if (id) {
deactivateProduct.run(id);
deletionsCount++;
}
}
}
// XML Format Fallback
if (fileName.endsWith('.xml')) {
let parsed = {};
try { parsed = await parser.parseStringPromise(rawContent); } catch(e) {}
const root = parsed.root || parsed;
let groups = root.ProductGroups?.ProductGroup || root.productGroups?.productGroup || [];
if (!Array.isArray(groups)) groups = [groups];
for (const g of groups) {
const id = String(g.id || g.groupId || '');
const name = g.name || g.text || ('Kategorie ' + id);
if (id) { insertGroup.run(id, name, 0); groupsCount++; }
}
let items = root.Products?.Product || root.products?.product || [];
if (!Array.isArray(items)) items = [items];
for (const item of items) {
const id = String(item.id || item.articleNo || '');
const text1 = item.text1 || item.name || 'Unbekannt';
const text2 = item.text2 || '';
const langtext1 = item.langtext1 || item.Langtext1 || '';
const langtext2 = item.langtext2 || item.Langtext2 || '';
const langtext3 = item.langtext3 || item.Langtext3 || '';
const langtext4 = item.langtext4 || item.Langtext4 || '';
const groupId = String(item.groupId || '');
let preis1 = parseFloat(String(item.preis1 || item.price || 0).replace(',', '.'));
if (id) { insertProduct.run(id, text1, text2, langtext1, langtext2, langtext3, langtext4, groupId, isNaN(preis1) ? 0 : preis1, ''); productsCount++; }
}
}
}
const msg = `Import: ${productsCount} Artikel, ${groupsCount} Gruppen, ${deletionsCount} Deaktiviert`;
db.prepare('INSERT INTO sync_logs (status, message, items_count) VALUES (?, ?, ?)')
.run('SUCCESS', msg, productsCount);
return { productsCount, groupsCount, deletionsCount, message: msg };
}
function importJsonData(data) {
const insertProduct = db.prepare(`
INSERT INTO products (id, text1, text2, langtext1, langtext2, langtext3, langtext4, group_id, preis1, unit, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(id) DO UPDATE SET
text1=excluded.text1,
text2=excluded.text2,
langtext1=excluded.langtext1,
langtext2=excluded.langtext2,
langtext3=excluded.langtext3,
langtext4=excluded.langtext4,
group_id=excluded.group_id,
preis1=excluded.preis1,
unit=excluded.unit,
active=1,
updated_at=CURRENT_TIMESTAMP
`);
const insertGroup = db.prepare(`
INSERT INTO product_groups (id, name, sort_order)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name
`);
let count = 0;
const tx = db.transaction(() => {
if (data.groups && Array.isArray(data.groups)) {
for (const g of data.groups) {
insertGroup.run(String(g.id), g.name, g.sortOrder || 0);
}
}
if (data.products && Array.isArray(data.products)) {
for (const p of data.products) {
insertProduct.run(
String(p.id || Math.random().toString(36).substring(2)),
p.text1 || 'Unbekannt',
p.text2 || '',
p.langtext1 || '',
p.langtext2 || '',
p.langtext3 || '',
p.langtext4 || '',
String(p.groupId || ''),
parseFloat(String(p.preis1 || p.price || 0).replace(',', '.')),
p.unit || ''
);
count++;
}
}
});
tx();
return count;
}
module.exports = {
processQrSetup,
performSync,
parseAndStoreZip,
importJsonData,
getConfig
};