feat(qr): automatischer QR-Code Handshake (@LNK & CASPOS.PUT Aktivierung)
All checks were successful
Build and Deploy / build (push) Successful in 59s
All checks were successful
Build and Deploy / build (push) Successful in 59s
This commit is contained in:
89
posClient.js
89
posClient.js
@@ -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,
|
||||
|
||||
@@ -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>';
|
||||
|
||||
27
server.js
27
server.js
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user