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 };