390 lines
13 KiB
JavaScript
390 lines
13 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);
|
|
|
|
const rawString = `${appId || ''}${passId || ''}${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, mdcDeviceId } = data;
|
|
if (!url || !servicepasslink) {
|
|
throw new Error('Fehlende Pflichtfelder (url oder servicepasslink) im QR-Code');
|
|
}
|
|
|
|
const cleanUrl = url.replace(/\/$/, '');
|
|
|
|
// 1. GET ServicePass: GET {url}/@LNK{servicepasslink}
|
|
const linkUrl = `${cleanUrl}/@LNK${servicepasslink}`;
|
|
const resLink = await fetch(linkUrl, {
|
|
method: 'GET',
|
|
headers: { 'User-Agent': 'Getraenkekarte-POS-Client/1.0' }
|
|
});
|
|
|
|
if (!resLink.ok) {
|
|
throw new Error(`Fehler beim Abruf von ServicePass (@LNK): ${resLink.status} ${resLink.statusText}`);
|
|
}
|
|
|
|
const passData = await resLink.json();
|
|
const passId = passData.passId || passData.PassId || passData.id;
|
|
const appId = passData.appId || passData.AppId || passData.key;
|
|
|
|
if (!passId || !appId) {
|
|
throw new Error('Antwort von ServicePass enthält keine gültige passId/appId');
|
|
}
|
|
|
|
// 2. ServicePass registrieren (lokal in DB)
|
|
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);
|
|
if (mdcDeviceId) upsert.run('pos_cash_register', String(mdcDeviceId));
|
|
|
|
// 3. Pass aktivieren (PUT /WWSVC/EXECURL/{passId}/CASPOS.PUT/REGISTERID={mdcDeviceId}/ENTITY=SERVICEPASS/SERVICEPASS_ID={passId})
|
|
const devId = mdcDeviceId || '1';
|
|
const activatePath = `/WWSVC/EXECURL/${passId}/CASPOS.PUT/REGISTERID=${devId}/ENTITY=SERVICEPASS/SERVICEPASS_ID=${passId}`;
|
|
const activateUrl = `${cleanUrl}${activatePath}`;
|
|
|
|
const activateHeaders = generateHeaders(appId, passId);
|
|
try {
|
|
const actRes = await fetch(activateUrl, {
|
|
method: 'PUT',
|
|
headers: activateHeaders
|
|
});
|
|
console.log(`ServicePass Activation Status: ${actRes.status}`);
|
|
} catch (err) {
|
|
console.warn('Aktivierungsaufruf Warnung:', err.message);
|
|
}
|
|
|
|
db.prepare('INSERT INTO sync_logs (status, message) VALUES (?, ?)')
|
|
.run('SUCCESS', `QR-Code Handshake erfolgreich. Pass-ID: ${passId}, Kasse: ${devId}`);
|
|
|
|
return {
|
|
success: true,
|
|
url: cleanUrl,
|
|
passId,
|
|
appId,
|
|
mdcDeviceId: devId
|
|
};
|
|
}
|
|
|
|
// Datenabruf-Ablauf: CASPOS.EXEC (Vorbereitung) & CASPOS.GET (Download)
|
|
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) {
|
|
throw new Error('POS Cloud URL nicht konfiguriert');
|
|
}
|
|
|
|
let baseUrl = pos_url.trim();
|
|
|
|
// 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 execUrl = new URL(baseUrl.replace(/CASPOS\.GET/i, 'CASPOS.EXEC'));
|
|
if (!execUrl.pathname.includes('CASPOS.EXEC') && !baseUrl.includes('CASPOS.')) {
|
|
execUrl.pathname = execUrl.pathname.replace(/\/$/, '') + '/CASPOS.EXEC';
|
|
}
|
|
|
|
execUrl.searchParams.set('ART', 'AENDERUNGEN');
|
|
execUrl.searchParams.set('AENDERUNGEN_ABDATUM', formatDate(fromDate));
|
|
execUrl.searchParams.set('AENDERUNGEN_ABUHRZEIT', formatTime(fromDate));
|
|
if (pos_cash_register) execUrl.searchParams.set('MDCDEVICEID', pos_cash_register);
|
|
|
|
const execHeaders = generateHeaders(pos_app_id, pos_pass_id);
|
|
try {
|
|
await fetch(execUrl.toString(), { method: 'GET', headers: execHeaders });
|
|
} catch(e) {
|
|
console.warn('CASPOS.EXEC Hinweis:', e.message);
|
|
}
|
|
}
|
|
|
|
// B) Download mit CASPOS.GET
|
|
const getUrl = new URL(baseUrl.replace(/CASPOS\.EXEC/i, 'CASPOS.GET'));
|
|
if (!getUrl.pathname.includes('CASPOS.GET') && !baseUrl.includes('CASPOS.')) {
|
|
getUrl.pathname = getUrl.pathname.replace(/\/$/, '') + '/CASPOS.GET';
|
|
}
|
|
|
|
getUrl.searchParams.set('ART', syncType);
|
|
if (pos_cash_register) {
|
|
getUrl.searchParams.set('MDCDEVICEID', pos_cash_register);
|
|
}
|
|
getUrl.searchParams.set('LAGER', '1');
|
|
getUrl.searchParams.set('LAGERORTE', '1');
|
|
getUrl.searchParams.set('VK1', '1');
|
|
getUrl.searchParams.set('VK2', '1');
|
|
getUrl.searchParams.set('VK3', '1');
|
|
getUrl.searchParams.set('VK4', '1');
|
|
getUrl.searchParams.set('VK5', '1');
|
|
getUrl.searchParams.set('VK6', '1');
|
|
|
|
const headers = generateHeaders(pos_app_id, pos_pass_id);
|
|
|
|
const response = await fetch(getUrl.toString(), {
|
|
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)
|
|
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, group_id, preis1, unit, active)
|
|
VALUES (?, ?, ?, ?, ?, ?, 1)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
text1=excluded.text1,
|
|
text2=excluded.text2,
|
|
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');
|
|
|
|
// A) Warengruppen (Casposdata_ProductGroups)
|
|
if (fileName.includes('productgroup') || fileName.includes('warengruppe')) {
|
|
let groups = [];
|
|
if (fileName.endsWith('.json')) {
|
|
try {
|
|
const parsed = JSON.parse(rawContent);
|
|
groups = Array.isArray(parsed) ? parsed : (parsed.groups || parsed.ProductGroups || []);
|
|
} catch(e) {}
|
|
} else if (fileName.endsWith('.xml')) {
|
|
try {
|
|
const parsed = await parser.parseStringPromise(rawContent);
|
|
const root = parsed.ProductGroups || parsed.groups || parsed.root || parsed;
|
|
groups = root.ProductGroup || root.group || [];
|
|
if (!Array.isArray(groups)) groups = [groups];
|
|
} catch(e) {}
|
|
}
|
|
|
|
for (const g of groups) {
|
|
const id = String(g.id || g.groupId || g.nr || g.Id || g.GroupId || '');
|
|
const name = g.name || g.text || g.text1 || g.description || g.Name || g.Text1 || ('Kategorie ' + id);
|
|
const sortOrder = parseInt(g.sortOrder || g.sort || g.SortOrder || 0, 10);
|
|
if (id) {
|
|
insertGroup.run(id, name, isNaN(sortOrder) ? 0 : sortOrder);
|
|
groupsCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// B) Artikelstamm & Preise (Casposdata_Products)
|
|
if (fileName.includes('product') && !fileName.includes('productgroup') && !fileName.includes('group')) {
|
|
let items = [];
|
|
if (fileName.endsWith('.json')) {
|
|
try {
|
|
const parsed = JSON.parse(rawContent);
|
|
items = Array.isArray(parsed) ? parsed : (parsed.products || parsed.Products || parsed.articles || []);
|
|
} catch(e) {}
|
|
} else if (fileName.endsWith('.xml')) {
|
|
try {
|
|
const parsed = await parser.parseStringPromise(rawContent);
|
|
const root = parsed.Products || parsed.products || parsed.root || parsed;
|
|
items = root.Product || root.product || root.article || [];
|
|
if (!Array.isArray(items)) items = [items];
|
|
} catch(e) {}
|
|
}
|
|
|
|
for (const item of items) {
|
|
const id = String(item.id || item.articleNo || item.barcode || item.Id || item.ArticleNo || '');
|
|
const text1 = item.text1 || item.name || item.title || item.Text1 || item.Name || 'Unbekannt';
|
|
const text2 = item.text2 || item.description || item.zutaten || item.Text2 || item.Description || '';
|
|
const groupId = String(item.groupId || item.warengruppe || item.GroupId || item.ProductGroupId || '');
|
|
|
|
let preis1 = 0;
|
|
if (typeof item.prices === 'object' && item.prices !== null) {
|
|
preis1 = parseFloat(item.prices.vk1 || item.prices.VK1 || item.prices.price1 || item.prices.standard || 0);
|
|
} else if (item.preis1 !== undefined || item.Preis1 !== undefined) {
|
|
preis1 = parseFloat(item.preis1 || item.Preis1);
|
|
} else if (item.price !== undefined || item.Price !== undefined) {
|
|
preis1 = parseFloat(item.price || item.Price);
|
|
} else if (item.VK1 !== undefined || item.vk1 !== undefined) {
|
|
preis1 = parseFloat(item.VK1 || item.vk1);
|
|
}
|
|
|
|
const unit = item.contentUnit || item.unit || item.ContentUnit || item.Unit || '';
|
|
|
|
if (id) {
|
|
insertProduct.run(id, text1, text2, groupId, isNaN(preis1) ? 0 : preis1, unit);
|
|
productsCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// C) Gelöschte / inaktive Artikel (Casposdata_Deletions)
|
|
if (fileName.includes('deletion') || fileName.includes('delete')) {
|
|
let deletions = [];
|
|
if (fileName.endsWith('.json')) {
|
|
try {
|
|
const parsed = JSON.parse(rawContent);
|
|
deletions = Array.isArray(parsed) ? parsed : (parsed.deletions || parsed.Deletions || []);
|
|
} catch(e) {}
|
|
} else if (fileName.endsWith('.xml')) {
|
|
try {
|
|
const parsed = await parser.parseStringPromise(rawContent);
|
|
const root = parsed.Deletions || parsed.deletions || parsed.root || parsed;
|
|
deletions = root.Deletion || root.deletion || root.id || [];
|
|
if (!Array.isArray(deletions)) deletions = [deletions];
|
|
} catch(e) {}
|
|
}
|
|
|
|
for (const del of deletions) {
|
|
const id = String(del.id || del.articleNo || del.Id || del);
|
|
if (id) {
|
|
deactivateProduct.run(id);
|
|
deletionsCount++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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, group_id, preis1, unit, active)
|
|
VALUES (?, ?, ?, ?, ?, ?, 1)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
text1=excluded.text1,
|
|
text2=excluded.text2,
|
|
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 || '',
|
|
String(p.groupId || ''),
|
|
parseFloat(p.preis1 || p.price || 0),
|
|
p.unit || ''
|
|
);
|
|
count++;
|
|
}
|
|
}
|
|
});
|
|
tx();
|
|
return count;
|
|
}
|
|
|
|
module.exports = {
|
|
processQrSetup,
|
|
performSync,
|
|
parseAndStoreZip,
|
|
importJsonData,
|
|
getConfig
|
|
};
|