feat(qr): automatischer QR-Code Handshake (@LNK & CASPOS.PUT Aktivierung)
All checks were successful
Build and Deploy / build (push) Successful in 59s

This commit is contained in:
DanielS
2026-09-22 22:08:53 +02:00
parent 728c9a1aa9
commit 21cb5c929a
3 changed files with 161 additions and 34 deletions

View File

@@ -14,11 +14,9 @@ function getConfig() {
// 1. Authentifizierung & WWSVC-Headers nach Spezifikation
function generateHeaders(appId, passId, algo = 'md5') {
// Zeitstempel Format: yyyy-MM-ddTHH:mm:ss oder epoch/ISO
const ts = new Date().toISOString();
const reqId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2);
// WWSVC Hash: MD5 oder SHA256 über appId + passId + ts
const rawString = `${appId || ''}${passId || ''}${ts}`;
const hash = crypto.createHash(algo === 'sha256' ? 'sha256' : 'md5').update(rawString).digest('hex');
@@ -40,7 +38,83 @@ function formatTime(d) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
// 2. Datenabruf-Ablauf: CASPOS.EXEC (Vorbereitung) & CASPOS.GET (Download)
// 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;
@@ -49,12 +123,11 @@ async function performSync(syncType = 'ERSTEINRICHTUNG') {
throw new Error('POS Cloud URL nicht konfiguriert');
}
// Basis-URL sauber aufbereiten
let baseUrl = pos_url.trim();
// A) Differenzabgleich Vorbereitung wenn ART=AENDERUNGEN
if (syncType === 'AENDERUNGEN') {
let fromDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // Default: 24h zurück
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;
@@ -113,14 +186,13 @@ async function performSync(syncType = 'ERSTEINRICHTUNG') {
const result = await parseAndStoreZip(buffer);
// Letzten erfolgreichen Sync-Zeitstempel speichern
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;
}
// 3. Inhalt ZIP & Dateitypen (JSON & XML Parsing für Products, Groups, Deletions)
// Inhalt ZIP & Dateitypen (JSON & XML Parsing)
async function parseAndStoreZip(buffer) {
const zip = new AdmZip(buffer);
const zipEntries = zip.getEntries();
@@ -209,7 +281,6 @@ async function parseAndStoreZip(buffer) {
const text2 = item.text2 || item.description || item.zutaten || item.Text2 || item.Description || '';
const groupId = String(item.groupId || item.warengruppe || item.GroupId || item.ProductGroupId || '');
// Preisermittlung (preis1 / vk1 / standard brutto)
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);
@@ -264,7 +335,6 @@ async function parseAndStoreZip(buffer) {
return { productsCount, groupsCount, deletionsCount, message: msg };
}
// Manueller/Mock Import für UI Testing
function importJsonData(data) {
const insertProduct = db.prepare(`
INSERT INTO products (id, text1, text2, group_id, preis1, unit, active)
@@ -311,6 +381,7 @@ function importJsonData(data) {
}
module.exports = {
processQrSetup,
performSync,
parseAndStoreZip,
importJsonData,

View File

@@ -58,7 +58,7 @@
text-transform: uppercase;
letter-spacing: 0.5px;
}
input, select {
input, textarea, select {
width: 100%;
padding: 12px 14px;
background: rgba(15, 23, 42, 0.8);
@@ -68,8 +68,9 @@
font-size: 14px;
outline: none;
transition: border-color 0.2s;
font-family: inherit;
}
input:focus {
input:focus, textarea:focus {
border-color: var(--primary);
}
.btn {
@@ -101,7 +102,11 @@
color: #090b10;
}
.btn-sync:hover { background: #0284c7; }
.btn-danger { background: var(--danger); color: #fff; }
.btn-qr {
background: #8b5cf6;
color: #fff;
}
.btn-qr:hover { background: #7c3aed; }
.grid-2 {
display: grid;
@@ -184,7 +189,7 @@
<div class="header-bar">
<div>
<h2>POS Cloud Administration</h2>
<p style="font-size: 13px; color: var(--text-muted);">Schnittstelle & Speisekarten-Synchronisation</p>
<p style="font-size: 13px; color: var(--text-muted);">Schnittstelle, QR-Setup & Speisekarten-Synchronisation</p>
</div>
<div style="display: flex; gap: 10px;">
<a href="/" target="_blank" class="btn btn-secondary">🍽️ Zur Karte</a>
@@ -194,9 +199,23 @@
<div id="dash-alert" class="notification"></div>
<!-- QR-Code Scanner / Setup -->
<div class="card" style="border-left: 4px solid #8b5cf6;">
<h3 style="margin-bottom: 8px; color: #a78bfa;">📷 Automatisches QR-Code Setup (ww-setup)</h3>
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px;">
Füge hier den gescannten QR-Code Inhalt (JSON) aus der POS Cloud ein, um den automatischen Handshake (@LNK) und die Aktivierung (CASPOS.PUT) auszuführen.
</p>
<form id="qr-form">
<div class="form-group">
<textarea id="qr-json" rows="3" placeholder='{"$type": "ww-setup", "url": "https://...", "servicepasslink": "...", "mdcDeviceId": "1"}' required></textarea>
</div>
<button type="submit" class="btn btn-qr">🚀 QR-Code Handshake ausführen</button>
</form>
</div>
<!-- POS Cloud Konfiguration -->
<div class="card">
<h3 style="margin-bottom: 16px; color: var(--primary);">⚙️ POS Cloud Verbindungsparameter</h3>
<h3 style="margin-bottom: 16px; color: var(--primary);">⚙️ Manuelle POS Cloud Verbindungsparameter</h3>
<form id="config-form">
<div class="grid-2">
<div class="form-group">
@@ -225,10 +244,11 @@
<input type="password" id="cfg-admin-pass" placeholder="Neues Passwort vergeben..." />
</div>
<div style="display: flex; gap: 12px; margin-top: 20px;">
<div style="display: flex; gap: 12px; margin-top: 20px; flex-wrap: wrap;">
<button type="submit" class="btn">Speichern</button>
<button type="button" id="btn-sync-full" class="btn btn-sync">🔄 Ersteinrichtung Sync (CASPOS.GET)</button>
<button type="button" id="btn-mock-data" class="btn btn-secondary">📥 Musterdaten laden</button>
<button type="button" id="btn-sync-full" class="btn btn-sync">🔄 Voll-Sync (ERSTEINRICHTUNG)</button>
<button type="button" id="btn-sync-diff" class="btn btn-secondary">⚡ Diff-Sync (AENDERUNGEN)</button>
<button type="button" id="btn-mock-data" class="btn btn-secondary">📥 Musterdaten</button>
</div>
</form>
</div>
@@ -280,12 +300,11 @@
</div>
<script>
// Hilfsfunktionen
function showAlert(id, msg, isError = false) {
const el = document.getElementById(id);
el.textContent = msg;
el.className = 'notification ' + (isError ? 'error' : 'success');
setTimeout(() => { el.className = 'notification'; }, 5000);
setTimeout(() => { el.className = 'notification'; }, 6000);
}
async function checkAuth() {
@@ -354,6 +373,31 @@
}
}
// QR Handshake Form
document.getElementById('qr-form').addEventListener('submit', async (e) => {
e.preventDefault();
const qrContent = document.getElementById('qr-json').value;
showAlert('dash-alert', 'QR-Code Handshake läuft...');
try {
const res = await fetch('/api/admin/qr-setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qrContent })
});
const data = await res.json();
if (res.ok) {
showAlert('dash-alert', 'QR-Code Handshake erfolgreich! Parameter übernommen & aktiviert.');
document.getElementById('qr-json').value = '';
loadConfig();
loadData();
} else {
showAlert('dash-alert', 'QR-Code Fehler: ' + data.error, true);
}
} catch (err) {
showAlert('dash-alert', 'Netzwerkfehler beim QR-Handshake', true);
}
});
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = {
@@ -378,13 +422,13 @@
});
// POS Sync Button
document.getElementById('btn-sync-full').addEventListener('click', async () => {
showAlert('dash-alert', 'Synchronisiere mit POS Cloud...');
async function triggerSync(type) {
showAlert('dash-alert', `Synchronisiere mit POS Cloud (${type})...`);
try {
const res = await fetch('/api/admin/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'ERSTEINRICHTUNG' })
body: JSON.stringify({ type })
});
const data = await res.json();
if (res.ok) {
@@ -397,7 +441,10 @@
} catch(e) {
showAlert('dash-alert', 'Verbindung zur POS Cloud fehlgeschlagen', true);
}
});
}
document.getElementById('btn-sync-full').addEventListener('click', () => triggerSync('ERSTEINRICHTUNG'));
document.getElementById('btn-sync-diff').addEventListener('click', () => triggerSync('AENDERUNGEN'));
// Mock-Daten laden für schnellen Test
document.getElementById('btn-mock-data').addEventListener('click', async () => {
@@ -440,14 +487,11 @@
if (res.ok) {
const data = await res.json();
// Count
document.getElementById('product-count').textContent = `${data.products.length} Artikel`;
// Gruppen-Map
const groupMap = {};
for (const g of data.groups) groupMap[g.id] = g.name;
// Produkte Table
const tbody = document.querySelector('#products-table tbody');
if (data.products.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:var(--text-muted);">Keine Artikel in DB</td></tr>';
@@ -464,7 +508,6 @@
`).join('');
}
// Logs Table
const logBody = document.querySelector('#logs-table tbody');
if (data.logs.length === 0) {
logBody.innerHTML = '<tr><td colspan="3" style="text-align:center; color:var(--text-muted);">Keine Logs</td></tr>';

View File

@@ -44,7 +44,6 @@ app.get('/api/menu', (req, res) => {
const groups = db.prepare('SELECT * FROM product_groups ORDER BY sort_order ASC, name ASC').all();
const products = db.prepare('SELECT id, text1, text2, group_id, preis1, unit FROM products WHERE active = 1 ORDER BY sort_order ASC, text1 ASC').all();
// Gruppieren
const groupMap = {};
for (const g of groups) {
groupMap[g.id] = {
@@ -106,18 +105,17 @@ app.get('/api/admin/check', authMiddleware, (req, res) => {
res.json({ authenticated: true, user: req.user.user });
});
// ==================== Admin POS Settings & Sync ====================
// ==================== Admin POS Settings, QR Setup & Sync ====================
// 5. Config abrufen
app.get('/api/admin/config', authMiddleware, (req, res) => {
const config = posClient.getConfig();
// Passwort nicht im Klartext zurückgeben
delete config.admin_password_hash;
delete config.jwt_secret;
res.json(config);
});
// 6. Config speichern
// 6. Config manuell speichern
app.post('/api/admin/config', authMiddleware, (req, res) => {
const { pos_url, pos_pass_id, pos_app_id, pos_cash_register, new_admin_password } = req.body;
@@ -136,7 +134,22 @@ app.post('/api/admin/config', authMiddleware, (req, res) => {
res.json({ success: true, message: 'Einstellungen gespeichert' });
});
// 7. Sync auslösen
// 7. QR-Code Setup Handshake
app.post('/api/admin/qr-setup', authMiddleware, async (req, res) => {
const { qrContent } = req.body;
if (!qrContent) {
return res.status(400).json({ error: 'Kein QR-Code Inhalt übergeben' });
}
try {
const result = await posClient.processQrSetup(qrContent);
res.json({ success: true, message: 'ServicePass erfolgreich registriert und aktiviert!', result });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 8. Sync auslösen
app.post('/api/admin/sync', authMiddleware, async (req, res) => {
const { type } = req.body; // 'ERSTEINRICHTUNG' oder 'AENDERUNGEN'
try {
@@ -148,7 +161,7 @@ app.post('/api/admin/sync', authMiddleware, async (req, res) => {
}
});
// 8. Logs & Produkte im Admin anzeigen
// 9. Logs & Produkte im Admin anzeigen
app.get('/api/admin/data', authMiddleware, (req, res) => {
const products = db.prepare('SELECT * FROM products ORDER BY updated_at DESC').all();
const groups = db.prepare('SELECT * FROM product_groups').all();
@@ -156,7 +169,7 @@ app.get('/api/admin/data', authMiddleware, (req, res) => {
res.json({ products, groups, logs });
});
// 9. Manueller Import (Test/Demo)
// 10. Manueller Import (Test/Demo)
app.post('/api/admin/mock-import', authMiddleware, (req, res) => {
const mockData = req.body;
const count = posClient.importJsonData(mockData);