From 0dfbf5c0131218bea29415904f5883cb48064b9c Mon Sep 17 00:00:00 2001 From: DanielS Date: Tue, 22 Sep 2026 22:30:44 +0200 Subject: [PATCH] =?UTF-8?q?fix(pos):=20Webware=20Slash-URL=20Endpunkte,=20?= =?UTF-8?q?JSON=20Struktur=20und=20Preis-Parsing=20(Komma-Format)=20vollst?= =?UTF-8?q?=C3=A4ndig=20integriert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- posClient.js | 282 +++++++++++++++++++-------------------------------- 1 file changed, 103 insertions(+), 179 deletions(-) diff --git a/posClient.js b/posClient.js index d3e805c..1d7adcc 100644 --- a/posClient.js +++ b/posClient.js @@ -17,7 +17,8 @@ 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}`; + // WWSVC Hash: MD5 über appId + ts + const rawString = `${appId || ''}${ts}`; const hash = crypto.createHash(algo === 'sha256' ? 'sha256' : 'md5').update(rawString).digest('hex'); return { @@ -60,108 +61,65 @@ async function processQrSetup(qrPayload) { const cleanUrl = url.replace(/\/$/, ''); - // Endpunkt-Varianten für ServicePass Link (Cloud-Instanzen unterstützen @LNK... oder @...) - const candidateUrls = [ - `${cleanUrl}/@${servicepasslink}`, - `${cleanUrl}/@LNK${servicepasslink}`, - `${cleanUrl}/LNK${servicepasslink}`, - `${cleanUrl}/@LNK/${servicepasslink}`, - `${cleanUrl}/WWSVC/@LNK${servicepasslink}` - ]; + // 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' } + }); - let resLink = null; - let responseText = ''; - let usedUrl = ''; - - for (const cUrl of candidateUrls) { - try { - const res = await fetch(cUrl, { - method: 'GET', - headers: { 'User-Agent': 'Getraenkekarte-POS-Client/1.0' } - }); - if (res.ok) { - resLink = res; - responseText = await res.text(); - usedUrl = cUrl; - break; - } - } catch (e) {} + if (!res.ok) { + throw new Error(`Fehler beim Abruf von ServicePass (@LNK): ${res.status} ${res.statusText}`); } - if (!resLink) { - throw new Error(`Fehler beim Abruf von ServicePass (@LNK): 404 Resource not found`); - } - - let passId = ''; - let appId = ''; - - try { - const passData = JSON.parse(responseText); - passId = passData.passId || passData.PassId || passData.id; - appId = passData.appId || passData.AppId || passData.key; - } catch (e) { - // Falls Text / Delimiter Format - if (responseText.includes('APPKILL')) { - throw new Error(`QR-Code Token abgelaufen oder bereits verbraucht (Server meldet: APPKILL). Bitte neuen QR-Code im Kassensystem erzeugen.`); - } - const parts = responseText.split('@)').map(s => s.trim()).filter(Boolean); - if (parts.length >= 2) { - passId = parts[0]; - appId = parts[1]; - } else { - throw new Error(`Unerwartete Antwort von ServicePass: ${responseText}`); - } - } + 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('Antwort von ServicePass enthält keine gültige passId/appId'); + throw new Error(`Unerwartete ServicePass-Antwort: ${JSON.stringify(resJson)}`); } - // 2. ServicePass registrieren (lokal in DB) + // 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); if (mdcDeviceId) upsert.run('pos_cash_register', String(mdcDeviceId)); - // 3. Pass aktivieren - 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}`); + .run('SUCCESS', `QR-Code Handshake erfolgreich. Pass-ID: ${passId}, Kasse: ${mdcDeviceId || '1'}`); + + // 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: devId + mdcDeviceId: mdcDeviceId || '1', + syncResult }; } -// Datenabruf-Ablauf: CASPOS.EXEC (Vorbereitung) & CASPOS.GET (Download) +// 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) { - throw new Error('POS Cloud URL nicht konfiguriert'); + if (!pos_url || !pos_pass_id || !pos_app_id) { + throw new Error('POS Cloud Zugangsdaten unvollständig (URL, PassId oder AppId fehlt)'); } - let baseUrl = pos_url.trim(); + const cleanUrl = pos_url.trim().replace(/\/$/, ''); + const devId = pos_cash_register || '1'; // A) Differenzabgleich Vorbereitung wenn ART=AENDERUNGEN if (syncType === 'AENDERUNGEN') { @@ -171,46 +129,22 @@ async function performSync(syncType = 'ERSTEINRICHTUNG') { 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 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(execUrl.toString(), { method: 'GET', headers: execHeaders }); + await fetch(`${cleanUrl}${execPath}`, { 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'); + // 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.toString(), { + const response = await fetch(getUrl, { method: 'GET', headers: headers }); @@ -230,7 +164,7 @@ async function performSync(syncType = 'ERSTEINRICHTUNG') { return result; } -// Inhalt ZIP & Dateitypen (JSON & XML Parsing) +// Inhalt ZIP & Dateitypen (JSON & XML Parsing für SoftEngine CASPOS Cloud) async function parseAndStoreZip(buffer) { const zip = new AdmZip(buffer); const zipEntries = zip.getEntries(); @@ -268,102 +202,92 @@ async function parseAndStoreZip(buffer) { 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) {} - } + // JSON Format von SoftEngine CASPOS (z.B. Casposdata_Products.json) + if (fileName.endsWith('.json')) { + let json = {}; + try { json = JSON.parse(rawContent); } 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); + // 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, isNaN(sortOrder) ? 0 : sortOrder); + insertGroup.run(id, name, 0); 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) {} - } + // 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 || ''); - 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 || ''); - + // Preis 1 ermitteln (VK1) 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); + 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) { + // Deutsches Zahlenformat "2,86" -> 2.86 + 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.unit || item.ContentUnit || item.Unit || ''; + const unit = item.contentUnit || item.salesUnit || 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); + // 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; + + // Warengruppen + 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++; } + } + + // Artikel + 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 groupId = String(item.groupId || ''); + let preis1 = parseFloat(String(item.preis1 || item.price || 0).replace(',', '.')); + if (id) { insertProduct.run(id, text1, text2, groupId, isNaN(preis1) ? 0 : preis1, ''); productsCount++; } + } + } } const msg = `Import: ${productsCount} Artikel, ${groupsCount} Gruppen, ${deletionsCount} Deaktiviert`; @@ -407,7 +331,7 @@ function importJsonData(data) { p.text1 || 'Unbekannt', p.text2 || '', String(p.groupId || ''), - parseFloat(p.preis1 || p.price || 0), + parseFloat(String(p.preis1 || p.price || 0).replace(',', '.')), p.unit || '' ); count++;