From c6b5a0572c8e7b0fda84237016d095f93d0dec60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Gla=CC=88ser?= Date: Mon, 15 Jun 2026 09:58:33 +0200 Subject: [PATCH] Add architecture module map --- AGENTS.md | 54 ++ docs/N8N_EXCEL_WORKFLOW.json | 182 ---- docs/N8N_NODE_COPY_PASTE.md | 104 --- docs/N8N_POSTGRES_NODE.json | 26 - docs/N8N_POSTGRES_NODE.md | 161 ---- docs/N8N_POSTGRES_NODE.yaml | 58 -- docs/N8N_POSTGRES_NODE_CORRECTED.md | 108 --- docs/N8N_POSTGRES_QUERY.md | 208 ----- docs/architektur/modulkarte.md | 115 +++ docs/architektur/technical_architecture.md | 239 ++++++ .../Portal Grundkonzept.md} | 2 +- docs/konzepte/phase1-technisches-konzept.md | 2 +- includes/webhook_throttle.php | 42 + ...ssetikette-erstellen.g6FDHAICnQdbW6Ye.json | 634 -------------- ...-eingang-online-shop.yNLjtV9yG0T6CqSr.json | 580 ------------- n8n/exports/index.json | 16 - order-import.php | 95 +-- public/api/otc-order.php | 785 ++++++++++++------ public/otc/index.php | 95 ++- 19 files changed, 1134 insertions(+), 2372 deletions(-) create mode 100644 AGENTS.md delete mode 100644 docs/N8N_EXCEL_WORKFLOW.json delete mode 100644 docs/N8N_NODE_COPY_PASTE.md delete mode 100644 docs/N8N_POSTGRES_NODE.json delete mode 100644 docs/N8N_POSTGRES_NODE.md delete mode 100644 docs/N8N_POSTGRES_NODE.yaml delete mode 100644 docs/N8N_POSTGRES_NODE_CORRECTED.md delete mode 100644 docs/N8N_POSTGRES_QUERY.md create mode 100644 docs/architektur/modulkarte.md create mode 100644 docs/architektur/technical_architecture.md rename docs/{CONCEPT.md => konzepte/Portal Grundkonzept.md} (99%) create mode 100644 includes/webhook_throttle.php delete mode 100644 n8n/exports/current/adressetikette-erstellen.g6FDHAICnQdbW6Ye.json delete mode 100644 n8n/exports/current/bestell-eingang-online-shop.yNLjtV9yG0T6CqSr.json delete mode 100644 n8n/exports/index.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8dbc857 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# Agent Instructions + +## 1. Geltung + +Diese Datei enthaelt operative Arbeitsregeln fuer Agenten in diesem Repository. + + +## 2. Codeaenderungen, Commits und Push + +- Zu jeder Codeaenderung eine Commit-Nachricht vorschlagen. +- Commit-Nachricht so kurz wie moeglich halten, maximal 120 Zeichen. +- Gilt fuer jede Sitzung in diesem Repository. +- Standard-Workflow fuer den Agenten: Nach jeder Codeaenderung automatisch `git add`, `git commit` und `git push` ausfuehren. +- Ausnahme: Nur dann nicht automatisch committen/pushen, wenn der User es fuer den konkreten Task explizit anders vorgibt. +- Falls `git push` fehlschlaegt (z. B. Konflikt/Reject), den Fehler sofort melden und kurz den naechsten sicheren Schritt vorschlagen. + +## 3. Loesungsqualitaet + +- Konsistente, skalierbare Loesungen umsetzen; keine Hacks oder Quick-Fixes als Endloesung. +- Grundregel fuer das gesamte Projekt: keine Fallbacks, Ausnahmen oder Workarounds in Applikation, DB oder UI vorsehen oder implementieren. +- Wenn auf Legacy, Rueckwaertskompatibilitaet oder alte Daten-/UI-/Ablaufvarianten Ruecksicht genommen werden soll, immer zuerst explizit den User fragen und erst nach Freigabe umsetzen. +- Wir erstellen immer saubere, skalierbare Loesungen fuer das Gesamtsystem und pruefen Integritaet, Konsistenz und Skalierbarkeit, bevor wir Aenderungen oder Erweiterungen umsetzen. +- Wenn fuer eine robuste Loesung Informationen fehlen, gezielt nachfragen bevor umgesetzt wird. + +## 4. Lokaler Rechner und DEV-Umgebung + +- Verbindlich: Lokale Installationen fuer App/Runtime/Scheduler existieren nicht und sind nicht zu verwenden. +- Verbindlich: Dieses lokale Repository auf dem Mac dient ausschliesslich zum Bearbeiten von Dateien. +- Verbindlich: DEV-Betrieb ist ausschliesslich auf Synology unter `/volume2/webssd/erpnaurua/dev`. +- Verbindlich: Fuer DEV-Operationen ist ausschliesslich der Zugang `ssh synology-hz` zu verwenden. +- Verbindlich: Prozesse, Cronjobs und Runtime-Pruefungen werden nur auf Synology ausgefuehrt, niemals lokal auf dem Mac. +- Fuer DB-Zugriffe auf DEV immer in diesem Pfad arbeiten und den Command-Prefix verwenden: `bash scripts/db/psql.sh` (DB-Zugangsdaten aus `.env`). + +## 5. DB-Backups + +- DB-Backups sind nur fuer Entwickler-DB-Aenderungen (Codex oder Mensch) im Rahmen von Entwicklungsarbeit verpflichtend. +- In operativen Prozessen/Betriebsablaeufen (App, Workflow, Scheduler, Cron, Runtime) werden niemals DB-Backups ausgefuehrt, auch nicht auf DEV. +- Fuer verpflichtende Entwickler-Backups auf DEV 20 rollierende Backup-Staende je Backup-Set behalten; aeltere Backups nach erfolgreicher Neuerstellung loeschen. + +## 6. Runtime-Artefakte + +- Laufzeitdaten, Logs, Backups, Exporte, PID-Dateien, Heartbeats und sonstige Runtime-Artefakte gehoeren ausschliesslich nach `/runtime/`. +- `/runtime/` ist bei normaler Code-Entwicklung, Analyse, Review und Refactoring grundsaetzlich zu ignorieren, sofern der User nicht ausdruecklich an Runtime-/Betriebsthemen arbeitet. + +## 7. Temporaere Migrationsartefakte + +- Temporaere Migrationsartefakte wie duenne Betriebs-Wrapper sind nur nach expliziter User-Freigabe zulaessig. +- Solche Artefakte muessen delegierend bleiben, duerfen keine eigene Business-Logik tragen und muessen in `docs/legacy/temporary_migration_artifacts.md` zur spaeteren Entfernung erfasst werden. + +## 8. Verbindliche Skills und Anleitungen +- Wenn Prozesse konzipiert werden: verwende immer den Skill prozess-konzeption +- Wenn Prozesse umgesetzt werden: verwende immer den Skill prozess-umsetzung +- Wenn UI umgesetzt werden soll: verwende immer den Skill styleguide-anwendung +- Zwingende Vorgabe für alle Arbeiten ist /docs/technical_architecture.md diff --git a/docs/N8N_EXCEL_WORKFLOW.json b/docs/N8N_EXCEL_WORKFLOW.json deleted file mode 100644 index 88e6c4c..0000000 --- a/docs/N8N_EXCEL_WORKFLOW.json +++ /dev/null @@ -1,182 +0,0 @@ -{ - "name": "Excel Buchhaltung Befüllung", - "nodes": [ - { - "parameters": { - "path": "excel_befuellen", - "options": {} - }, - "type": "n8n-nodes-base.webhook", - "typeVersion": 2.1, - "position": [-288, -64], - "id": "webhook-node", - "name": "Webhook", - "webhookId": "excel-befuellen-webhook" - }, - { - "parameters": { - "operation": "executeQuery", - "query": "SELECT \n -- Bestellinformationen\n so.external_ref AS \"Bestellnummer\",\n TO_CHAR(so.order_date, 'YYYY-MM-DD\"T\"HH24:MI:SS') AS \"Bestelldatum\",\n TO_CHAR(so.shipping_date, 'YYYY-MM-DD') AS \"Versanddatum\",\n \n -- Kundenadresse (Lieferadresse)\n COALESCE(ad.first_name, '') AS \"Vorname\",\n COALESCE(ad.last_name, '') AS \"Nachname\",\n COALESCE(ad.street, '') AS \"Strasse\",\n COALESCE(ad.house_number, '') AS \"Hausnummer\",\n COALESCE(ad.zip, '') AS \"PLZ\",\n COALESCE(ad.city, '') AS \"Stadt\",\n COALESCE(ad.country_name, '') AS \"Land\",\n \n -- Zahlungs- und Betragsinformationen\n COALESCE(pm.code, '') AS \"Zahlungsart\",\n COALESCE(so.amount_net, 0) AS \"Gesamtbetrag_netto\",\n COALESCE(so.amount_shipping, 0) AS \"Versandkosten\",\n COALESCE(so.total_amount, 0) AS \"Gesamtbetrag_brutto\",\n COALESCE(so.amount_discount, 0) AS \"Rabatt\",\n \n -- Produktzählungen (nur aktive Produkte)\n COALESCE(SUM(CASE WHEN p.id = 8 THEN a.qty ELSE 0 END), 0) AS \"#_ChagaFlaschen\", -- CHAGA (ID 8)\n COALESCE(SUM(CASE WHEN p.id = 5 THEN a.qty ELSE 0 END), 0) AS \"#_ReishiFlaschen\", -- 003.01 (ID 5)\n COALESCE(SUM(CASE WHEN p.id = 9 THEN a.qty ELSE 0 END), 0) AS \"#_ShiitakeFlaschen\", -- SHIITAKE (ID 9)\n COALESCE(SUM(CASE WHEN p.id = 6 THEN a.qty ELSE 0 END), 0) AS \"#_LionsManeFlaschen\" -- 005.02 (ID 6)\n \nFROM sales_order so\n-- Lieferadresse\nLEFT JOIN address ad ON so.party_id = ad.party_id AND ad.type = 'shipping'\n-- Zahlungsart\nLEFT JOIN payment_method pm ON so.payment_method_id = pm.id\n-- Bestellpositionen und Allokationen\nLEFT JOIN sales_order_line sol ON so.id = sol.sales_order_id\nLEFT JOIN sales_order_line_lot_allocation a ON sol.id = a.sales_order_line_id\n-- Produkte (nur aktive)\nLEFT JOIN product p ON a.product_id = p.id AND p.status = 'active'\n\nWHERE so.external_ref = $1\nGROUP BY so.id, ad.id, pm.id", - "queryValues": { - "values": { - "value": "={{ $json.Bestellnummer }}", - "string": "={{ $json.Bestellnummer }}" - } - }, - "options": { - "maxRows": 1 - } - }, - "type": "n8n-nodes-base.postgres", - "typeVersion": 2.1, - "position": [-64, -64], - "id": "postgres-node", - "name": "Excel Daten aus ERP abfragen", - "credentials": { - "postgres": { - "id": "YOUR_POSTGRES_CREDENTIAL_ID", - "name": "ERP Naurua Database" - } - } - }, - { - "parameters": { - "resource": "worksheet", - "operation": "upsert", - "workbook": { - "__rl": true, - "value": "01CF7VVDAE6RTC2NMMTREI2C66KS7ZTNBK", - "mode": "list", - "cachedResultName": "Buchhaltung", - "cachedResultUrl": "https://beaufortdata-my.sharepoint.com/personal/mathias_glaeser_beaufort_ch/_layouts/15/Doc.aspx?sourcedoc=%7B2D66F404-8C35-489C-8D0B-DE54BF99B42A%7D&file=Buchhaltung.xlsx&action=default&mobileredirect=true&DefaultItemOpen=1" - }, - "worksheet": { - "__rl": true, - "value": "{1CBC09E2-CE51-0349-A736-1EA898A76FF2}", - "mode": "list", - "cachedResultName": "n8n", - "cachedResultUrl": "https://beaufortdata-my.sharepoint.com/personal/mathias_glaeser_beaufort_ch/_layouts/15/Doc.aspx?sourcedoc=%7B2D66F404-8C35-489C-8D0B-DE54BF99B42A%7D&file=Buchhaltung.xlsx&action=default&mobileredirect=true&DefaultItemOpen=1&activeCell=n8n!A1" - }, - "columnToMatchOn": "Bestelldatum", - "valueToMatchOn": "={{ $json.Bestelldatum }}", - "fieldsUi": { - "values": [ - { - "column": "Versanddatum", - "fieldValue": "={{ $json.Versanddatum }}" - }, - { - "column": "Bestellnummer", - "fieldValue": "={{ $json.Bestellnummer }}" - }, - { - "column": "Vorname", - "fieldValue": "={{ $json.Vorname }}" - }, - { - "column": "Nachname", - "fieldValue": "={{ $json.Nachname }}" - }, - { - "column": "Strasse", - "fieldValue": "={{ $json.Strasse }}" - }, - { - "column": "PLZ", - "fieldValue": "={{ $json.PLZ }}" - }, - { - "column": "Hausnummer", - "fieldValue": "={{ $json.Hausnummer }}" - }, - { - "column": "Stadt", - "fieldValue": "={{ $json.Stadt }}" - }, - { - "column": "Land", - "fieldValue": "={{ $json.Land }}" - }, - { - "column": "Zahlungsart", - "fieldValue": "={{ $json.Zahlungsart }}" - }, - { - "column": "Gesamtbetrag_netto", - "fieldValue": "={{ $json.Gesamtbetrag_netto }}" - }, - { - "column": "Versandkosten", - "fieldValue": "={{ $json.Versandkosten }}" - }, - { - "column": "Gesamtbetrag_brutto", - "fieldValue": "={{ $json.Gesamtbetrag_brutto }}" - }, - { - "column": "Rabatt", - "fieldValue": "={{ $json.Rabatt }}" - }, - { - "column": "#_ChagaFlaschen", - "fieldValue": "={{ $json['#_ChagaFlaschen'] }}" - }, - { - "column": "#_LionsManeFlaschen", - "fieldValue": "={{ $json['#_LionsManeFlaschen'] }}" - }, - { - "column": "#_ReishiFlaschen", - "fieldValue": "={{ $json['#_ReishiFlaschen'] }}" - }, - { - "column": "#_ShiitakeFlaschen", - "fieldValue": "={{ $json['#_ShiitakeFlaschen'] }}" - } - ] - }, - "options": {} - }, - "type": "n8n-nodes-base.microsoftExcel", - "typeVersion": 2.1, - "position": [240, -64], - "id": "excel-node", - "name": "Bestellungen auf mathias onedrive Excel übertragen", - "retryOnFail": true, - "credentials": { - "microsoftExcelOAuth2Api": { - "id": "xrxWImZOTzNL3hTl", - "name": "Microsoft Excel account OneDrive MGL Naurua" - } - } - } - ], - "connections": { - "Webhook": { - "main": [ - [ - { - "node": "Excel Daten aus ERP abfragen", - "type": "main", - "index": 0 - } - ] - ] - }, - "Excel Daten aus ERP abfragen": { - "main": [ - [ - { - "node": "Bestellungen auf mathias onedrive Excel übertragen", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "pinData": {}, - "meta": { - "instanceId": "excel-befuellen-workflow" - } -} \ No newline at end of file diff --git a/docs/N8N_NODE_COPY_PASTE.md b/docs/N8N_NODE_COPY_PASTE.md deleted file mode 100644 index b444fcd..0000000 --- a/docs/N8N_NODE_COPY_PASTE.md +++ /dev/null @@ -1,104 +0,0 @@ -# n8n Postgres Node - Copy & Paste - -## SQL Query für Postgres Node -Kopiere diesen SQL-Code in das Query-Feld der n8n Postgres Node: - -```sql -SELECT - -- Bestellinformationen - so.external_ref AS "Bestellnummer", - TO_CHAR(so.order_date, 'YYYY-MM-DD"T"HH24:MI:SS') AS "Bestelldatum", - TO_CHAR(so.shipping_date, 'YYYY-MM-DD') AS "Versanddatum", - - -- Kundenadresse (Lieferadresse) - COALESCE(ad.first_name, '') AS "Vorname", - COALESCE(ad.last_name, '') AS "Nachname", - COALESCE(ad.street, '') AS "Strasse", - COALESCE(ad.house_number, '') AS "Hausnummer", - COALESCE(ad.zip, '') AS "PLZ", - COALESCE(ad.city, '') AS "Stadt", - COALESCE(ad.country_name, '') AS "Land", - - -- Zahlungs- und Betragsinformationen - COALESCE(pm.code, '') AS "Zahlungsart", - COALESCE(so.amount_net, 0) AS "Gesamtbetrag_netto", - COALESCE(so.amount_shipping, 0) AS "Versandkosten", - COALESCE(so.total_amount, 0) AS "Gesamtbetrag_brutto", - COALESCE(so.amount_discount, 0) AS "Rabatt", - - -- Produktzählungen (nur aktive Produkte) - COALESCE(SUM(CASE WHEN p.id = 8 THEN a.qty ELSE 0 END), 0) AS "#_ChagaFlaschen", -- CHAGA (ID 8) - COALESCE(SUM(CASE WHEN p.id = 5 THEN a.qty ELSE 0 END), 0) AS "#_ReishiFlaschen", -- 003.01 (ID 5) - COALESCE(SUM(CASE WHEN p.id = 9 THEN a.qty ELSE 0 END), 0) AS "#_ShiitakeFlaschen", -- SHIITAKE (ID 9) - COALESCE(SUM(CASE WHEN p.id = 6 THEN a.qty ELSE 0 END), 0) AS "#_LionsManeFlaschen" -- 005.02 (ID 6) - -FROM sales_order so --- Lieferadresse -LEFT JOIN address ad ON so.party_id = ad.party_id AND ad.type = 'shipping' --- Zahlungsart -LEFT JOIN payment_method pm ON so.payment_method_id = pm.id --- Bestellpositionen und Allokationen -LEFT JOIN sales_order_line sol ON so.id = sol.sales_order_id -LEFT JOIN sales_order_line_lot_allocation a ON sol.id = a.sales_order_line_id --- Produkte (nur aktive) -LEFT JOIN product p ON a.product_id = p.id AND p.status = 'active' - -WHERE so.external_ref = $1 -GROUP BY so.id, ad.id, pm.id -``` - -## Query Values (Parameter) -```json -{ - "values": { - "value": "={{ $json.Bestellnummer }}", - "string": "={{ $json.Bestellnummer }}" - } -} -``` - -## Database Connection -``` -Name: ERP Naurua Database -Host: 192.168.1.199 -Port: 55432 -Database: naurua_erp -User: codex_db_user -Password: Ze90re0KAry8gyJ6eAx0Gf4IelEGI -SSL: Disabled -``` - -## Options -```json -{ - "maxRows": 1 -} -``` - -## Output Fields -Die Query liefert diese Felder für die Excel-Node: - -| Feldname | Typ | Beispiel | -|----------|-----|----------| -| Bestellnummer | String | "10477" | -| Bestelldatum | ISO DateTime | "2026-04-05T23:12:07" | -| Versanddatum | ISO Date | "2026-04-06" | -| Vorname | String | "Irendy" | -| Nachname | String | "Bucio" | -| Strasse | String | "Weingartenstrasse" | -| Hausnummer | String | "5" | -| PLZ | String | "4600" | -| Stadt | String | "Olten" | -| Land | String | "CH" | -| Zahlungsart | String | "" | -| Gesamtbetrag_netto | Number | 49.95 | -| Versandkosten | Number | 4.95 | -| Gesamtbetrag_brutto | Number | 44.91 | -| Rabatt | Number | 9.99 | -| #_ChagaFlaschen | Number | 0 | -| #_ReishiFlaschen | Number | 0 | -| #_ShiitakeFlaschen | Number | 0 | -| #_LionsManeFlaschen | Number | 1.0000 | - -## Excel-Node Mapping -Die Excel-Node muss diese Feldnamen exakt übernehmen (Groß-/Kleinschreibung beachten!). \ No newline at end of file diff --git a/docs/N8N_POSTGRES_NODE.json b/docs/N8N_POSTGRES_NODE.json deleted file mode 100644 index 3899cd6..0000000 --- a/docs/N8N_POSTGRES_NODE.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "Excel Daten aus ERP abfragen", - "type": "n8n-nodes-base.postgres", - "typeVersion": 2.1, - "position": [0, 0], - "credentials": { - "postgres": { - "id": "YOUR_CREDENTIAL_ID_HERE", - "name": "ERP Naurua Database" - } - }, - "parameters": { - "operation": "executeQuery", - "query": "SELECT \n -- Bestellinformationen\n so.external_ref AS \"Bestellnummer\",\n TO_CHAR(so.order_date, 'YYYY-MM-DD\"T\"HH24:MI:SS') AS \"Bestelldatum\",\n TO_CHAR(so.shipping_date, 'YYYY-MM-DD') AS \"Versanddatum\",\n \n -- Kundenadresse (Lieferadresse)\n COALESCE(ad.first_name, '') AS \"Vorname\",\n COALESCE(ad.last_name, '') AS \"Nachname\",\n COALESCE(ad.street, '') AS \"Strasse\",\n COALESCE(ad.house_number, '') AS \"Hausnummer\",\n COALESCE(ad.zip, '') AS \"PLZ\",\n COALESCE(ad.city, '') AS \"Stadt\",\n COALESCE(ad.country_name, '') AS \"Land\",\n \n -- Zahlungs- und Betragsinformationen\n COALESCE(pm.code, '') AS \"Zahlungsart\",\n COALESCE(so.amount_net, 0) AS \"Gesamtbetrag_netto\",\n COALESCE(so.amount_shipping, 0) AS \"Versandkosten\",\n COALESCE(so.total_amount, 0) AS \"Gesamtbetrag_brutto\",\n COALESCE(so.amount_discount, 0) AS \"Rabatt\",\n \n -- Produktzählungen (nur aktive Produkte)\n COALESCE(SUM(CASE WHEN p.id = 8 THEN a.qty ELSE 0 END), 0) AS \"#_ChagaFlaschen\", -- CHAGA (ID 8)\n COALESCE(SUM(CASE WHEN p.id = 5 THEN a.qty ELSE 0 END), 0) AS \"#_ReishiFlaschen\", -- 003.01 (ID 5)\n COALESCE(SUM(CASE WHEN p.id = 9 THEN a.qty ELSE 0 END), 0) AS \"#_ShiitakeFlaschen\", -- SHIITAKE (ID 9)\n COALESCE(SUM(CASE WHEN p.id = 6 THEN a.qty ELSE 0 END), 0) AS \"#_LionsManeFlaschen\" -- 005.02 (ID 6)\n \nFROM sales_order so\n-- Lieferadresse\nLEFT JOIN address ad ON so.party_id = ad.party_id AND ad.type = 'shipping'\n-- Zahlungsart\nLEFT JOIN payment_method pm ON so.payment_method_id = pm.id\n-- Bestellpositionen und Allokationen\nLEFT JOIN sales_order_line sol ON so.id = sol.sales_order_id\nLEFT JOIN sales_order_line_lot_allocation a ON sol.id = a.sales_order_line_id\n-- Produkte (nur aktive)\nLEFT JOIN product p ON a.product_id = p.id AND p.status = 'active'\n\nWHERE so.external_ref = $1\nGROUP BY so.id, ad.id, pm.id", - "queryValues": { - "values": { - "value": "={{ $json.Bestellnummer }}", - "string": "={{ $json.Bestellnummer }}" - } - }, - "options": { - "maxRows": 1 - }, - "additionalFields": {} - } -} \ No newline at end of file diff --git a/docs/N8N_POSTGRES_NODE.md b/docs/N8N_POSTGRES_NODE.md deleted file mode 100644 index aa40db7..0000000 --- a/docs/N8N_POSTGRES_NODE.md +++ /dev/null @@ -1,161 +0,0 @@ -# n8n Postgres Node Konfiguration - -## Database Connection -``` -Name: ERP Naurua Database -Host: 192.168.1.199 -Port: 55432 -Database: naurua_erp -User: codex_db_user -Password: Ze90re0KAry8gyJ6eAx0Gf4IelEGI -SSL: Disabled -``` - -## Node Parameter - -### Operation -``` -Execute Query -``` - -### Query -```sql -SELECT - -- Bestellinformationen - so.external_ref AS "Bestellnummer", - TO_CHAR(so.order_date, 'YYYY-MM-DD"T"HH24:MI:SS') AS "Bestelldatum", - TO_CHAR(so.shipping_date, 'YYYY-MM-DD') AS "Versanddatum", - - -- Kundenadresse (Lieferadresse) - COALESCE(ad.first_name, '') AS "Vorname", - COALESCE(ad.last_name, '') AS "Nachname", - COALESCE(ad.street, '') AS "Strasse", - COALESCE(ad.house_number, '') AS "Hausnummer", - COALESCE(ad.zip, '') AS "PLZ", - COALESCE(ad.city, '') AS "Stadt", - COALESCE(ad.country_name, '') AS "Land", - - -- Zahlungs- und Betragsinformationen - COALESCE(pm.code, '') AS "Zahlungsart", - COALESCE(so.amount_net, 0) AS "Gesamtbetrag_netto", - COALESCE(so.amount_shipping, 0) AS "Versandkosten", - COALESCE(so.total_amount, 0) AS "Gesamtbetrag_brutto", - COALESCE(so.amount_discount, 0) AS "Rabatt", - - -- Produktzählungen (nur aktive Produkte) - COALESCE(SUM(CASE WHEN p.id = 8 THEN a.qty ELSE 0 END), 0) AS "#_ChagaFlaschen", -- CHAGA (ID 8) - COALESCE(SUM(CASE WHEN p.id = 5 THEN a.qty ELSE 0 END), 0) AS "#_ReishiFlaschen", -- 003.01 (ID 5) - COALESCE(SUM(CASE WHEN p.id = 9 THEN a.qty ELSE 0 END), 0) AS "#_ShiitakeFlaschen", -- SHIITAKE (ID 9) - COALESCE(SUM(CASE WHEN p.id = 6 THEN a.qty ELSE 0 END), 0) AS "#_LionsManeFlaschen" -- 005.02 (ID 6) - -FROM sales_order so --- Lieferadresse -LEFT JOIN address ad ON so.party_id = ad.party_id AND ad.type = 'shipping' --- Zahlungsart -LEFT JOIN payment_method pm ON so.payment_method_id = pm.id --- Bestellpositionen und Allokationen -LEFT JOIN sales_order_line sol ON so.id = sol.sales_order_id -LEFT JOIN sales_order_line_lot_allocation a ON sol.id = a.sales_order_line_id --- Produkte (nur aktive) -LEFT JOIN product p ON a.product_id = p.id AND p.status = 'active' - -WHERE so.external_ref = $1 -GROUP BY so.id, ad.id, pm.id -``` - -### Query Values -```json -{ - "values": { - "value": "={{ $json.Bestellnummer }}", - "string": "={{ $json.Bestellnummer }}" - } -} -``` - -### Options -```json -{ - "maxRows": 1 -} -``` - -## Workflow Integration - -### Flow -``` -Webhook → Postgres Node → Excel Node -``` - -### Webhook Input -```json -{ - "Bestellnummer": "10477" -} -``` - -### Expected Output -```json -{ - "Bestellnummer": "10477", - "Bestelldatum": "2026-04-05T23:12:07", - "Versanddatum": "2026-04-06", - "Vorname": "Irendy", - "Nachname": "Bucio", - "Strasse": "Weingartenstrasse", - "Hausnummer": "5", - "PLZ": "4600", - "Stadt": "Olten", - "Land": "CH", - "Zahlungsart": "", - "Gesamtbetrag_netto": 49.95, - "Versandkosten": 4.95, - "Gesamtbetrag_brutto": 44.91, - "Rabatt": 9.99, - "#_ChagaFlaschen": 0, - "#_ReishiFlaschen": 0, - "#_ShiitakeFlaschen": 0, - "#_LionsManeFlaschen": 1.0000 -} -``` - -## Produkt-Mapping - -| Excel-Feld | Produkt-ID | SKU | Status | -|------------|------------|-----|--------| -| `#_ChagaFlaschen` | 8 | `CHAGA` | aktiv | -| `#_ReishiFlaschen` | 5 | `003.01` | aktiv | -| `#_ShiitakeFlaschen` | 9 | `SHIITAKE` | aktiv | -| `#_LionsManeFlaschen` | 6 | `005.02` | aktiv | - -**Ignoriert:** ID 7 (`LIONSMANE`) - inaktiv - -## Fehlerbehandlung - -### Keine Bestellung gefunden -- Alle Felder werden als leere Strings oder 0 zurückgegeben -- Excel-Node kann trotzdem ausgeführt werden - -### Keine Produkt-Allokationen -- Produktzählungen sind 0 -- Andere Felder sind normal gefüllt - -## Testing - -### Test Query direkt in DB -```bash -psql "postgresql://codex_db_user:Ze90re0KAry8gyJ6eAx0Gf4IelEGI@192.168.1.199:55432/naurua_erp" -c " --- Test mit existierender Bestellung -SELECT external_ref FROM sales_order WHERE external_ref LIKE '10%' LIMIT 1;" -``` - -### Test Response -Erwartet eine Zeile mit allen Feldern gefüllt oder als leere Strings/0. - -## Wichtige Hinweise - -1. **shipping_date** wird automatisch via Trigger berechnet (nächster Arbeitstag) -2. **Wochenend-Logik**: Freitag → Montag, Samstag → Montag, Sonntag → Montag -3. **Feldnamen** müssen exakt wie oben angegeben sein (Groß-/Kleinschreibung beachten) -4. **Produkt-IDs** sind fest in der Query hinterlegt -5. **Max Rows** = 1 (eine Bestellung pro Aufruf) \ No newline at end of file diff --git a/docs/N8N_POSTGRES_NODE.yaml b/docs/N8N_POSTGRES_NODE.yaml deleted file mode 100644 index dfec97b..0000000 --- a/docs/N8N_POSTGRES_NODE.yaml +++ /dev/null @@ -1,58 +0,0 @@ -name: Excel Daten aus ERP abfragen -type: n8n-nodes-base.postgres -typeVersion: 2.1 -position: [0, 0] -credentials: - postgres: - id: YOUR_CREDENTIAL_ID_HERE - name: ERP Naurua Database -parameters: - operation: executeQuery - query: | - SELECT - -- Bestellinformationen - so.external_ref AS "Bestellnummer", - TO_CHAR(so.order_date, 'YYYY-MM-DD"T"HH24:MI:SS') AS "Bestelldatum", - TO_CHAR(so.shipping_date, 'YYYY-MM-DD') AS "Versanddatum", - - -- Kundenadresse (Lieferadresse) - COALESCE(ad.first_name, '') AS "Vorname", - COALESCE(ad.last_name, '') AS "Nachname", - COALESCE(ad.street, '') AS "Strasse", - COALESCE(ad.house_number, '') AS "Hausnummer", - COALESCE(ad.zip, '') AS "PLZ", - COALESCE(ad.city, '') AS "Stadt", - COALESCE(ad.country_name, '') AS "Land", - - -- Zahlungs- und Betragsinformationen - COALESCE(pm.code, '') AS "Zahlungsart", - COALESCE(so.amount_net, 0) AS "Gesamtbetrag_netto", - COALESCE(so.amount_shipping, 0) AS "Versandkosten", - COALESCE(so.total_amount, 0) AS "Gesamtbetrag_brutto", - COALESCE(so.amount_discount, 0) AS "Rabatt", - - -- Produktzählungen (nur aktive Produkte) - COALESCE(SUM(CASE WHEN p.id = 8 THEN a.qty ELSE 0 END), 0) AS "#_ChagaFlaschen", -- CHAGA (ID 8) - COALESCE(SUM(CASE WHEN p.id = 5 THEN a.qty ELSE 0 END), 0) AS "#_ReishiFlaschen", -- 003.01 (ID 5) - COALESCE(SUM(CASE WHEN p.id = 9 THEN a.qty ELSE 0 END), 0) AS "#_ShiitakeFlaschen", -- SHIITAKE (ID 9) - COALESCE(SUM(CASE WHEN p.id = 6 THEN a.qty ELSE 0 END), 0) AS "#_LionsManeFlaschen" -- 005.02 (ID 6) - - FROM sales_order so - -- Lieferadresse - LEFT JOIN address ad ON so.party_id = ad.party_id AND ad.type = 'shipping' - -- Zahlungsart - LEFT JOIN payment_method pm ON so.payment_method_id = pm.id - -- Bestellpositionen und Allokationen - LEFT JOIN sales_order_line sol ON so.id = sol.sales_order_id - LEFT JOIN sales_order_line_lot_allocation a ON sol.id = a.sales_order_line_id - -- Produkte (nur aktive) - LEFT JOIN product p ON a.product_id = p.id AND p.status = 'active' - - WHERE so.external_ref = $1 - GROUP BY so.id, ad.id, pm.id - queryValues: - values: - value: "={{ $json.Bestellnummer }}" - string: "={{ $json.Bestellnummer }}" - options: - maxRows: 1 \ No newline at end of file diff --git a/docs/N8N_POSTGRES_NODE_CORRECTED.md b/docs/N8N_POSTGRES_NODE_CORRECTED.md deleted file mode 100644 index 80071bd..0000000 --- a/docs/N8N_POSTGRES_NODE_CORRECTED.md +++ /dev/null @@ -1,108 +0,0 @@ -# n8n Postgres Node - Korrigierte Version - -## SQL Query (ohne Kommentare, mit $1 Parameter) -```sql -SELECT - so.external_ref AS "Bestellnummer", - TO_CHAR(so.order_date, 'YYYY-MM-DD"T"HH24:MI:SS') AS "Bestelldatum", - TO_CHAR(so.shipping_date, 'YYYY-MM-DD') AS "Versanddatum", - COALESCE(ad.first_name, '') AS "Vorname", - COALESCE(ad.last_name, '') AS "Nachname", - COALESCE(ad.street, '') AS "Strasse", - COALESCE(ad.house_number, '') AS "Hausnummer", - COALESCE(ad.zip, '') AS "PLZ", - COALESCE(ad.city, '') AS "Stadt", - COALESCE(ad.country_name, '') AS "Land", - COALESCE(pm.code, '') AS "Zahlungsart", - COALESCE(so.amount_net, 0) AS "Gesamtbetrag_netto", - COALESCE(so.amount_shipping, 0) AS "Versandkosten", - COALESCE(so.total_amount, 0) AS "Gesamtbetrag_brutto", - COALESCE(so.amount_discount, 0) AS "Rabatt", - COALESCE(SUM(CASE WHEN p.id = 8 THEN a.qty ELSE 0 END), 0) AS "#_ChagaFlaschen", - COALESCE(SUM(CASE WHEN p.id = 5 THEN a.qty ELSE 0 END), 0) AS "#_ReishiFlaschen", - COALESCE(SUM(CASE WHEN p.id = 9 THEN a.qty ELSE 0 END), 0) AS "#_ShiitakeFlaschen", - COALESCE(SUM(CASE WHEN p.id = 6 THEN a.qty ELSE 0 END), 0) AS "#_LionsManeFlaschen" -FROM sales_order so -LEFT JOIN address ad ON so.party_id = ad.party_id AND ad.type = 'shipping' -LEFT JOIN payment_method pm ON so.payment_method_id = pm.id -LEFT JOIN sales_order_line sol ON so.id = sol.sales_order_id -LEFT JOIN sales_order_line_lot_allocation a ON sol.id = a.sales_order_line_id -LEFT JOIN product p ON a.product_id = p.id AND p.status = 'active' -WHERE so.external_ref = $1 -GROUP BY so.id, ad.id, pm.id -``` - -## Alternative mit einfachen Feldnamen (keine Anführungszeichen): -```sql -SELECT - so.external_ref AS Bestellnummer, - TO_CHAR(so.order_date, 'YYYY-MM-DD"T"HH24:MI:SS') AS Bestelldatum, - TO_CHAR(so.shipping_date, 'YYYY-MM-DD') AS Versanddatum, - COALESCE(ad.first_name, '') AS Vorname, - COALESCE(ad.last_name, '') AS Nachname, - COALESCE(ad.street, '') AS Strasse, - COALESCE(ad.house_number, '') AS Hausnummer, - COALESCE(ad.zip, '') AS PLZ, - COALESCE(ad.city, '') AS Stadt, - COALESCE(ad.country_name, '') AS Land, - COALESCE(pm.code, '') AS Zahlungsart, - COALESCE(so.amount_net, 0) AS Gesamtbetrag_netto, - COALESCE(so.amount_shipping, 0) AS Versandkosten, - COALESCE(so.total_amount, 0) AS Gesamtbetrag_brutto, - COALESCE(so.amount_discount, 0) AS Rabatt, - COALESCE(SUM(CASE WHEN p.id = 8 THEN a.qty ELSE 0 END), 0) AS _ChagaFlaschen, - COALESCE(SUM(CASE WHEN p.id = 5 THEN a.qty ELSE 0 END), 0) AS _ReishiFlaschen, - COALESCE(SUM(CASE WHEN p.id = 9 THEN a.qty ELSE 0 END), 0) AS _ShiitakeFlaschen, - COALESCE(SUM(CASE WHEN p.id = 6 THEN a.qty ELSE 0 END), 0) AS _LionsManeFlaschen -FROM sales_order so -LEFT JOIN address ad ON so.party_id = ad.party_id AND ad.type = 'shipping' -LEFT JOIN payment_method pm ON so.payment_method_id = pm.id -LEFT JOIN sales_order_line sol ON so.id = sol.sales_order_id -LEFT JOIN sales_order_line_lot_allocation a ON sol.id = a.sales_order_line_id -LEFT JOIN product p ON a.product_id = p.id AND p.status = 'active' -WHERE so.external_ref = $1 -GROUP BY so.id, ad.id, pm.id -``` - -## Query Values in n8n -```json -{ - "values": { - "value": "={{ $json.Bestellnummer }}", - "string": "={{ $json.Bestellnummer }}" - } -} -``` - -## Testing Query direkt in DB -```bash -psql "postgresql://codex_db_user:Ze90re0KAry8gyJ6eAx0Gf4IelEGI@192.168.1.199:55432/naurua_erp" -c " -SELECT - so.external_ref AS Bestellnummer, - TO_CHAR(so.order_date, 'YYYY-MM-DD\"T\"HH24:MI:SS') AS Bestelldatum, - TO_CHAR(so.shipping_date, 'YYYY-MM-DD') AS Versanddatum, - COALESCE(ad.first_name, '') AS Vorname, - COALESCE(ad.last_name, '') AS Nachname, - COALESCE(ad.street, '') AS Strasse, - COALESCE(ad.house_number, '') AS Hausnummer, - COALESCE(ad.zip, '') AS PLZ, - COALESCE(ad.city, '') AS Stadt, - COALESCE(ad.country_name, '') AS Land, - COALESCE(pm.code, '') AS Zahlungsart, - COALESCE(so.amount_net, 0) AS Gesamtbetrag_netto, - COALESCE(so.amount_shipping, 0) AS Versandkosten, - COALESCE(so.total_amount, 0) AS Gesamtbetrag_brutto, - COALESCE(so.amount_discount, 0) AS Rabatt, - COALESCE(SUM(CASE WHEN p.id = 8 THEN a.qty ELSE 0 END), 0) AS _ChagaFlaschen, - COALESCE(SUM(CASE WHEN p.id = 5 THEN a.qty ELSE 0 END), 0) AS _ReishiFlaschen, - COALESCE(SUM(CASE WHEN p.id = 9 THEN a.qty ELSE 0 END), 0) AS _ShiitakeFlaschen, - COALESCE(SUM(CASE WHEN p.id = 6 THEN a.qty ELSE 0 END), 0) AS _LionsManeFlaschen -FROM sales_order so -LEFT JOIN address ad ON so.party_id = ad.party_id AND ad.type = 'shipping' -LEFT JOIN payment_method pm ON so.payment_method_id = pm.id -LEFT JOIN sales_order_line sol ON so.id = sol.sales_order_id -LEFT JOIN sales_order_line_lot_allocation a ON sol.id = a.sales_order_line_id -LEFT JOIN product p ON a.product_id = p.id AND p.status = 'active' -WHERE so.external_ref = '10477' -GROUP BY so.id, ad.id, pm.id;" -``` \ No newline at end of file diff --git a/docs/N8N_POSTGRES_QUERY.md b/docs/N8N_POSTGRES_QUERY.md deleted file mode 100644 index 31b9c79..0000000 --- a/docs/N8N_POSTGRES_QUERY.md +++ /dev/null @@ -1,208 +0,0 @@ -# Postgres Query für n8n Excel Node - -## Übersicht -Diese Query extrahiert alle benötigten Daten für die Excel-Buchhaltung basierend auf einer Bestellnummer. - -## Postgres Node Konfiguration für n8n - -### Connection Details -``` -Host: 192.168.1.199 -Port: 55432 -Database: naurua_erp -User: codex_db_user -Password: Ze90re0KAry8gyJ6eAx0Gf4IelEGI -SSL: Disabled (lokales Netzwerk) -``` - -### SQL Query für Excel-Daten - -```sql -SELECT - -- Bestellinformationen - so.external_ref AS "Bestellnummer", - TO_CHAR(so.order_date, 'YYYY-MM-DD"T"HH24:MI:SS') AS "Bestelldatum", - TO_CHAR(so.shipping_date, 'YYYY-MM-DD') AS "Versanddatum", - - -- Kundenadresse (Lieferadresse) - COALESCE(ad.first_name, '') AS "Vorname", - COALESCE(ad.last_name, '') AS "Nachname", - COALESCE(ad.street, '') AS "Strasse", - COALESCE(ad.house_number, '') AS "Hausnummer", - COALESCE(ad.zip, '') AS "PLZ", - COALESCE(ad.city, '') AS "Stadt", - COALESCE(ad.country_name, '') AS "Land", - - -- Zahlungs- und Betragsinformationen - COALESCE(pm.code, '') AS "Zahlungsart", - COALESCE(so.amount_net, 0) AS "Gesamtbetrag_netto", - COALESCE(so.amount_shipping, 0) AS "Versandkosten", - COALESCE(so.total_amount, 0) AS "Gesamtbetrag_brutto", - COALESCE(so.amount_discount, 0) AS "Rabatt", - - -- Produktzählungen (nur aktive Produkte) - COALESCE(SUM(CASE WHEN p.id = 8 THEN a.qty ELSE 0 END), 0) AS "#_ChagaFlaschen", -- CHAGA (ID 8) - COALESCE(SUM(CASE WHEN p.id = 5 THEN a.qty ELSE 0 END), 0) AS "#_ReishiFlaschen", -- 003.01 (ID 5) - COALESCE(SUM(CASE WHEN p.id = 9 THEN a.qty ELSE 0 END), 0) AS "#_ShiitakeFlaschen", -- SHIITAKE (ID 9) - COALESCE(SUM(CASE WHEN p.id = 6 THEN a.qty ELSE 0 END), 0) AS "#_LionsManeFlaschen" -- 005.02 (ID 6) - -FROM sales_order so --- Lieferadresse -LEFT JOIN address ad ON so.party_id = ad.party_id AND ad.type = 'shipping' --- Zahlungsart -LEFT JOIN payment_method pm ON so.payment_method_id = pm.id --- Bestellpositionen und Allokationen -LEFT JOIN sales_order_line sol ON so.id = sol.sales_order_id -LEFT JOIN sales_order_line_lot_allocation a ON sol.id = a.sales_order_line_id --- Produkte (nur aktive) -LEFT JOIN product p ON a.product_id = p.id AND p.status = 'active' - -WHERE so.external_ref = :bestellnummer -GROUP BY so.id, ad.id, pm.id -``` - -## Parameter -- `:bestellnummer` - Die externe Bestellreferenz (z.B. `W123456` oder `DIR-20260329-00017`) - -## Produkt-Mapping - -| Excel-Feld | Produkt-ID | SKU | Name | Status | -|------------|------------|-----|------|--------| -| `#_ChagaFlaschen` | 8 | `CHAGA` | Chaga Extrakt Tinktur 50 ml | 100% rein | aktiv | -| `#_ReishiFlaschen` | 5 | `003.01` | Reishi Extrakt Tinktur 50 ml | 100% rein | aktiv | -| `#_ShiitakeFlaschen` | 9 | `SHIITAKE` | Shiitake Extrakt Tinktur 50 ml | 100% rein | aktiv | -| `#_LionsManeFlaschen` | 6 | `005.02` | Lion's Mane Extrakt Tinktur 50 ml | 100% rein | aktiv | - -**Ignoriert:** Produkt-ID 7 (`LIONSMANE`) - Status: inaktiv - -## Feld-Erklärungen - -### Bestellinformationen -- `Bestellnummer` - Eindeutige Bestellreferenz (Wix: BestellungNr, Direktverkauf: DIR-...) -- `Bestelldatum` - ISO 8601 Format: `YYYY-MM-DDTHH:MM:SS` -- `Versanddatum` - ISO Date Format: `YYYY-MM-DD` (automatisch berechnet als nächster Arbeitstag) - -### Kundenadresse -- Alle Felder aus der Lieferadresse (`type = 'shipping'`) -- Leere Werte werden als leere Strings zurückgegeben - -### Zahlungsinformationen -- `Zahlungsart` - Interner Code aus `payment_method` (z.B. `twint`, `bank_transfer`, `card`, `pickup`) -- Beträge mit 2 Dezimalstellen, NULL wird zu 0 - -### Produktzählungen -- Summe der allokierten Mengen pro Produkt -- Nur aktive Produkte (`status = 'active'`) -- NULL wird zu 0 - -## Beispieldaten - -### Input -``` -Bestellnummer: "W123456" -``` - -### Output -```json -{ - "Bestellnummer": "W123456", - "Bestelldatum": "2026-04-06T14:30:00", - "Versanddatum": "2026-04-07", - "Vorname": "Max", - "Nachname": "Mustermann", - "Strasse": "Musterstrasse", - "Hausnummer": "123", - "PLZ": "8000", - "Stadt": "Zürich", - "Land": "Schweiz", - "Zahlungsart": "twint", - "Gesamtbetrag_netto": 85.00, - "Versandkosten": 5.90, - "Gesamtbetrag_brutto": 100.00, - "Rabatt": 0.00, - "#_ChagaFlaschen": 2, - "#_ReishiFlaschen": 1, - "#_ShiitakeFlaschen": 0, - "#_LionsManeFlaschen": 1 -} -``` - -## Fehlerbehandlung - -### Keine Bestellung gefunden -```json -{ - "Bestellnummer": "W123456", - "Bestelldatum": "", - "Versanddatum": "", - "Vorname": "", - "Nachname": "", - "Strasse": "", - "Hausnummer": "", - "PLZ": "", - "Stadt": "", - "Land": "", - "Zahlungsart": "", - "Gesamtbetrag_netto": 0, - "Versandkosten": 0, - "Gesamtbetrag_brutto": 0, - "Rabatt": 0, - "#_ChagaFlaschen": 0, - "#_ReishiFlaschen": 0, - "#_ShiitakeFlaschen": 0, - "#_LionsManeFlaschen": 0 -} -``` - -### Keine Produkt-Allokationen -- Alle Produktzählungen sind 0 -- Andere Felder sind normal gefüllt - -## Integration in n8n Flow - -### Flow-Ablauf -1. **Webhook Node**: Empfängt `Bestellnummer` vom ERP -2. **Postgres Node**: Führt diese Query mit `:bestellnummer = $json.Bestellnummer` aus -3. **Excel Node**: Schreibt das Ergebnis in die Excel-Datei -4. **Response Node**: Optional - Bestätigung senden - -### Query Parameters in n8n -``` -Query: (siehe oben) -Query Values: - bestellnummer: {{ $json.Bestellnummer }} -Operation: Select -``` - -## Technische Hinweise - -### Performance -- Query verwendet JOINs auf indizierten Spalten -- Gruppierung ist effizient für einzelne Bestellungen -- `COALESCE` verhindert NULL-Werte für Excel-Kompatibilität - -### Datum-Logik -- `shipping_date` wird automatisch via Trigger berechnet -- Wochenend-Logik: Freitag → Montag, Samstag → Montag, Sonntag → Montag -- Keine Feiertags-Logik in Phase 1 - -### Schema-Konsistenz -- Alle Tabellen existieren nach Migration 0007 -- `shipping_date` kann NULL sein (für sehr alte Bestellungen) -- Produkt-IDs sind statisch in der aktuellen DB - -## Testing - -### Test Query -```sql --- Test mit existierender Bestellung -SELECT * FROM sales_order WHERE external_ref LIKE 'W%' OR external_ref LIKE 'DIR-%' LIMIT 5; - --- Test Query mit konkreter Bestellnummer --- [Hier Query einfügen und :bestellnummer ersetzen] -``` - -### Expected Results -- Eine Zeile pro Bestellung -- Alle Felder gefüllt oder leere Strings/0 -- Produktzählungen als Ganzzahlen \ No newline at end of file diff --git a/docs/architektur/modulkarte.md b/docs/architektur/modulkarte.md new file mode 100644 index 0000000..39e3232 --- /dev/null +++ b/docs/architektur/modulkarte.md @@ -0,0 +1,115 @@ +# Modulkarte ERP Naurua +Stand: 2026-06-15 +Status: Architektur-Arbeitskarte + +## 1. Zweck + +Diese Modulkarte ordnet das System in klar getrennte fachliche Hauptmodule, Submodule und technische Querschnittsbereiche. Sie dient als Zielbild fuer die saubere Trennung von Ownership, Schnittstellen und wiederverwendbaren `shared`-Bausteinen. + +## 2. Leitlinien + +- Jedes Hauptmodul besitzt genau eine Primaerverantwortung. +- Submodule sind nur interne fachliche Zuschnitte innerhalb eines Hauptmoduls. +- `shared` enthaelt nur fachlich neutrale, wiederverwendbare Bausteine. +- `system` enthaelt nur technische Runtime-, Start-, Trigger- und Laufzeitlogik. +- Fachliche Logik bleibt immer im owning Modul. +- Cross-Modul-Zugriffe erfolgen nur ueber explizite Schnittstellen. + +## 3. Moduluebersicht + +| Modul | Typ | Submodule | Primaerverantwortung | Owned Daten / Artefakte | Writes | Reads | Public Interface | `shared`-Bedarf | +|---|---|---|---|---|---|---|---|---| +| Kontakte | Hauptmodul | Stammdaten, Adressen, Kommunikationsdaten | Kunden-, Lieferanten- und Kontaktstamm | `party`, `address`, `contact` | Kontaktstamm, Adressen, Kommunikationsdaten | Referenzen aus Bestellungen, Buchhaltung, Beratung | Kontakt-API, Such- und Lookup-Schnittstellen | Validierung, Formatierung, UI-Bausteine | +| Bestellungen | Hauptmodul | Bestellkopf, Positionen, Status | Operative Bestellverarbeitung | `sales_order`, `sales_order_line`, Statusdaten | Bestellanlage, Status, Zuordnung | Kontakte, Artikel-Mapping, Lager | Bestell-API, Status-API | Geld-/Datumsformat, Tabellen- und Status-Patterns | +| Lager | Hauptmodul | Chargen, Bewegungen, Bestandsfuehrung | Bestand, Charge, MHD, Bewegungen | `product`, `stock_lot`, `stock_move`, `v_stock_lot_balance` | Warenzugang, -abgang, Umlagerung, Chargenpflege | Bestellungen, Import, Direktverkauf | Lager-API, Bestands- und Chargen-Schnittstellen | Listen-, Detail- und Statusdarstellung | +| Artikel-Mapping | Hauptmodul | Externe Artikel, interne Artikel, Zuordnung | Aufloesung externer Shopdaten auf interne Artikel | `sellable_item`, `external_item_alias`, `sellable_item_component` | Aliaspflege, Zuordnung, Bundle-/Komponentenpflege | Bestellungen, Import | Mapping-API | Suchlogik, Tabellenmuster, Normalisierung | +| Import / Integration | Hauptmodul | Webhooks, Import-Routing, externe Adapter | Annahme und Verteilung externer Eingangsdaten | Integrationsdaten, Importzustand, technische Events | Importausloesung, technische Events | Externe Quellen, operative Kernmodule | Webhook- und Import-Schnittstellen | HTTP-nahe Bausteine, Parsing, technische Validierung | +| Direktverkauf | Hauptmodul | Tageserfassung, Sammelverkauf | Manuelle Erfassung von Direktverkaeufen | Direktverkaufsbelege, Erfassungsdaten | Direktverkauf, Ausloesung operativer Folgeschritte | Kontakte optional, Lager, Bestellungen | Direktverkaufs-UI und API | Erfassungsmuster, Formularbausteine | +| Buchhaltung | Hauptmodul | Debitoren, Kreditoren, Hauptbuch, Nebenbuecher | Finanzbuchhaltung, Kontierung, Abschluss, Steuerlogik | Buchungssaetze, Konten, OP-Positionen, Steuerdaten | Verbuchung, OP-Pflege, Abschlusslaeufe | Freigegebene ERP-Belege, Zahlungsdaten, Kontenplan | Buchhaltungs-API, Buchungsimport, Zahlungsabgleich | Geld-/Steuerformat, Listen, Prüf- und Statusmuster | +| Kundenberatung | Hauptmodul | Gespraechserfassung, Empfehlungen, Follow-ups | Beratungsfall, Bedarf, Empfehlung, Rueckmeldung | Beratungsgespräche, Empfehlungen, Rueckmeldungen | Gespraech, Notizen, Follow-ups | Kontakte, Produkte, Bestellungen | Beratungs-API, Fall- und Verlaufsschnittstellen | Formular- und Verlaufsmuster | +| `shared` | Technischer Bereich | UI, Helpers, technische Services | Fachlich neutrale Wiederverwendung | Wiederverwendbare technische Bausteine | Keine fachlichen Writes | Mehrere Module | Gemeinsame technische APIs und Komponenten | Kernbereich | +| `system` | Technischer Bereich | Runtime, Trigger, Scheduler, Supervisor | Technische Laufzeit und Ausfuehrungslogik | Jobs, Trigger, technische Laufzeiten | Systemnahe Laufzeitlogik | Technische Zustandsdaten | Systeminterne technische Schnittstellen | Laufzeit-, Job- und Triggerbausteine | + +## 4. Modulzuschnitt im Zielbild + +### 4.1 ERP-Kern + +Der ERP-Kern besteht aus: + +- Kontakte +- Bestellungen +- Lager +- Artikel-Mapping +- Import / Integration +- Direktverkauf + +Diese Module bilden die operative Kernverarbeitung. Sie duerfen Buchhaltung nur ueber klare Integrationsschnittstellen versorgen. + +### 4.2 Buchhaltungssystem + +Die Buchhaltung ist ein eigenes Hauptmodul mit eigener fachlicher Ownership. + +Es erhaelt aus dem ERP: + +- freigegebene Rechnungen oder Erlosereignisse +- Zahlungsinformationen +- relevante Debitoren-/Kreditoren-Referenzen +- Buchungsgrundlagen aus operativen Belegen + +Es fuehrt selbst: + +- Kontierung +- Hauptbuch +- Nebenbuecher +- OP-Verwaltung +- Zahlungslauf +- Mahnwesen +- Abschluss und Steuerlogik + +### 4.3 Kundenberatung + +Kundenberatung ist ein eigenes Hauptmodul fuer fall- und gespraechsbezogene Arbeit. + +Es erhaelt aus dem ERP: + +- Kundenstamm +- Kaufhistorie +- produktbezogene Referenzen + +Es fuehrt selbst: + +- Gespraechserfassung +- Bedarfserhebung +- Produktempfehlungen +- Rueckmeldungen +- Follow-up-Logik + +## 5. `shared`-Regeln fuer das Zielbild + +In `shared` gehoeren nur Bausteine, die fachlich neutral sind und in mehreren Modulen wiederverwendet werden koennen: + +- UI-Komponenten und Layout-Bausteine +- Tabellen, Formulare, Statusdarstellung +- Validierung ohne Fachentscheidungen +- Datums-, Geld- und Formatierungshelfer +- technische API-Clients +- Logging, Auth- und Session-nahe Hilfen +- generische Fehler- und Ladezustandsmuster + +Nicht in `shared` gehoeren: + +- Buchhaltungsregeln +- Lagerlogik +- Beratungslogik +- Bestellfachlogik +- modulpezifische Dateninterpretation + +## 6. Naechste Strukturierungsarbeit + +Aus dieser Modulkarte folgen als naechste Dokumente: + +1. Modulvertraege je Hauptmodul +2. Owned-DB-Register je Modul +3. Prozessvertraege fuer die Hauptprozesse je Modul +4. Liste der wiederverwendbaren `shared`-Bausteine + diff --git a/docs/architektur/technical_architecture.md b/docs/architektur/technical_architecture.md new file mode 100644 index 0000000..ad58713 --- /dev/null +++ b/docs/architektur/technical_architecture.md @@ -0,0 +1,239 @@ +# Technische Architektur + +Stand: 2026-04-08 +Status: Verbindlicher Architektur-Master +Dokumentklasse: normative + +## 1. Zweck und Geltung + +Dieses Dokument ist die verbindliche Architekturvorgabe fuer die Entwicklung hochmodularer Webportale in diesem Repository. Es steuert Analyse, Implementierung, Refactoring und Review durch Mensch und LLM. + +Es definiert verbindlich: + +- Modul-, Prozess-, Frontend-, API- und Datenbankgrenzen +- Ownership und oeffentliche Schnittstellen +- Standard-Scope fuer Analyse und Implementierung +- Wiederverwendungs- und Redundanzregeln +- Regeln fuer neue Module, Submodule und zentrale Bausteine + +Dieses Dokument ist kein Inventar konkreter Tabellen, Prozesse, Dateien oder Runtime-Artefakte. Solche Details liegen in den zustaendigen Modul-, Prozess- oder DB-Vertraegen. + +## 2. Architekturprinzipien + +### 2.1 Module + +Ein Modul ist ein fachlich oder technisch gekapselter Contract, nicht nur eine Verzeichnisstruktur. + +Verbindliche Regel: + +- jedes Modul besitzt genau eine klar abgegrenzte Primaerverantwortung +- jedes Modul kapselt seine Domain-Logik, Datenzugriffe, externen Schnittstellen, internen Datenstrukturen und Speicherdetails +- jedes Modul definiert eine explizite oeffentliche Schnittstelle fuer andere Module +- jedes Modul besitzt definierte Owned Artefakte und Schreibrechte +- interne Implementierungen anderer Module duerfen nicht direkt genutzt werden +- jede Modulgrenze muss als potenzielle spaetere Service-Grenze behandelbar bleiben + +### 2.2 Prozesse + +Ein Prozess ist ein sequenzieller Hauptablauf innerhalb genau eines owning Hauptmoduls. + +Verbindliche Regel: + +- ein Hauptmodul besitzt beliebig viele Hauptprozesse; jeder Hauptprozess gehoert genau einem Hauptmodul +- ein Hauptprozess darf Submodule desselben Hauptmoduls nutzen +- fachliche Hauptprozesse ueber Hauptmodulgrenzen hinweg sind nicht zulaessig +- Batching, Parallelisierung, Retry, Resume, Fehlerklassifikation, Statusermittlung und Ergebnisvertrag gehoeren in den owning Prozessvertrag +- zusaetzliche harte Schreib-, Feld- oder Datenbankschranken sind nur zulaessig, wenn sie explizit im Prozess- oder DB-Vertrag festgelegt sind + + + +### 2.3 Implementierung + +Module, Prozesse und Sub-Prozesse sind technisch so scharf wie moeglich zu trennen. + +Verbindliche Regel: + +- jedes Modul besitzt eine interne API oder Zugriffsschicht +- Cross-Modul-Kommunikation erfolgt nur ueber definierte Modul-Schnittstellen +- direkte Zugriffe auf interne Implementierungen oder Daten anderer Module sind verboten +- Module, Prozesse und Sub-Prozesse muessen getrennt entwickelbar, testbar, ausrollbar, ueberwachbar, debugbar und optimierbar sein +- technische Kopplung zwischen Prozessen und Sub-Prozessen ist auf das notwendige Minimum zu reduzieren +- Module duerfen keine impliziten Abhaengigkeiten besitzen, die eine spaetere Extraktion verhindern + +### 2.4 Frontend + +Das Frontend ist bewusst minimalistisch. Ziel ist eine kleine Zahl konsistenter, wiederverwendbarer UI-Bausteine statt vieler seiten- oder feature-spezifischer Sonderkomponenten. + +Verbindliche Regel: + +- Seiten und Views komponieren Daten, Layouts und Komponenten, erzeugen aber keine Fachlogik +- UI-Komponenten duerfen keine fachliche Primaerlogik, fachliche Dateninterpretation oder eigenstaendige Fachwahrheit besitzen +- fachliche UI-Komponenten, fachliche Darstellungsregeln und fachlich interpretierende UI-Logik gehoeren zum owning Fachmodul +- fachlich neutrale UI-Komponenten, Layout-Komponenten, Formatierungshelfer, Interaktionsmuster und Frontend-Utilities gehoeren nach `shared` +- neue oder geaenderte Frontend-Komponenten muessen zuerst gegen bestehende Komponenten, Patterns und `shared`-Bausteine geprueft werden +- Varianten bestehender Komponenten sind ueber Props, Konfiguration oder dokumentierte Erweiterungspunkte umzusetzen, nicht durch Copy-Paste +- Karten, Tabellen, Filter, Tabs, Buttons, Statusanzeigen, Ladezustaende, Empty States, Fehlermeldungen und Detailansichten sind als wiederverwendbare Patterns zu behandeln +- seitenlokale Sonderkomponenten sind nur zulaessig, wenn sie nachweislich nicht sinnvoll wiederverwendbar sind +- API-Responses duerfen frontendnah komponiert sein, aber keine Fachlogik aus dem owning Modul in API oder Frontend verschieben + +## 3. Modulmodell + +### 3.1 Zulaessige Modulbereiche + +Fachliche Hauptmodule: + +- `zu definieren + +Technische Modulbereiche: + +- `system` +- `shared` + +Verbindliche Regel: + +- fachliche Hauptmodule besitzen Business-Logik und fachliche Ownership +- `system` enthaelt nur technische Runtime-, Start-, Trigger- und Laufzeitlogik +- `shared` enthaelt nur fachlich neutrale, wiederverwendbare technische Bausteine +- konkrete Submodule, Prozesse, Artefakte und Schnittstellen werden in Modulvertraegen dokumentiert, nicht in diesem Architektur-Master + +### 3.2 `shared` + +`shared` ist der zentrale technische Querschnittsbereich. + +Verbindliche Regel: + +- `shared` darf keine Business-Logik, Fachwahrheit oder primaere fachliche Ownership besitzen +- `shared` darf Services, Komponenten, Helper, technische Schnittstellen, UI-nahe und API-nahe Bausteine bereitstellen +- ein Baustein gehoert nur nach `shared`, wenn er fachlich neutral ist und keine Verantwortung eines Fachmoduls uebernimmt +- fachliche Regeln, Dateninterpretation, Statuslogik und Entscheidungen bleiben im owning Fachmodul +- technische Querschnittslogik ohne fachlichen Inhalt muss in `shared` oder einem dort dokumentierten zentralen Baustein liegen, sobald Wiederverwendung ueber mehr als eine Stelle absehbar ist + +### 3.3 `admin` + +`admin` ist kein Hauptmodul und besitzt keine fachliche Ownership. + +Verbindliche Regel: + +- Admin-Funktionen sind nur Bedienoberflaechen auf bestehende Hauptmodule +- Admin-spezifische Fachlogik liegt im owning Hauptmodul +- ein separates fachliches Modul `admin` ist nicht zulaessig + +### 3.4 `public/api/` + +`public/api/` ist die HTTP-Service- und Kompositionsschicht des modularen Monolithen. + +Verbindliche Regel: + +- `public/api/` besitzt keine fachliche Primaerverantwortung +- `public/api/` darf Modul- oder Submodul-Schnittstellen fuer konkrete Frontend-User-Stories zusammensetzen +- `public/api/` bleibt duenn und enthaelt nur HTTP-nahe und technische Kompositionslogik +- fachliche Ownership darf nicht aus Modulen in `public/api/` wandern + +## 4. Datenbankgrenze + +Die Plattform nutzt aktuell genau eine gemeinsame Datenbank. Diese gemeinsame Datenbank hebt Modul- und Ownership-Grenzen nicht auf. + +Verbindliche Regel: + +- jede Tabelle, View, materialisierte View und DB-Funktion besitzt genau eine primaere Ownership +- primaere Ownership folgt genau einem Hauptmodul oder einem technischen Shared-Bereich +- nur das owning Modul definiert Struktur, Write-Logik, Constraints und fachliche Semantik eines DB-Artefakts +- direkte Cross-Modul-Writes in fremde Owned Tabellen sind verboten +- andere Module duerfen fremde Artefakte nur ueber definierte Schnittstellen des owning Moduls nutzen +- physische DB-Splits sind kein aktueller Standard und beduerfen einer expliziten Architekturentscheidung + +Operative DB-Referenz: + +`docs/architektur/database/module_db_ownership.md` + +Verbindliche Regel: + +- konkrete DB-Artefaktlisten gehoeren in die DB-Ownership-Dokumentation, nicht in diesen Architektur-Master +- bei Analyse, Refactoring und Implementierung wird der DB-Scope zuerst ueber Modul plus Owned DB-Artefakte bestimmt +- fremde DB-Artefakte duerfen nur bei deklarierter Leseabhaengigkeit, FK-Beziehung oder Integrationspruefung in den Scope aufgenommen werden + +## 5. Dokumentation und Lesescope + +Aktive Dokumentation ist komponentennah. + +Verbindliche Regel: + +- aktive Modul- und Submodul-Dokumentation liegt unter `docs/modules//...` +- aktive technische Shared-Dokumentation liegt unter `docs/modules/shared/...` +- technische Runtime-Dokumentation liegt unter `docs/modules/system/...` +- fachliche Hauptprozesse liegen innerhalb des owning Modulbaums +- historische oder abgeloeste Dokumentation liegt ausserhalb des aktiven Modulbaums +- `docs/architektur/technical_architecture.md` bleibt der einzige aktive Architektur-Master + +Standard-Lesescope: + +- Arbeit an einem Modul: `docs/architektur/technical_architecture.md` plus `docs/modules//...` +- Arbeit an einem Submodul: `docs/architektur/technical_architecture.md` plus `docs/modules///...` +- `docs/modules/shared/...`: nur bei betroffener Shared-Schnittstelle oder moeglicher Wiederverwendung +- `docs/modules/system/...`: nur bei Runtime-, Queue-, Scheduler-, Trigger- oder Supervisor-Themen +- andere Module: nur bei explizitem Integrationsbedarf + +## 6. Wiederverwendung und Redundanzverbot + +Vor jeder Aenderung an App, Frontend, UI-Komponenten, UI-Patterns, API, Prozesslogik, Datenzugriff, Infrastruktur oder Dokumentation ist Wiederverwendung vor Neuerstellung zu pruefen. + +Ziel ist eine Architektur ohne redundante Fachlogik, technische Inselloesungen, doppelte UI-Komponenten oder mehrfach gepflegte Varianten desselben Musters. + +Verbindliche Regel: + +- zuerst bestehende Muster, Komponenten, Services, Modul-Schnittstellen und `shared`-Bausteine pruefen +- vorhandene passende Muster muessen wiederverwendet, erweitert oder als zentrale Schnittstelle bereitgestellt werden +- fachliche Inhalte gehoeren immer in das owning Fachmodul und werden von dort ueber definierte Schnittstellen bereitgestellt +- fachliche Logik darf nicht in `shared`, `public/api/`, `admin`, Skripte oder fachfremde Bereiche ausgelagert oder dupliziert werden +- fachlich neutrale technische Logik gehoert nach `shared`, sofern sie wiederverwendbar ist oder Wiederverwendung absehbar ist +- Frontend-Darstellung, Interaktionslogik, Layout, Formatierung und Zustandsdarstellung sind als bestehende oder neue zentrale UI-Komponenten beziehungsweise UI-Patterns in `shared` zu pruefen +- neue `shared`-Bausteine sind nur zulaessig, wenn sie keine Fachwahrheit enthalten, keine fachliche Primaerverantwortung uebernehmen und wiederverwendbar sind +- technische Querschnittsbausteine duerfen Module entkoppeln, aber keine fachlichen Entscheidungen aus owning Modulen herausziehen +- neue Loesungen sind nur zulaessig, wenn kein passendes bestehendes Muster, keine passende Modul-Schnittstelle und kein geeigneter `shared`-Baustein existiert +- Quickfixes, Workarounds, Copy-Paste-Varianten, seitenlokale UI-Duplikate und isolierte Sonderlogik fuer Einzelstellen sind verboten +- Redundanzvermeidung muss bei Analyse, Umsetzung, Review und Refactoring aktiv nachgewiesen werden koennen + +## 7. Verbotene Strukturen + +Nicht zulaessig sind: + +- Module als reine Ordner ohne Contract +- direkte Cross-Modul-Zugriffe auf DB-Tabellen, interne Implementierungen oder Daten anderer Module +- versteckte oder implizite Modulabhaengigkeiten +- Vermischung mehrerer Primaerverantwortungen innerhalb eines Moduls +- parallele Fachlogik ausserhalb des owning Moduls +- redundante fachliche, technische oder UI-Loesungen, obwohl ein bestehendes Modul, eine Schnittstelle, ein Pattern oder ein `shared`-Baustein erweitert werden koennte +- neue generische Frameworks oder Orchestrator-Umbauten ohne explizite Architekturentscheidung +- implizite Schutz-, Schreib- oder Datenbankschranken ohne Prozess- oder DB-Vertrag + +## 8. Erweiterungsregeln + +Neue Hauptmodule, Submodule oder zentrale Bausteine duerfen nur eingefuehrt werden, wenn bestehende Strukturen die Verantwortung nicht sauber tragen koennen. + +Prueffragen vor jeder Erweiterung: + +1. Welches bestehende Hauptmodul ist primaer betroffen? +2. Reicht eine neue Datei, ein neues Submodul oder eine neue Schnittstelle im owning Hauptmodul aus? +3. Handelt es sich um fachlich neutrale Wiederverwendung, die nach `shared` gehoert? +4. Entsteht wirklich eine neue Primaerverantwortung mit eigenen Owned Artefakten und Schreibrechten? +5. Bleibt der Handshake zum Rest des Systems auf minimale Scope-Identifier begrenzt? +6. Reduziert der neue Schnitt Kopplung und Redundanz, statt sie zu erhoehen? + +Verbindliche Regel: + +- neue Hauptmodule sind nur zulaessig, wenn eine neue fachliche oder technische Primaerverantwortung entsteht, die nicht sauber in `company`, `ratings`, `groups`, `lists`, `system` oder `shared` aufgeht +- neue Submodule sind nur zulaessig, wenn innerhalb eines bestehenden Hauptmoduls eine klar isolierbare Verantwortung mit eigener Schnittstelle entsteht +- neue Prozess- und Sub-Prozess-Dokumente werden im owning Modulpfad angelegt und folgen `component.subcomponent.process_name` +- `admin`, `public/api/`, `scripts/` oder `lib/` begruenden kein neues fachliches Hauptmodul +- Bequemlichkeit, Dateigroesse oder kurzfristiger Umbauaufwand rechtfertigen kein neues Hauptmodul +- vor jeder normativen Architektur- oder Modulerweiterung muss dieses Dokument aktualisiert werden + +## 9. Aenderungsregel + +Jede strukturelle Aenderung an Hauptmodulen, Modulgrenzen, Prozesszuordnungen, Frontend-Prinzipien, API-Grenzen, DB-Ownership oder primaeren Ownership-Prinzipien muss zuerst in diesem Dokument normativ festgelegt und danach in Detaildokumenten nachgezogen werden. + +Verbindlich ist: + +- `docs/technical_architecture.md` ist der einzige aktive Architektur-Master des Repositories +- andere Dokumente duerfen diese Architektur erklaeren, anwenden oder historisieren, aber keine konkurrierende Master-Struktur definieren +- konkrete Inventare wie Tabellenlisten, Submodullisten, Prozesslisten oder Runtime-Artefakte werden in Detaildokumenten gepflegt und nicht hier dupliziert diff --git a/docs/CONCEPT.md b/docs/konzepte/Portal Grundkonzept.md similarity index 99% rename from docs/CONCEPT.md rename to docs/konzepte/Portal Grundkonzept.md index 11a5fe5..756513c 100644 --- a/docs/CONCEPT.md +++ b/docs/konzepte/Portal Grundkonzept.md @@ -52,7 +52,7 @@ Schritt 1 basiert auf drei Kern-Domaenen: Kernaussage: Eine Bestellung kann mehrere Positionen enthalten; jede Position kann einer oder mehreren Chargen zugeordnet werden; jede Charge hat Bewegungen und MHD-Status. -## System Overview +## System Overview / Module Schritt-1-Systemkomponenten: diff --git a/docs/konzepte/phase1-technisches-konzept.md b/docs/konzepte/phase1-technisches-konzept.md index bb0db0a..65e75b4 100644 --- a/docs/konzepte/phase1-technisches-konzept.md +++ b/docs/konzepte/phase1-technisches-konzept.md @@ -47,7 +47,7 @@ View: Prozesse: -- `n8n.bestell-eingang-online-shop` +- `n8n.bestell-eingang-online-shop`kzz - `order-import.php` - `db.trigger.sales_order` - `db.trigger.sales_order_line` diff --git a/includes/webhook_throttle.php b/includes/webhook_throttle.php new file mode 100644 index 0000000..eb063a0 --- /dev/null +++ b/includes/webhook_throttle.php @@ -0,0 +1,42 @@ + 0) { + usleep((int) ceil($waitSeconds * 1000000)); + } + + $sentAt = microtime(true); + rewind($handle); + ftruncate($handle, 0); + fwrite($handle, sprintf('%.6f', $sentAt)); + fflush($handle); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } +} diff --git a/n8n/exports/current/adressetikette-erstellen.g6FDHAICnQdbW6Ye.json b/n8n/exports/current/adressetikette-erstellen.g6FDHAICnQdbW6Ye.json deleted file mode 100644 index b649102..0000000 --- a/n8n/exports/current/adressetikette-erstellen.g6FDHAICnQdbW6Ye.json +++ /dev/null @@ -1,634 +0,0 @@ -{ - "updatedAt": "2026-03-29T19:20:11.222Z", - "createdAt": "2025-10-03T10:56:26.208Z", - "id": "g6FDHAICnQdbW6Ye", - "name": "Adressetikette erstellen", - "description": null, - "active": true, - "isArchived": false, - "nodes": [ - { - "parameters": { - "method": "POST", - "url": "http://192.168.1.199:9901/forms/chromium/convert/html", - "sendBody": true, - "contentType": "multipart-form-data", - "bodyParameters": { - "parameters": [ - { - "name": "Response Format", - "value": "File" - }, - { - "name": "Download File Name", - "value": "Versand-Label" - }, - { - "parameterType": "formBinaryData", - "name": "index", - "inputDataFieldName": "index" - }, - { - "parameterType": "formBinaryData", - "name": "styles", - "inputDataFieldName": "styles" - }, - { - "name": "preferCssPageSize", - "value": "true" - } - ] - }, - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1120, - 0 - ], - "id": "517816cc-fae4-482b-9b0b-7406c1057a3e", - "name": "Gotemberg PDF", - "retryOnFail": true, - "maxTries": 5 - }, - { - "parameters": { - "protocol": "sftp", - "operation": "upload", - "path": "={{ $json.filename }}", - "options": {} - }, - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, - "position": [ - 2000, - 16 - ], - "id": "3ea1d6ad-e706-4677-977a-c733c8e13085", - "name": "FTP", - "credentials": { - "sftp": { - "id": "cK8t7TPPZIynTdj7", - "name": "Naurua SFTP Account" - } - } - }, - { - "parameters": { - "method": "POST", - "url": "http://192.168.1.199:9902/convert", - "sendBody": true, - "contentType": "multipart-form-data", - "bodyParameters": { - "parameters": [ - { - "parameterType": "formBinaryData", - "name": "file", - "inputDataFieldName": "data" - } - ] - }, - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1376, - 0 - ], - "id": "b64e3c18-a06c-4c64-9c0b-11f0fe71e45c", - "name": "pdf2png" - }, - { - "parameters": { - "jsCode": "// --- Node-Namen anpassen, falls sie bei dir anders heißen ---\nconst ORDER_NODE = 'Bestellung laden';\nconst ADDRESS_NODE = 'Versandadresse laden';\n\n// kleine Helfer\nconst get = (fn, dflt = undefined) => {\n try { return fn(); } catch { return dflt; }\n};\nconst sanitize = (s) => String(s ?? '')\n .normalize('NFKD').replace(/[\\u0300-\\u036f]/g, '') // Akzente entfernen\n .replace(/[^A-Za-z0-9_-]+/g, '_') // nur sichere Zeichen\n .replace(/^_+|_+$/g, ''); // Trim underscores\n\n// Werte aus anderen Nodes holen (Fallback: aktuelles $json)\nconst createdAtRaw = get(() => $node[ORDER_NODE].json.CreatedAt, $json.CreatedAt);\nconst bestellnummer = get(() => $node[ORDER_NODE].json.Bestellnummer, $json.Bestellnummer);\nconst vorname = get(() => $node[ADDRESS_NODE].json.Vorname, $json.Vorname);\nconst nachname = get(() => $node[ADDRESS_NODE].json.Nachname, $json.Nachname);\n\n// Datum -> YYYYMMDD\nconst dt = createdAtRaw ? new Date(createdAtRaw) : new Date();\nconst yyyymmdd = dt.toISOString().slice(0,10).replace(/-/g, '');\n\n// Dateiname bauen\nconst filename = `${yyyymmdd}_${sanitize(bestellnummer)}_${sanitize(vorname)}_${sanitize(nachname)}.png`;\n\n// Binary-Key ermitteln (meist \"data\")\nconst binKeys = Object.keys($binary || {});\nconst binKey = binKeys[0] || 'data';\n\n// Item zurückgeben, Binary-Dateiname überschreiben\nreturn {\n json: { filename },\n binary: {\n ...$binary,\n [binKey]: { ...$binary[binKey], fileName: filename }\n }\n};" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 1600, - 112 - ], - "id": "9f55cde9-8743-4ea8-be0e-c07991e4cf96", - "name": "png umbenennen" - }, - { - "parameters": { - "jsCode": "// INPUT: item.json mit Feldern (Vorname, Nachname, …)\n// OUTPUT: binary.index (HTML), binary.styles (CSS)\n\n// — Layout-Parameter —\nconst WIDTH_MM = 60, HEIGHT_MM = 50;\nconst MARGIN = { top: 10, right: 5, bottom: 5, left: 10 };\nconst FONT_FAMILY = \"AvenirCustom\"; // frei wählbar, unten in @font-face + body benutzen\nconst FONT_SIZE_PT = 11;\nconst LINE_HEIGHT = 1; // realistisch, 0.2 wäre praktisch ohne Zeilenabstand\n\n// — Font-Pfad im Gotenberg-Container (über docker-compose gemountet) —\nconst FONT_PATH = \"file:///usr/local/fonts/avenir-regular.woff2\";\n\nconst d = $json;\n\nconst html = `\n\n \n \n\n
\n
An
\n
${d.Vorname || \"\"} ${d.Nachname || \"\"}
\n
${d.Strasse || \"\"} ${d.Hausnummer || \"\"}
\n
${d.PLZ || \"\"} ${d.Stadt || \"\"}
\n ${d.Land ? `
${d.Land}
` : ``}\n
\n`;\n\nconst css = `\n@page { \n size: ${WIDTH_MM}mm ${HEIGHT_MM}mm; \n margin: 0; \n}\n\n@font-face {\n font-family: \"${FONT_FAMILY}\";\n src: url(\"${FONT_PATH}\") format(\"woff2\");\n font-weight: normal;\n font-style: normal;\n font-display: swap;\n}\n\n* { box-sizing: border-box; }\n\nhtml, body { \n margin: 0; \n height: 100%; \n}\n\n.label {\n width: 100%;\n height: 100%;\n padding: ${MARGIN.top}mm ${MARGIN.right}mm ${MARGIN.bottom}mm ${MARGIN.left}mm;\n font-family: \"${FONT_FAMILY}\", Arial, Helvetica, sans-serif;\n font-size: ${FONT_SIZE_PT}pt;\n line-height: ${LINE_HEIGHT};\n overflow: hidden; /* Verhindert 2. Seite */\n page-break-after: avoid;\n break-after: avoid-page;\n}\n\n.line { margin: 0 0 4mm 0; }\n.line:last-child { margin-bottom: 0; }\n`;\n\nreturn [{\n json: {},\n binary: {\n index: { data: Buffer.from(html, 'utf8').toString('base64'), fileName: 'index.html', mimeType: 'text/html' },\n styles: { data: Buffer.from(css, 'utf8').toString('base64'), fileName: 'styles.css', mimeType: 'text/css' },\n }\n}];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 896, - 0 - ], - "id": "153cc657-86e7-4207-a0b2-4bb6f44f4f4d", - "name": "Layout HTML und CSS erzeugen" - }, - { - "parameters": { - "mode": "combine", - "combineBy": "combineByPosition", - "options": {} - }, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 1792, - 16 - ], - "id": "66ec492e-0b2e-43d1-acb3-0d808c6e91cf", - "name": "Merge" - }, - { - "parameters": { - "path": "naurua_erp_adressetikette", - "authentication": "none", - "options": {}, - "httpMethod": "POST" - }, - "type": "n8n-nodes-base.webhook", - "typeVersion": 2.1, - "position": [ - 528, - 0 - ], - "id": "cf7b0436-e77b-4b10-924c-e4cd911046c2", - "name": "Webhook", - "webhookId": "1c0f4e40-d5a7-4145-8692-65bdf08d3b35" - }, - { - "parameters": { - "jsCode": "// Normalize webhook payload into the exact address contract expected by the label layout node.\nconst raw = $json.body ?? $json;\nconst src = Array.isArray(raw) ? (raw[0] ?? {}) : raw;\n\nreturn [{\n json: {\n Vorname: src.Vorname ?? src.Vorname_LfAdr ?? src.Vorname_LfAdr1 ?? \"\",\n Nachname: src.Nachname ?? src.Nachname_LfAdr ?? src.Nachname_LfAdr1 ?? \"\",\n Strasse: src.Strasse ?? src.Strasse_LfAdr ?? \"\",\n Hausnummer: src.Hausnummer ?? src.Hausnummer_LfAdr ?? \"\",\n PLZ: src.PLZ ?? src.PLZ_LfAdr ?? \"\",\n Stadt: src.Stadt ?? src.Stadt_LfAdr ?? \"\",\n Land: src.Land ?? src.Land_LfAdr ?? \"\",\n }\n}];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 704, - 0 - ], - "id": "28d7547e-dfb0-4904-ab43-b410b0c4e2b0", - "name": "Adresse an Label-Format anpassen" - }, - { - "parameters": { - "content": "## Shipping Label Generation\nThis workflow receives a shipping address via webhook, normalizes field names, renders HTML/CSS, creates a PDF/PNG label, and uploads it via FTP.", - "height": 180, - "width": 640 - }, - "type": "n8n-nodes-base.stickyNote", - "position": [ - 512, - -256 - ], - "typeVersion": 1, - "id": "fdb30bcb-e656-45c8-ad99-a27f4887c525", - "name": "Sticky Note - Overview" - }, - { - "parameters": { - "content": "## Expected Webhook Input Fields\nPreferred shipping keys: Vorname_LfAdr, Nachname_LfAdr, Strasse_LfAdr, Hausnummer_LfAdr, PLZ_LfAdr, Stadt_LfAdr, Land_LfAdr.\nFallback keys also accepted: Vorname, Nachname, Strasse, Hausnummer, PLZ, Stadt, Land.", - "height": 220, - "width": 640, - "color": 5 - }, - "type": "n8n-nodes-base.stickyNote", - "position": [ - 512, - -24 - ], - "typeVersion": 1, - "id": "947d919c-b976-41fa-bfc7-70fbf383ed53", - "name": "Sticky Note - Inputs" - } - ], - "connections": { - "Gotemberg PDF": { - "main": [ - [ - { - "node": "pdf2png", - "type": "main", - "index": 0 - } - ] - ] - }, - "pdf2png": { - "main": [ - [ - { - "node": "png umbenennen", - "type": "main", - "index": 0 - }, - { - "node": "Merge", - "type": "main", - "index": 0 - } - ] - ] - }, - "Layout HTML und CSS erzeugen": { - "main": [ - [ - { - "node": "Gotemberg PDF", - "type": "main", - "index": 0 - } - ] - ] - }, - "png umbenennen": { - "main": [ - [ - { - "node": "Merge", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge": { - "main": [ - [ - { - "node": "FTP", - "type": "main", - "index": 0 - } - ] - ] - }, - "Webhook": { - "main": [ - [ - { - "node": "Adresse an Label-Format anpassen", - "type": "main", - "index": 0 - } - ] - ] - }, - "Adresse an Label-Format anpassen": { - "main": [ - [ - { - "node": "Layout HTML und CSS erzeugen", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "settings": { - "executionOrder": "v1", - "callerPolicy": "workflowsFromSameOwner", - "errorWorkflow": "QQ1KFafAxgMOjKWm", - "availableInMCP": false - }, - "staticData": null, - "meta": { - "templateCredsSetupCompleted": true - }, - "pinData": {}, - "versionId": "ae81cbfa-9001-4fcf-a032-659f68804899", - "activeVersionId": "ae81cbfa-9001-4fcf-a032-659f68804899", - "versionCounter": 52, - "triggerCount": 1, - "shared": [ - { - "updatedAt": "2025-10-03T10:56:26.266Z", - "createdAt": "2025-10-03T10:56:26.266Z", - "role": "workflow:owner", - "workflowId": "g6FDHAICnQdbW6Ye", - "projectId": "loIw8cF8XKYX00Ow", - "project": { - "updatedAt": "2025-06-07T09:04:27.150Z", - "createdAt": "2025-06-07T06:22:39.698Z", - "id": "loIw8cF8XKYX00Ow", - "name": "Mathias Gläser ", - "type": "personal", - "icon": null, - "description": null, - "creatorId": "f82ed6a8-4704-4f80-8617-622fd5911d56" - } - } - ], - "tags": [], - "activeVersion": { - "updatedAt": "2026-03-29T19:20:11.224Z", - "createdAt": "2026-03-29T19:20:11.224Z", - "versionId": "ae81cbfa-9001-4fcf-a032-659f68804899", - "workflowId": "g6FDHAICnQdbW6Ye", - "nodes": [ - { - "parameters": { - "method": "POST", - "url": "http://192.168.1.199:9901/forms/chromium/convert/html", - "sendBody": true, - "contentType": "multipart-form-data", - "bodyParameters": { - "parameters": [ - { - "name": "Response Format", - "value": "File" - }, - { - "name": "Download File Name", - "value": "Versand-Label" - }, - { - "parameterType": "formBinaryData", - "name": "index", - "inputDataFieldName": "index" - }, - { - "parameterType": "formBinaryData", - "name": "styles", - "inputDataFieldName": "styles" - }, - { - "name": "preferCssPageSize", - "value": "true" - } - ] - }, - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1120, - 0 - ], - "id": "517816cc-fae4-482b-9b0b-7406c1057a3e", - "name": "Gotemberg PDF", - "retryOnFail": true, - "maxTries": 5 - }, - { - "parameters": { - "protocol": "sftp", - "operation": "upload", - "path": "={{ $json.filename }}", - "options": {} - }, - "type": "n8n-nodes-base.ftp", - "typeVersion": 1, - "position": [ - 2000, - 16 - ], - "id": "3ea1d6ad-e706-4677-977a-c733c8e13085", - "name": "FTP", - "credentials": { - "sftp": { - "id": "cK8t7TPPZIynTdj7", - "name": "Naurua SFTP Account" - } - } - }, - { - "parameters": { - "method": "POST", - "url": "http://192.168.1.199:9902/convert", - "sendBody": true, - "contentType": "multipart-form-data", - "bodyParameters": { - "parameters": [ - { - "parameterType": "formBinaryData", - "name": "file", - "inputDataFieldName": "data" - } - ] - }, - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1376, - 0 - ], - "id": "b64e3c18-a06c-4c64-9c0b-11f0fe71e45c", - "name": "pdf2png" - }, - { - "parameters": { - "jsCode": "// --- Node-Namen anpassen, falls sie bei dir anders heißen ---\nconst ORDER_NODE = 'Bestellung laden';\nconst ADDRESS_NODE = 'Versandadresse laden';\n\n// kleine Helfer\nconst get = (fn, dflt = undefined) => {\n try { return fn(); } catch { return dflt; }\n};\nconst sanitize = (s) => String(s ?? '')\n .normalize('NFKD').replace(/[\\u0300-\\u036f]/g, '') // Akzente entfernen\n .replace(/[^A-Za-z0-9_-]+/g, '_') // nur sichere Zeichen\n .replace(/^_+|_+$/g, ''); // Trim underscores\n\n// Werte aus anderen Nodes holen (Fallback: aktuelles $json)\nconst createdAtRaw = get(() => $node[ORDER_NODE].json.CreatedAt, $json.CreatedAt);\nconst bestellnummer = get(() => $node[ORDER_NODE].json.Bestellnummer, $json.Bestellnummer);\nconst vorname = get(() => $node[ADDRESS_NODE].json.Vorname, $json.Vorname);\nconst nachname = get(() => $node[ADDRESS_NODE].json.Nachname, $json.Nachname);\n\n// Datum -> YYYYMMDD\nconst dt = createdAtRaw ? new Date(createdAtRaw) : new Date();\nconst yyyymmdd = dt.toISOString().slice(0,10).replace(/-/g, '');\n\n// Dateiname bauen\nconst filename = `${yyyymmdd}_${sanitize(bestellnummer)}_${sanitize(vorname)}_${sanitize(nachname)}.png`;\n\n// Binary-Key ermitteln (meist \"data\")\nconst binKeys = Object.keys($binary || {});\nconst binKey = binKeys[0] || 'data';\n\n// Item zurückgeben, Binary-Dateiname überschreiben\nreturn {\n json: { filename },\n binary: {\n ...$binary,\n [binKey]: { ...$binary[binKey], fileName: filename }\n }\n};" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 1600, - 112 - ], - "id": "9f55cde9-8743-4ea8-be0e-c07991e4cf96", - "name": "png umbenennen" - }, - { - "parameters": { - "jsCode": "// INPUT: item.json mit Feldern (Vorname, Nachname, …)\n// OUTPUT: binary.index (HTML), binary.styles (CSS)\n\n// — Layout-Parameter —\nconst WIDTH_MM = 60, HEIGHT_MM = 50;\nconst MARGIN = { top: 10, right: 5, bottom: 5, left: 10 };\nconst FONT_FAMILY = \"AvenirCustom\"; // frei wählbar, unten in @font-face + body benutzen\nconst FONT_SIZE_PT = 11;\nconst LINE_HEIGHT = 1; // realistisch, 0.2 wäre praktisch ohne Zeilenabstand\n\n// — Font-Pfad im Gotenberg-Container (über docker-compose gemountet) —\nconst FONT_PATH = \"file:///usr/local/fonts/avenir-regular.woff2\";\n\nconst d = $json;\n\nconst html = `\n\n \n \n\n
\n
An
\n
${d.Vorname || \"\"} ${d.Nachname || \"\"}
\n
${d.Strasse || \"\"} ${d.Hausnummer || \"\"}
\n
${d.PLZ || \"\"} ${d.Stadt || \"\"}
\n ${d.Land ? `
${d.Land}
` : ``}\n
\n`;\n\nconst css = `\n@page { \n size: ${WIDTH_MM}mm ${HEIGHT_MM}mm; \n margin: 0; \n}\n\n@font-face {\n font-family: \"${FONT_FAMILY}\";\n src: url(\"${FONT_PATH}\") format(\"woff2\");\n font-weight: normal;\n font-style: normal;\n font-display: swap;\n}\n\n* { box-sizing: border-box; }\n\nhtml, body { \n margin: 0; \n height: 100%; \n}\n\n.label {\n width: 100%;\n height: 100%;\n padding: ${MARGIN.top}mm ${MARGIN.right}mm ${MARGIN.bottom}mm ${MARGIN.left}mm;\n font-family: \"${FONT_FAMILY}\", Arial, Helvetica, sans-serif;\n font-size: ${FONT_SIZE_PT}pt;\n line-height: ${LINE_HEIGHT};\n overflow: hidden; /* Verhindert 2. Seite */\n page-break-after: avoid;\n break-after: avoid-page;\n}\n\n.line { margin: 0 0 4mm 0; }\n.line:last-child { margin-bottom: 0; }\n`;\n\nreturn [{\n json: {},\n binary: {\n index: { data: Buffer.from(html, 'utf8').toString('base64'), fileName: 'index.html', mimeType: 'text/html' },\n styles: { data: Buffer.from(css, 'utf8').toString('base64'), fileName: 'styles.css', mimeType: 'text/css' },\n }\n}];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 896, - 0 - ], - "id": "153cc657-86e7-4207-a0b2-4bb6f44f4f4d", - "name": "Layout HTML und CSS erzeugen" - }, - { - "parameters": { - "mode": "combine", - "combineBy": "combineByPosition", - "options": {} - }, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 1792, - 16 - ], - "id": "66ec492e-0b2e-43d1-acb3-0d808c6e91cf", - "name": "Merge" - }, - { - "parameters": { - "path": "naurua_erp_adressetikette", - "authentication": "none", - "options": {}, - "httpMethod": "POST" - }, - "type": "n8n-nodes-base.webhook", - "typeVersion": 2.1, - "position": [ - 528, - 0 - ], - "id": "cf7b0436-e77b-4b10-924c-e4cd911046c2", - "name": "Webhook", - "webhookId": "1c0f4e40-d5a7-4145-8692-65bdf08d3b35" - }, - { - "parameters": { - "jsCode": "// Normalize webhook payload into the exact address contract expected by the label layout node.\nconst raw = $json.body ?? $json;\nconst src = Array.isArray(raw) ? (raw[0] ?? {}) : raw;\n\nreturn [{\n json: {\n Vorname: src.Vorname ?? src.Vorname_LfAdr ?? src.Vorname_LfAdr1 ?? \"\",\n Nachname: src.Nachname ?? src.Nachname_LfAdr ?? src.Nachname_LfAdr1 ?? \"\",\n Strasse: src.Strasse ?? src.Strasse_LfAdr ?? \"\",\n Hausnummer: src.Hausnummer ?? src.Hausnummer_LfAdr ?? \"\",\n PLZ: src.PLZ ?? src.PLZ_LfAdr ?? \"\",\n Stadt: src.Stadt ?? src.Stadt_LfAdr ?? \"\",\n Land: src.Land ?? src.Land_LfAdr ?? \"\",\n }\n}];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 704, - 0 - ], - "id": "28d7547e-dfb0-4904-ab43-b410b0c4e2b0", - "name": "Adresse an Label-Format anpassen" - }, - { - "parameters": { - "content": "## Shipping Label Generation\nThis workflow receives a shipping address via webhook, normalizes field names, renders HTML/CSS, creates a PDF/PNG label, and uploads it via FTP.", - "height": 180, - "width": 640 - }, - "type": "n8n-nodes-base.stickyNote", - "position": [ - 512, - -256 - ], - "typeVersion": 1, - "id": "fdb30bcb-e656-45c8-ad99-a27f4887c525", - "name": "Sticky Note - Overview" - }, - { - "parameters": { - "content": "## Expected Webhook Input Fields\nPreferred shipping keys: Vorname_LfAdr, Nachname_LfAdr, Strasse_LfAdr, Hausnummer_LfAdr, PLZ_LfAdr, Stadt_LfAdr, Land_LfAdr.\nFallback keys also accepted: Vorname, Nachname, Strasse, Hausnummer, PLZ, Stadt, Land.", - "height": 220, - "width": 640, - "color": 5 - }, - "type": "n8n-nodes-base.stickyNote", - "position": [ - 512, - -24 - ], - "typeVersion": 1, - "id": "947d919c-b976-41fa-bfc7-70fbf383ed53", - "name": "Sticky Note - Inputs" - } - ], - "connections": { - "Gotemberg PDF": { - "main": [ - [ - { - "node": "pdf2png", - "type": "main", - "index": 0 - } - ] - ] - }, - "pdf2png": { - "main": [ - [ - { - "node": "png umbenennen", - "type": "main", - "index": 0 - }, - { - "node": "Merge", - "type": "main", - "index": 0 - } - ] - ] - }, - "Layout HTML und CSS erzeugen": { - "main": [ - [ - { - "node": "Gotemberg PDF", - "type": "main", - "index": 0 - } - ] - ] - }, - "png umbenennen": { - "main": [ - [ - { - "node": "Merge", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge": { - "main": [ - [ - { - "node": "FTP", - "type": "main", - "index": 0 - } - ] - ] - }, - "Webhook": { - "main": [ - [ - { - "node": "Adresse an Label-Format anpassen", - "type": "main", - "index": 0 - } - ] - ] - }, - "Adresse an Label-Format anpassen": { - "main": [ - [ - { - "node": "Layout HTML und CSS erzeugen", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "authors": "Mathias Gläser", - "name": null, - "description": null, - "autosaved": false, - "workflowPublishHistory": [ - { - "createdAt": "2026-03-29T19:20:11.338Z", - "id": 150, - "workflowId": "g6FDHAICnQdbW6Ye", - "versionId": "ae81cbfa-9001-4fcf-a032-659f68804899", - "event": "activated", - "userId": "f82ed6a8-4704-4f80-8617-622fd5911d56" - }, - { - "createdAt": "2026-03-29T19:20:11.314Z", - "id": 149, - "workflowId": "g6FDHAICnQdbW6Ye", - "versionId": "ae81cbfa-9001-4fcf-a032-659f68804899", - "event": "deactivated", - "userId": "f82ed6a8-4704-4f80-8617-622fd5911d56" - } - ] - } -} diff --git a/n8n/exports/current/bestell-eingang-online-shop.yNLjtV9yG0T6CqSr.json b/n8n/exports/current/bestell-eingang-online-shop.yNLjtV9yG0T6CqSr.json deleted file mode 100644 index 39c7d28..0000000 --- a/n8n/exports/current/bestell-eingang-online-shop.yNLjtV9yG0T6CqSr.json +++ /dev/null @@ -1,580 +0,0 @@ -{ - "updatedAt": "2026-03-29T18:33:43.163Z", - "createdAt": "2025-07-05T06:04:09.291Z", - "id": "yNLjtV9yG0T6CqSr", - "name": "Bestell-Eingang Online-Shop", - "description": null, - "active": true, - "isArchived": false, - "nodes": [ - { - "parameters": { - "format": "resolved", - "options": {} - }, - "type": "n8n-nodes-base.emailReadImap", - "typeVersion": 2, - "position": [ - -272, - -768 - ], - "id": "def6f3b1-b241-4e6c-89b1-63e4a0c12258", - "name": "Email Trigger (IMAP)", - "credentials": { - "imap": { - "id": "5jzK2kuSVJ8NWL1D", - "name": "IMAP account 4" - } - } - }, - { - "parameters": { - "content": "Eingang ist Bestellung", - "height": 520, - "width": 860 - }, - "type": "n8n-nodes-base.stickyNote", - "position": [ - 80, - -960 - ], - "typeVersion": 1, - "id": "cec90477-28bf-45fd-a220-72edb7e20297", - "name": "Sticky Note" - }, - { - "parameters": { - "modelId": { - "__rl": true, - "value": "deepseek/deepseek-v3.2", - "mode": "list", - "cachedResultName": "DEEPSEEK/DEEPSEEK-V3.2" - }, - "messages": { - "values": [ - { - "content": "=Your input is this HTML-Mail: {{ $json.html }}\nYou are a JSON extractor for our order-processing pipeline.\nGiven the raw email HTML/text, you must output exactly one JSON object matching our schema, with these keys (in any order):\n\n*Section Bestellinformationen*\nBestellungNr, Zahlungsstatus, Zahlungsmethode, \n*Section Zahlungsinformationen*\nVorname_RgAdr, Nachname_RgAdr, Strasse_RgAdr, Hausnummer_RgAdr, Stadt_RgAdr, Bundesland_RgAdr, PLZ_RgAdr, Land_RgAdr, EmailKunde, \n*Section Lieferinformationen*\nVorname_LfAdr, Nachname_LfAdr, Strasse_LfAdr, Hausnummer_LfAdr, Stadt_LfAdr, Bundesland_LfAdr, PLZ_LfAdr, Land_LfAdr,\n*Section Versandmethode\"\nLiefermethode\n\n*Section Bestelldetails*\nEach lineItems entry must be an object with exactly these keys: \ntitel (Name of the item), artikelnummer, preisEinheit (CHF Price per lineitem divided by #Items), artikelanzahl (Anzahl)\n*Bottom-Sections*\nNetto, Versandkosten, Mehrwertsteuer, Rabatt (add to total if several Rabatte exist in the section), Rabattcode (is the name of the rabatt in the left column on the line of the rabatt sum) Gesamtsumme, \n\n\nImportant: \n- Output only the JSON object (no surrounding array, no code fences, no explanations). \n- Use string values for all text fields and numbers for all numeric fields. \n- If a field is missing or empty, output an empty string (\"\") for text or 0 for numbers.\n\nExtract the order details from the given raw email (HTML or text) and respond with only the JSON object below—no code fences, no markdown, no surrounding array, no keys like index or message. It must match exactly this schema (ordering doesn’t matter):\n\n{\n \"BestellungNr\": string,\n \"Zahlungsstatus\": string,\n \"Zahlungsmethode\": string,\n \"Vorname_RgAdr\": string,\n \"Nachname_RgAdr\": string,\n \"Strasse_RgAdr\": string,\n \"Hausnummer_RgAdr\": string,\n \"Stadt_RgAdr\": string,\n \"Bundesland_RgAdr\": string,\n \"PLZ_RgAdr\": string,\n \"Land_RgAdr\": string,\n \"EmailKunde\": string,\n \"Vorname_LfAdr\": string,\n \"Nachname_LfAdr\": string,\n \"Strasse_LfAdr\": string,\n \"Hausnummer_LfAdr\": string,\n \"Stadt_LfAdr\": string,\n \"Bundesland_LfAdr\": string,\n \"PLZ_LfAdr\": string,\n \"Land_LfAdr\": string,\n \"Liefermethode\": string,\n \"Netto\": number,\n \"Versandkosten\": number,\n \"Mehrwertsteuer\": number,\n \"Rabatt\": number,\n \"Gesamtsumme\": number,\n\"Rabattcode\": string\n \"lineItems\": [\n {\n \"titel\": string,\n \"artikelnummer\": string,\n \"preisEinheit\": number,\n \"artikelanzahl\": number\n }\n // repeat for each line item\n ]\n}" - } - ] - }, - "options": {} - }, - "type": "@n8n/n8n-nodes-langchain.openAi", - "typeVersion": 1.8, - "position": [ - 144, - -784 - ], - "id": "7ab009e6-5c24-48cb-ad34-28886cfec035", - "name": "Message a model", - "retryOnFail": true, - "waitBetweenTries": 5000, - "credentials": { - "openAiApi": { - "id": "CQ31lEApDEhhVATP", - "name": "OpenAi OpenRouter" - } - } - }, - { - "parameters": { - "jsCode": "// n8n Code-Node – parse AI-Agent output into a clean JSON object\nreturn items.map(item => {\n // 1) Roh-String aus dem AI-Step ziehen\n let content = item.json.message?.content\n || item.json.content\n || '';\n \n // 2) Code-Fence und Markdown-Marker entfernen\n content = content\n .replace(/```json\\s*/g, '')\n .replace(/```/g, '')\n .trim();\n \n // 3) Falls der Agent eine Array-Antwort (z.B. “[ { … } ]”) liefert,\n // das Array parsen und das erste Objekt herausgreifen\n if (content.startsWith('[') && content.endsWith(']')) {\n let arr;\n try {\n arr = JSON.parse(content);\n } catch {\n throw new Error(`Kann AI-Array nicht parsen:\\n${content}`);\n }\n if (!Array.isArray(arr) || arr.length === 0) {\n throw new Error(`Erwartetes Array mit mindestens einem Element, bekam:\\n${content}`);\n }\n // Wenn das Array schon Deine Order-Objekte enthält (kein wrapper), nimm arr[0]\n // Andernfalls, falls es das n8n-wrapper-Format ist, grabe tiefer:\n content = JSON.stringify(\n typeof arr[0].message?.content === 'string'\n // falls das erste Element wieder ein wrapper-Objekt mit .message.content ist:\n ? JSON.parse(arr[0].message.content.replace(/```/g, '').trim())\n // sonst direkt das erste Element\n : arr[0]\n );\n }\n \n // 4) Jetzt das finale JSON parsen\n let data;\n try {\n data = JSON.parse(content);\n } catch (e) {\n throw new Error(`Failed to JSON.parse AI output:\\n${content}`);\n }\n \n // 5) Als neues Item zurückgeben\n return { json: data };\n});" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 480, - -784 - ], - "id": "a199695a-ed3f-4b16-b1c2-0dc5bf143fd8", - "name": "JSON Parsen1" - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "strict", - "version": 3 - }, - "conditions": [ - { - "id": "dbe1657a-046d-44a2-a602-588bb6e2e83a", - "leftValue": "={{ $json.headers.subject }}", - "rightValue": "Neue Bestellung", - "operator": { - "type": "string", - "operation": "contains" - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "type": "n8n-nodes-base.if", - "typeVersion": 2.3, - "position": [ - -64, - -768 - ], - "id": "8c16451e-288d-4348-b04e-d072a133bc8f", - "name": "If" - }, - { - "parameters": { - "method": "POST", - "url": "https://erpnaurua.imhochrain.ch/order-import.php", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Content-Type", - "value": "application/json" - }, - { - "name": "X-Webhook-Secret", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmODJlZDZhOC00NzA0LTRmODAtODYxNy02MjJmZDU5MTFkNTYiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiw" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ $json }}", - "options": { - "response": { - "response": { - "responseFormat": "json" - } - } - } - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 736, - -784 - ], - "id": "5c81fac4-8ca3-471a-a546-0e73995446a5", - "name": "Bestellung an ERP senden" - } - ], - "connections": { - "Email Trigger (IMAP)": { - "main": [ - [ - { - "node": "If", - "type": "main", - "index": 0 - } - ] - ] - }, - "Message a model": { - "main": [ - [ - { - "node": "JSON Parsen1", - "type": "main", - "index": 0 - } - ] - ] - }, - "JSON Parsen1": { - "main": [ - [ - { - "node": "Bestellung an ERP senden", - "type": "main", - "index": 0 - } - ] - ] - }, - "If": { - "main": [ - [ - { - "node": "Message a model", - "type": "main", - "index": 0 - } - ] - ] - }, - "Bestellung an ERP senden": { - "main": [ - [] - ] - } - }, - "settings": { - "executionOrder": "v1", - "callerPolicy": "workflowsFromSameOwner", - "errorWorkflow": "QQ1KFafAxgMOjKWm", - "availableInMCP": false - }, - "staticData": { - "node:Email Trigger (IMAP)": {}, - "node:Email Trigger (IMAP)1": {} - }, - "meta": { - "templateCredsSetupCompleted": true - }, - "pinData": { - "Email Trigger (IMAP)": [ - { - "json": { - "headers": { - "return-path": "Return-Path: ", - "x-original-to": "X-Original-To: n8n_naurua_bestelleingang@imhochrain.ch", - "delivered-to": "Delivered-To: n8n_naurua_bestelleingang@imhochrain.ch", - "received-spf": "Received-SPF: pass (bounces.wixemails.com: Sender is authorized to use 'msprvs1=20545RoK9oTDl=bounces-17751-1668086@bounces.wixemails.com' in 'mfrom' identity (mechanism 'exists:%{i}._spf.sparkpostmail.com' matched)) receiver=hz_nas2; identity=mailfrom; envelope-from=\"msprvs1=20545RoK9oTDl=bounces-17751-1668086@bounces.wixemails.com\"; helo=mta-70-25-9.wix-shared.com.sparkpostmail.com; client-ip=156.70.25.9", - "authentication-results": "Authentication-Results: imhochrain.ch;\r\n\tdkim=pass (1024-bit key) header.d=stores-emails.com header.i=@stores-emails.com header.b=jsFBHei7", - "received": "Received: from [10.90.46.161] ([10.90.46.161])\r\n\tby i-043224e4cc422583f.mta1vrest.sd.prd.sparkpost (ecelerity 5.2.0.75340 r(msys-ecelerity:tags/5.2.0.8)) with REST\r\n\tid 27/8B-10073-3CDF4C96; Thu, 26 Mar 2026 09:34:59 +0000", - "x-msfbl": "X-MSFBL: I5/PwAFR1Omzb1UxJL4Y250GcVn79Tr/9VF5IMMBVfg=|eyJzdWJhY2NvdW50X2l\r\n\tkIjoiMTY2ODA4NiIsIm1lc3NhZ2VfaWQiOiI2OWMzYzNmZGM0Njk2NzMxMzg3MiI\r\n\tsImN1c3RvbWVyX2lkIjoiMTc3NTEiLCJyIjoibjhuX25hdXJ1YV9iZXN0ZWxsZWl\r\n\tuZ2FuZ0BpbWhvY2hyYWluLmNoIiwidGVuYW50X2lkIjoic3BjIn0=", - "dkim-signature": "DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=stores-emails.com;\r\n\ts=scph0326; t=1774517699; i=@stores-emails.com;\r\n\tbh=/4WZe0NPBZ4rRG9eBwLVkQNU0JfwTneiOpmKK/Ov38E=;\r\n\th=To:Message-ID:Date:Content-Type:List-Unsubscribe-Post:Subject:\r\n\t List-Unsubscribe:From:From:To:Cc:Subject;\r\n\tb=jsFBHei7R1LKcqpF2tE8f6vPPWTYlaIOtolOZtySbszRdJW+rWb+fS5RKfAoGWTsJ\r\n\t rBD4mmIjiGNHTdF+jAyPIevNXVG8ybRw7cXk8ASx8dc0IbRVP0V5C7ZfwXZGUi0MWA\r\n\t 3iWth0ouQNtVcG/fcOlN7cPUtUHqkwppHGClMZOk=", - "to": "To: n8n_naurua_bestelleingang@imhochrain.ch", - "message-id": "Message-ID: <27.8B.10073.3CDF4C96@i-043224e4cc422583f.mta1vrest.sd.prd.sparkpost>", - "date": "Date: Thu, 26 Mar 2026 09:34:59 +0000", - "content-type": "Content-Type: multipart/alternative; boundary=\"_----myAB/yV9XX69ShxBXnnVhA===_F6/8B-10073-3CDF4C96\"", - "mime-version": "MIME-Version: 1.0", - "reply-to": "Reply-To: naurua.ch@gmail.com", - "list-unsubscribe-post": "List-Unsubscribe-Post: List-Unsubscribe=One-Click", - "subject": "Subject: Neue Bestellung (#10466)", - "list-unsubscribe": "List-Unsubscribe: ", - "from": "From: \"Naurua GmbH\" ", - "x-abuse-id": "x-abuse-id: a052a266-8283-493d-8e9f-88a82683af01", - "precedence": "Precedence: Bulk", - "feedback-id": "Feedback-ID: a052a266-8283-493d-8e9f-88a82683af01:6b1c6765-7f6f-45df-b6ab-df8f41b90345:wixshoutout", - "x-mailscanner-id": "X-MailScanner-ID: 211D7339758.A58F0", - "x-mailscanner": "X-MailScanner: Found to be clean", - "x-mailscanner-from": "X-MailScanner-From: msprvs1=20545rok9otdl=bounces-17751-1668086@bounces.wixemails.com", - "x-spam-status": "X-Spam-Status: No" - }, - "html": "\n
\"\"/
\n
Auf deiner Website wurde eine Bestellung aufgegeben.‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ 
Die Nachricht ist nicht sichtbar? Im Browser ansehen

Neue Bestellung

Auf deiner Website wurde eine Bestellung aufgegeben.

Bestellung #10466 

Bestellinformationen

Bestellung #10466 

Datum:

26. März 2026 um 10:34 MEZ

Betrag:

CHF 54.90

Zahlungsstatus: bezahlt

Zahlungsmethode:

Kredit-/Debitkarten

Zahlungsinformationen

Holger Hannemann prakt. Naturarzt

Bahnhofplatz 11
9100 Herisau
Schweiz
 

Herisau, CH-AR, CH

9100 

mail@hannemann.ch 

Lieferinformationen

Holger Hannemann prakt. Naturarzt 

 

Bahnhofplatz 11 

 

Herisau CH-AR CH 

9100 

 

Versandmethode

shipping

Versand mit Die Schweizerische Post (1-3 Werktage)

Bestelldetails

Chaga Extrakt Tinktur 50 ml | 100% rein

Artikelnummer: 001.01

Preis: CHF 49.95

Anzahl: 1 

CHF 49.95

Zwischensumme

CHF 49.95

Versand

CHF 4.95

MwSt.

 CHF 0.00

Gesamtsumme

CHF 54.90

Diese E-Mail wurde von dieser Website gesendet.
Falls der Erhalt dieser E-Mails nicht erwünscht ist, bitte die E-Mail-Einstellungen hier ändern.
\n\"\"\n", - "text": "Neue Bestellung\n\nAuf deiner Website wurde eine Bestellung aufgegeben.\n\nBestellung #10466  \n\nBestellinformationen\n\nBestelldetails\n\nKlicke auf den Link unten, um die Nachricht in einem Browser zu öffnen.\nhttps://naurua.ch/so/tr/3078e2f5-3b52-4dd2-8e38-a47eb2dda7a1\n\nDiese E-Mail wird an Abonnenten dieser Website gesendet.\nhttps://naurua.ch/so/tr/3078e2f5-3b52-4dd2-8e38-a47eb2dda7a1/c?w=z1-nY5tvv5xMH793kk24PFyY_tHVXU7Gwpm6yyIShio.eyJ1IjoiaHR0cHM6Ly93d3cubmF1cnVhLmNoLyIsImMiOiI1NTBmZmMxOC0zNGFmLTM5NDItYWI2ZS1lNDQ0NzlmMTAyOWUiLCJtIjoibWFpbCIsInJpIjoiNTE2Y2ZhZmUtZjAzNC00ZmFhLTkxODAtOWJlNTBjOGFkNDA4IiwicnQiOiJVc2VyIn0\n\nZum Abbestellen dieser E-Mail bitte hier klicken:\nhttps://manage.wix.com/_manage-preferences?token=JWE.eyJhbGciOiJBMTI4S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2Iiwia2lkIjoiYWRxdVA4WE8ifQ.UasP5dRUUJPkYW9s2DC7voh72ZmK2LcfJUSzSNCWt-uRdeD5GNc1aQ.ZDbXuCWhzUmDNxY5ILrM-A.DE7HSGUgMAJv9mVDO26k7bZcJyw2GBqhEPJaauGQDOy9vipoZINC8kIcATm7xvHf7cE18cZhmdQFPfzkjcGfhbD_VLhcfe7P6yOmkFLd4e0bHPEtKLE9I3bZUd7N-0d2Li7pMQzgHKgM6rVcHmyeOv-LT-zLYxtSJLKOmHHZuZMBvQ5GbTkyrDep8TwfgdlZrd8T0kXgAEzuOyh8dMs1PpR53I2a4_m3vISSlK3gs3Zcu5ZaTdFi4JXwQzqpt9YwJvHZmpYhvtR1QcGWlbDSRVFpyNVVqod0V7euzbQ9wqBFI0yvMDMt70Wpy5i-aOw8l0tOPkLAK6i_itUQIYWdfGGeExkfKw_IxzESMDz8cJ3A4gcxdRWopw1QkQvd0lyVqhqYGjOicCnYdM0O5l5YA1qOPnNcf3Wk-1NUNLBwd00.KrDqqqX_KEPspf0jWDGb8Q&locale=de", - "textAsHtml": "

Neue Bestellung

Auf deiner Website wurde eine Bestellung aufgegeben.

Bestellung #10466  

Bestellinformationen

Bestelldetails

Klicke auf den Link unten, um die Nachricht in einem Browser zu öffnen.
https://naurua.ch/so/tr/3078e2f5-3b52-4dd2-8e38-a47eb2dda7a1

Diese E-Mail wird an Abonnenten dieser Website gesendet.
https://naurua.ch/so/tr/3078e2f5-3b52-4dd2-8e38-a47eb2dda7a1/c?w=z1-nY5tvv5xMH793kk24PFyY_tHVXU7Gwpm6yyIShio.eyJ1IjoiaHR0cHM6Ly93d3cubmF1cnVhLmNoLyIsImMiOiI1NTBmZmMxOC0zNGFmLTM5NDItYWI2ZS1lNDQ0NzlmMTAyOWUiLCJtIjoibWFpbCIsInJpIjoiNTE2Y2ZhZmUtZjAzNC00ZmFhLTkxODAtOWJlNTBjOGFkNDA4IiwicnQiOiJVc2VyIn0

Zum Abbestellen dieser E-Mail bitte hier klicken:
https://manage.wix.com/_manage-preferences?token=JWE.eyJhbGciOiJBMTI4S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2Iiwia2lkIjoiYWRxdVA4WE8ifQ.UasP5dRUUJPkYW9s2DC7voh72ZmK2LcfJUSzSNCWt-uRdeD5GNc1aQ.ZDbXuCWhzUmDNxY5ILrM-A.DE7HSGUgMAJv9mVDO26k7bZcJyw2GBqhEPJaauGQDOy9vipoZINC8kIcATm7xvHf7cE18cZhmdQFPfzkjcGfhbD_VLhcfe7P6yOmkFLd4e0bHPEtKLE9I3bZUd7N-0d2Li7pMQzgHKgM6rVcHmyeOv-LT-zLYxtSJLKOmHHZuZMBvQ5GbTkyrDep8TwfgdlZrd8T0kXgAEzuOyh8dMs1PpR53I2a4_m3vISSlK3gs3Zcu5ZaTdFi4JXwQzqpt9YwJvHZmpYhvtR1QcGWlbDSRVFpyNVVqod0V7euzbQ9wqBFI0yvMDMt70Wpy5i-aOw8l0tOPkLAK6i_itUQIYWdfGGeExkfKw_IxzESMDz8cJ3A4gcxdRWopw1QkQvd0lyVqhqYGjOicCnYdM0O5l5YA1qOPnNcf3Wk-1NUNLBwd00.KrDqqqX_KEPspf0jWDGb8Q&locale=de

", - "subject": "Neue Bestellung (#10466)", - "date": "2026-03-26T09:34:59.000Z", - "to": { - "value": [ - { - "address": "n8n_naurua_bestelleingang@imhochrain.ch", - "name": "" - } - ], - "html": "n8n_naurua_bestelleingang@imhochrain.ch", - "text": "n8n_naurua_bestelleingang@imhochrain.ch" - }, - "from": { - "value": [ - { - "address": "naurua.ch@stores-emails.com", - "name": "Naurua GmbH" - } - ], - "html": "Naurua GmbH <naurua.ch@stores-emails.com>", - "text": "\"Naurua GmbH\" " - }, - "messageId": "<27.8B.10073.3CDF4C96@i-043224e4cc422583f.mta1vrest.sd.prd.sparkpost>", - "replyTo": { - "value": [ - { - "address": "naurua.ch@gmail.com", - "name": "" - } - ], - "html": "naurua.ch@gmail.com", - "text": "naurua.ch@gmail.com" - }, - "attributes": { - "uid": 266 - } - }, - "pairedItem": { - "item": 0 - } - } - ], - "Message a model": [ - { - "json": { - "index": 0, - "logprobs": null, - "finish_reason": "stop", - "native_finish_reason": "stop", - "message": { - "role": "assistant", - "content": "{\n \"BestellungNr\": \"10466\",\n \"Zahlungsstatus\": \"bezahlt\",\n \"Zahlungsmethode\": \"Kredit-/Debitkarten\",\n \"Vorname_RgAdr\": \"Holger\",\n \"Nachname_RgAdr\": \"Hannemann prakt. Naturarzt\",\n \"Strasse_RgAdr\": \"Bahnhofplatz\",\n \"Hausnummer_RgAdr\": \"11\",\n \"Stadt_RgAdr\": \"Herisau\",\n \"Bundesland_RgAdr\": \"CH-AR\",\n \"PLZ_RgAdr\": \"9100\",\n \"Land_RgAdr\": \"Schweiz\",\n \"EmailKunde\": \"mail@hannemann.ch\",\n \"Vorname_LfAdr\": \"Holger\",\n \"Nachname_LfAdr\": \"Hannemann prakt. Naturarzt\",\n \"Strasse_LfAdr\": \"Bahnhofplatz\",\n \"Hausnummer_LfAdr\": \"11\",\n \"Stadt_LfAdr\": \"Herisau\",\n \"Bundesland_LfAdr\": \"CH-AR\",\n \"PLZ_LfAdr\": \"9100\",\n \"Land_LfAdr\": \"CH\",\n \"Liefermethode\": \"Versand mit Die Schweizerische Post (1-3 Werktage)\",\n \"Netto\": 49.95,\n \"Versandkosten\": 4.95,\n \"Mehrwertsteuer\": 0.0,\n \"Rabatt\": 0.0,\n \"Rabattcode\": \"\",\n \"Gesamtsumme\": 54.90,\n \"lineItems\": [\n {\n \"titel\": \"Chaga Extrakt Tinktur 50 ml | 100% rein\",\n \"artikelnummer\": \"001.01\",\n \"preisEinheit\": 49.95,\n \"artikelanzahl\": 1\n }\n ]\n}", - "refusal": null, - "reasoning": null - } - }, - "pairedItem": { - "item": 0 - } - } - ] - }, - "versionId": "0d0f1eec-82c8-4b6e-9770-bbbeca45a905", - "activeVersionId": "0d0f1eec-82c8-4b6e-9770-bbbeca45a905", - "versionCounter": 221, - "triggerCount": 1, - "shared": [ - { - "updatedAt": "2025-07-05T06:04:09.312Z", - "createdAt": "2025-07-05T06:04:09.312Z", - "role": "workflow:owner", - "workflowId": "yNLjtV9yG0T6CqSr", - "projectId": "loIw8cF8XKYX00Ow", - "project": { - "updatedAt": "2025-06-07T09:04:27.150Z", - "createdAt": "2025-06-07T06:22:39.698Z", - "id": "loIw8cF8XKYX00Ow", - "name": "Mathias Gläser ", - "type": "personal", - "icon": null, - "description": null, - "creatorId": "f82ed6a8-4704-4f80-8617-622fd5911d56" - } - } - ], - "tags": [], - "activeVersion": { - "updatedAt": "2026-03-29T18:33:43.165Z", - "createdAt": "2026-03-29T18:33:43.165Z", - "versionId": "0d0f1eec-82c8-4b6e-9770-bbbeca45a905", - "workflowId": "yNLjtV9yG0T6CqSr", - "nodes": [ - { - "parameters": { - "format": "resolved", - "options": {} - }, - "type": "n8n-nodes-base.emailReadImap", - "typeVersion": 2, - "position": [ - -272, - -768 - ], - "id": "def6f3b1-b241-4e6c-89b1-63e4a0c12258", - "name": "Email Trigger (IMAP)", - "credentials": { - "imap": { - "id": "5jzK2kuSVJ8NWL1D", - "name": "IMAP account 4" - } - } - }, - { - "parameters": { - "content": "Eingang ist Bestellung", - "height": 520, - "width": 860 - }, - "type": "n8n-nodes-base.stickyNote", - "position": [ - 80, - -960 - ], - "typeVersion": 1, - "id": "cec90477-28bf-45fd-a220-72edb7e20297", - "name": "Sticky Note" - }, - { - "parameters": { - "modelId": { - "__rl": true, - "value": "deepseek/deepseek-v3.2", - "mode": "list", - "cachedResultName": "DEEPSEEK/DEEPSEEK-V3.2" - }, - "messages": { - "values": [ - { - "content": "=Your input is this HTML-Mail: {{ $json.html }}\nYou are a JSON extractor for our order-processing pipeline.\nGiven the raw email HTML/text, you must output exactly one JSON object matching our schema, with these keys (in any order):\n\n*Section Bestellinformationen*\nBestellungNr, Zahlungsstatus, Zahlungsmethode, \n*Section Zahlungsinformationen*\nVorname_RgAdr, Nachname_RgAdr, Strasse_RgAdr, Hausnummer_RgAdr, Stadt_RgAdr, Bundesland_RgAdr, PLZ_RgAdr, Land_RgAdr, EmailKunde, \n*Section Lieferinformationen*\nVorname_LfAdr, Nachname_LfAdr, Strasse_LfAdr, Hausnummer_LfAdr, Stadt_LfAdr, Bundesland_LfAdr, PLZ_LfAdr, Land_LfAdr,\n*Section Versandmethode\"\nLiefermethode\n\n*Section Bestelldetails*\nEach lineItems entry must be an object with exactly these keys: \ntitel (Name of the item), artikelnummer, preisEinheit (CHF Price per lineitem divided by #Items), artikelanzahl (Anzahl)\n*Bottom-Sections*\nNetto, Versandkosten, Mehrwertsteuer, Rabatt (add to total if several Rabatte exist in the section), Rabattcode (is the name of the rabatt in the left column on the line of the rabatt sum) Gesamtsumme, \n\n\nImportant: \n- Output only the JSON object (no surrounding array, no code fences, no explanations). \n- Use string values for all text fields and numbers for all numeric fields. \n- If a field is missing or empty, output an empty string (\"\") for text or 0 for numbers.\n\nExtract the order details from the given raw email (HTML or text) and respond with only the JSON object below—no code fences, no markdown, no surrounding array, no keys like index or message. It must match exactly this schema (ordering doesn’t matter):\n\n{\n \"BestellungNr\": string,\n \"Zahlungsstatus\": string,\n \"Zahlungsmethode\": string,\n \"Vorname_RgAdr\": string,\n \"Nachname_RgAdr\": string,\n \"Strasse_RgAdr\": string,\n \"Hausnummer_RgAdr\": string,\n \"Stadt_RgAdr\": string,\n \"Bundesland_RgAdr\": string,\n \"PLZ_RgAdr\": string,\n \"Land_RgAdr\": string,\n \"EmailKunde\": string,\n \"Vorname_LfAdr\": string,\n \"Nachname_LfAdr\": string,\n \"Strasse_LfAdr\": string,\n \"Hausnummer_LfAdr\": string,\n \"Stadt_LfAdr\": string,\n \"Bundesland_LfAdr\": string,\n \"PLZ_LfAdr\": string,\n \"Land_LfAdr\": string,\n \"Liefermethode\": string,\n \"Netto\": number,\n \"Versandkosten\": number,\n \"Mehrwertsteuer\": number,\n \"Rabatt\": number,\n \"Gesamtsumme\": number,\n\"Rabattcode\": string\n \"lineItems\": [\n {\n \"titel\": string,\n \"artikelnummer\": string,\n \"preisEinheit\": number,\n \"artikelanzahl\": number\n }\n // repeat for each line item\n ]\n}" - } - ] - }, - "options": {} - }, - "type": "@n8n/n8n-nodes-langchain.openAi", - "typeVersion": 1.8, - "position": [ - 144, - -784 - ], - "id": "7ab009e6-5c24-48cb-ad34-28886cfec035", - "name": "Message a model", - "retryOnFail": true, - "waitBetweenTries": 5000, - "credentials": { - "openAiApi": { - "id": "CQ31lEApDEhhVATP", - "name": "OpenAi OpenRouter" - } - } - }, - { - "parameters": { - "jsCode": "// n8n Code-Node – parse AI-Agent output into a clean JSON object\nreturn items.map(item => {\n // 1) Roh-String aus dem AI-Step ziehen\n let content = item.json.message?.content\n || item.json.content\n || '';\n \n // 2) Code-Fence und Markdown-Marker entfernen\n content = content\n .replace(/```json\\s*/g, '')\n .replace(/```/g, '')\n .trim();\n \n // 3) Falls der Agent eine Array-Antwort (z.B. “[ { … } ]”) liefert,\n // das Array parsen und das erste Objekt herausgreifen\n if (content.startsWith('[') && content.endsWith(']')) {\n let arr;\n try {\n arr = JSON.parse(content);\n } catch {\n throw new Error(`Kann AI-Array nicht parsen:\\n${content}`);\n }\n if (!Array.isArray(arr) || arr.length === 0) {\n throw new Error(`Erwartetes Array mit mindestens einem Element, bekam:\\n${content}`);\n }\n // Wenn das Array schon Deine Order-Objekte enthält (kein wrapper), nimm arr[0]\n // Andernfalls, falls es das n8n-wrapper-Format ist, grabe tiefer:\n content = JSON.stringify(\n typeof arr[0].message?.content === 'string'\n // falls das erste Element wieder ein wrapper-Objekt mit .message.content ist:\n ? JSON.parse(arr[0].message.content.replace(/```/g, '').trim())\n // sonst direkt das erste Element\n : arr[0]\n );\n }\n \n // 4) Jetzt das finale JSON parsen\n let data;\n try {\n data = JSON.parse(content);\n } catch (e) {\n throw new Error(`Failed to JSON.parse AI output:\\n${content}`);\n }\n \n // 5) Als neues Item zurückgeben\n return { json: data };\n});" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 480, - -784 - ], - "id": "a199695a-ed3f-4b16-b1c2-0dc5bf143fd8", - "name": "JSON Parsen1" - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "strict", - "version": 3 - }, - "conditions": [ - { - "id": "dbe1657a-046d-44a2-a602-588bb6e2e83a", - "leftValue": "={{ $json.headers.subject }}", - "rightValue": "Neue Bestellung", - "operator": { - "type": "string", - "operation": "contains" - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "type": "n8n-nodes-base.if", - "typeVersion": 2.3, - "position": [ - -64, - -768 - ], - "id": "8c16451e-288d-4348-b04e-d072a133bc8f", - "name": "If" - }, - { - "parameters": { - "method": "POST", - "url": "https://erpnaurua.imhochrain.ch/order-import.php", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Content-Type", - "value": "application/json" - }, - { - "name": "X-Webhook-Secret", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmODJlZDZhOC00NzA0LTRmODAtODYxNy02MjJmZDU5MTFkNTYiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiw" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ $json }}", - "options": { - "response": { - "response": { - "responseFormat": "json" - } - } - } - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 736, - -784 - ], - "id": "5c81fac4-8ca3-471a-a546-0e73995446a5", - "name": "Bestellung an ERP senden" - } - ], - "connections": { - "Email Trigger (IMAP)": { - "main": [ - [ - { - "node": "If", - "type": "main", - "index": 0 - } - ] - ] - }, - "Message a model": { - "main": [ - [ - { - "node": "JSON Parsen1", - "type": "main", - "index": 0 - } - ] - ] - }, - "JSON Parsen1": { - "main": [ - [ - { - "node": "Bestellung an ERP senden", - "type": "main", - "index": 0 - } - ] - ] - }, - "If": { - "main": [ - [ - { - "node": "Message a model", - "type": "main", - "index": 0 - } - ] - ] - }, - "Bestellung an ERP senden": { - "main": [ - [] - ] - } - }, - "authors": "Mathias Gläser", - "name": null, - "description": null, - "autosaved": false, - "workflowPublishHistory": [ - { - "createdAt": "2026-03-29T18:33:43.293Z", - "id": 144, - "workflowId": "yNLjtV9yG0T6CqSr", - "versionId": "0d0f1eec-82c8-4b6e-9770-bbbeca45a905", - "event": "activated", - "userId": "f82ed6a8-4704-4f80-8617-622fd5911d56" - }, - { - "createdAt": "2026-03-29T18:33:43.221Z", - "id": 143, - "workflowId": "yNLjtV9yG0T6CqSr", - "versionId": "0d0f1eec-82c8-4b6e-9770-bbbeca45a905", - "event": "deactivated", - "userId": "f82ed6a8-4704-4f80-8617-622fd5911d56" - } - ] - } -} diff --git a/n8n/exports/index.json b/n8n/exports/index.json deleted file mode 100644 index f06c31b..0000000 --- a/n8n/exports/index.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "exported_at": "2026-03-29T19:20:34Z", - "source": "n8n", - "workflows": [ - { - "id": "yNLjtV9yG0T6CqSr", - "name": "Bestell-Eingang Online-Shop", - "file": "n8n/exports/current/bestell-eingang-online-shop.yNLjtV9yG0T6CqSr.json" - }, - { - "id": "g6FDHAICnQdbW6Ye", - "name": "Adressetikette erstellen", - "file": "n8n/exports/current/adressetikette-erstellen.g6FDHAICnQdbW6Ye.json" - } - ] -} diff --git a/order-import.php b/order-import.php index a9d673a..11200bc 100644 --- a/order-import.php +++ b/order-import.php @@ -1,6 +1,8 @@ false, 'ok' => false, - 'message' => 'Excel webhook URL not configured or incorrect (must contain "excel_befuellen")', + 'message' => 'Excel webhook URL not configured', ]; } - - // SQL Query for all Excel data (without comments for n8n compatibility) - $sql = " - SELECT - so.external_ref AS \"Bestellnummer\", - TO_CHAR(so.order_date, 'YYYY-MM-DD\"T\"HH24:MI:SS') AS \"Bestelldatum\", - TO_CHAR(so.shipping_date, 'YYYY-MM-DD') AS \"Versanddatum\", - COALESCE(ad.first_name, '') AS \"Vorname\", - COALESCE(ad.last_name, '') AS \"Nachname\", - COALESCE(ad.street, '') AS \"Strasse\", - COALESCE(ad.house_number, '') AS \"Hausnummer\", - COALESCE(ad.zip, '') AS \"PLZ\", - COALESCE(ad.city, '') AS \"Stadt\", - COALESCE(ad.country_name, '') AS \"Land\", - COALESCE(pm.code, '') AS \"Zahlungsart\", - COALESCE(so.amount_net, 0) AS \"Gesamtbetrag_netto\", - COALESCE(so.amount_shipping, 0) AS \"Versandkosten\", - COALESCE(so.total_amount, 0) AS \"Gesamtbetrag_brutto\", - COALESCE(so.amount_discount, 0) AS \"Rabatt\", - COALESCE(SUM(CASE WHEN p.id = 8 THEN a.qty ELSE 0 END), 0) AS \"#_ChagaFlaschen\", - COALESCE(SUM(CASE WHEN p.id = 5 THEN a.qty ELSE 0 END), 0) AS \"#_ReishiFlaschen\", - COALESCE(SUM(CASE WHEN p.id = 9 THEN a.qty ELSE 0 END), 0) AS \"#_ShiitakeFlaschen\", - COALESCE(SUM(CASE WHEN p.id = 6 THEN a.qty ELSE 0 END), 0) AS \"#_LionsManeFlaschen\" - FROM sales_order so - LEFT JOIN address ad ON so.party_id = ad.party_id AND ad.type = 'shipping' - LEFT JOIN payment_method pm ON so.payment_method_id = pm.id - LEFT JOIN sales_order_line sol ON so.id = sol.sales_order_id - LEFT JOIN sales_order_line_lot_allocation a ON sol.id = a.sales_order_line_id - LEFT JOIN product p ON a.product_id = p.id AND p.status = 'active' - WHERE so.id = :order_id - GROUP BY so.id, ad.id, pm.id - "; - - $stmt = $pdo->prepare($sql); - $stmt->execute([':order_id' => $orderId]); - $data = $stmt->fetch(); - - if (!$data) { - return [ - 'enabled' => true, - 'ok' => false, - 'message' => 'Order data not found or no product allocations', - ]; - } - - // Authentication headers (same mechanism as shipping_label_flow) + + $payload = [ + 'Bestellnummer' => $externalRef, + ]; + $headers = []; $secret = env_value('N8N_WEBHOOK_SECRET', $localEnv); if ($secret !== '') { @@ -412,9 +398,10 @@ function trigger_excel_webhook(PDO $pdo, int $orderId, array $localEnv): array $headers['X-API-Key'] = $secret; $headers['Authorization'] = 'Bearer ' . $secret; } - - $result = post_json($url, $data, $headers, 20); - + + throttle_webhook_channel('excel', 10); + $result = post_json($url, $payload, $headers, 20); + return [ 'enabled' => true, 'ok' => $result['ok'], @@ -1597,7 +1584,7 @@ try { $pdo->commit(); $labelTrigger = trigger_shipping_label_flow($data, $env); - $excelTrigger = trigger_excel_webhook($pdo, $orderId, $env); + $excelTrigger = trigger_excel_webhook($externalRef, $env); json_response(200, [ 'ok' => true, diff --git a/public/api/otc-order.php b/public/api/otc-order.php index eea3cb3..7821660 100644 --- a/public/api/otc-order.php +++ b/public/api/otc-order.php @@ -2,9 +2,436 @@ declare(strict_types=1); require_once __DIR__ . '/../../includes/db.php'; +require_once __DIR__ . '/../../includes/webhook_throttle.php'; header('Content-Type: application/json; charset=utf-8'); +function derive_excel_webhook_url(array $localEnv): string +{ + $explicit = env_value('N8N_EXCEL_WEBHOOK_URL', $localEnv); + if ($explicit !== '') { + return $explicit; + } + + $legacy = env_value('N8N_OUTBOUND_URL_ADRESSE', $localEnv); + if ($legacy !== '' && str_contains(strtolower($legacy), 'excel_befuellen')) { + return $legacy; + } + + $base = env_value('N8N_BASE_URL', $localEnv); + if ($base === '') { + return ''; + } + + $root = preg_replace('#/api/v1/?$#', '', rtrim($base, '/')); + if (!is_string($root) || $root === '') { + return ''; + } + + return $root . '/webhook/excel_befuellen'; +} + +function post_json(string $url, array $payload, array $headers = [], int $timeoutSeconds = 15): array +{ + $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($body === false) { + return ['ok' => false, 'status' => 0, 'body' => '', 'error' => 'Could not encode payload']; + } + + $headerLines = ['Content-Type: application/json']; + foreach ($headers as $name => $value) { + if ($name === '' || $value === '') { + continue; + } + $headerLines[] = $name . ': ' . $value; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headerLines), + 'content' => $body, + 'timeout' => $timeoutSeconds, + 'ignore_errors' => true, + ], + ]); + + $responseBody = @file_get_contents($url, false, $context); + $responseHeaders = $http_response_header ?? []; + + $status = 0; + if (isset($responseHeaders[0]) && preg_match('#HTTP/\S+\s+(\d{3})#', $responseHeaders[0], $m) === 1) { + $status = (int) $m[1]; + } + + if ($responseBody === false) { + $responseBody = ''; + } + + return [ + 'ok' => $status >= 200 && $status < 300, + 'status' => $status, + 'body' => substr($responseBody, 0, 500), + 'error' => ($status === 0) ? 'Request failed or timed out' : '', + ]; +} + +function trigger_excel_webhook(string $externalRef, array $localEnv): array +{ + $url = derive_excel_webhook_url($localEnv); + if ($url === '') { + return [ + 'enabled' => false, + 'ok' => false, + 'message' => 'Excel webhook URL not configured', + ]; + } + + $headers = []; + $secret = env_value('N8N_WEBHOOK_SECRET', $localEnv); + if ($secret !== '') { + $headers['X-Webhook-Secret'] = $secret; + $headers['X-N8N-Secret'] = $secret; + $headers['X-API-Key'] = $secret; + $headers['Authorization'] = 'Bearer ' . $secret; + } + + throttle_webhook_channel('excel', 10); + $result = post_json($url, ['Bestellnummer' => $externalRef], $headers, 20); + + return [ + 'enabled' => true, + 'ok' => $result['ok'], + 'status' => $result['status'], + 'url' => $url, + 'message' => $result['ok'] ? 'Excel webhook triggered' : ($result['error'] !== '' ? $result['error'] : 'Excel webhook returned non-2xx'), + 'responseBody' => $result['body'], + ]; +} + +function normalize_match_key(string $value): string +{ + $value = trim(mb_strtolower($value, 'UTF-8')); + if ($value === '') { + return ''; + } + + if (function_exists('iconv')) { + $transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); + if ($transliterated !== false) { + $value = $transliterated; + } + } + + $value = preg_replace('/[^a-z0-9]+/', ' ', $value) ?? ''; + return trim($value); +} + +function detect_product_family_key(string $normalizedName): ?string +{ + if (str_contains($normalizedName, 'lion') && str_contains($normalizedName, 'mane')) { + return 'lionsmane'; + } + if (str_contains($normalizedName, 'chaga')) { + return 'chaga'; + } + if (str_contains($normalizedName, 'reishi')) { + return 'reishi'; + } + if (str_contains($normalizedName, 'shiitake')) { + return 'shiitake'; + } + + return null; +} + +function resolve_otc_product(PDO $pdo, string $title): array +{ + $normalizedTitle = normalize_match_key($title); + $familyKey = detect_product_family_key($normalizedTitle); + if ($familyKey === null) { + throw new RuntimeException("Kein Produkt-Matching fuer '{$title}' gefunden"); + } + + $stmt = $pdo->query( + "SELECT id, sku, name + FROM product + WHERE status = 'active' + ORDER BY id" + ); + + foreach ($stmt->fetchAll() as $product) { + $productName = (string) ($product['name'] ?? ''); + $productKey = detect_product_family_key(normalize_match_key($productName)); + if ($productKey === $familyKey) { + return [ + 'id' => (int) $product['id'], + 'sku' => (string) $product['sku'], + 'name' => $productName, + ]; + } + } + + throw new RuntimeException("Kein aktives ERP-Produkt fuer '{$title}' gefunden"); +} + +function resolve_otc_sellable_item(PDO $pdo, int $productId): array +{ + $stmt = $pdo->prepare( + "SELECT + si.id, + si.display_name, + eia.external_article_number + FROM sellable_item si + JOIN sellable_item_component sic ON sic.sellable_item_id = si.id + LEFT JOIN external_item_alias eia + ON eia.sellable_item_id = si.id + AND eia.source_system = 'wix' + AND eia.is_active = TRUE + WHERE si.status = 'active' + AND sic.product_id = :product_id + AND sic.qty_per_item = 1 + AND NOT EXISTS ( + SELECT 1 + FROM sellable_item_component sic_other + WHERE sic_other.sellable_item_id = si.id + AND sic_other.id <> sic.id + ) + ORDER BY + CASE WHEN eia.external_article_number IS NULL OR eia.external_article_number = '' THEN 1 ELSE 0 END, + eia.external_article_number, + si.id + LIMIT 1" + ); + $stmt->execute([':product_id' => $productId]); + $row = $stmt->fetch(); + + if ($row === false) { + throw new RuntimeException("Kein Einzelartikel fuer Produkt-ID {$productId} gefunden"); + } + + return [ + 'id' => (int) $row['id'], + 'display_name' => (string) $row['display_name'], + 'article_number' => trim((string) ($row['external_article_number'] ?? '')), + ]; +} + +function get_default_location_ids(PDO $pdo): array +{ + $row = $pdo->query( + "SELECT + (SELECT id FROM location WHERE type = 'storage' ORDER BY id LIMIT 1) AS storage_id, + (SELECT id FROM location WHERE type = 'dispatch' ORDER BY id LIMIT 1) AS dispatch_id" + )->fetch(); + + if (!is_array($row) || $row['storage_id'] === null || $row['dispatch_id'] === null) { + throw new RuntimeException('Erforderliche Lagerorte fehlen'); + } + + return [ + 'storage' => (int) $row['storage_id'], + 'dispatch' => (int) $row['dispatch_id'], + ]; +} + +function get_item_components(PDO $pdo, int $sellableItemId): array +{ + $stmt = $pdo->prepare( + 'SELECT product_id, qty_per_item + FROM sellable_item_component + WHERE sellable_item_id = :sellable_item_id + ORDER BY id' + ); + $stmt->execute([':sellable_item_id' => $sellableItemId]); + return $stmt->fetchAll(); +} + +function get_current_lot_balance_for_update(PDO $pdo, int $productId): array +{ + $stmt = $pdo->prepare( + "SELECT + sl.id AS lot_id, + COALESCE(( + SELECT v.qty_net + FROM v_stock_lot_balance v + WHERE v.stock_lot_id = sl.id + ), 0) AS qty_net + FROM stock_lot sl + WHERE sl.product_id = :product_id + AND sl.status = 'current' + LIMIT 1 + FOR UPDATE" + ); + $stmt->execute([':product_id' => $productId]); + $row = $stmt->fetch(); + + if ($row === false) { + throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden"); + } + + return [ + 'lot_id' => (int) $row['lot_id'], + 'qty_net' => (float) $row['qty_net'], + ]; +} + +function insert_stock_move_out( + PDO $pdo, + int $productId, + int $lotId, + float $qty, + int $fromLocationId, + int $toLocationId, + string $note +): int { + $stmt = $pdo->prepare( + "INSERT INTO stock_move ( + product_id, lot_id, from_location_id, to_location_id, qty, move_type, move_date, note, created_at, updated_at + ) VALUES ( + :product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'out', NOW(), :note, NOW(), NOW() + ) + RETURNING id" + ); + $stmt->execute([ + ':product_id' => $productId, + ':lot_id' => $lotId, + ':from_location_id' => $fromLocationId, + ':to_location_id' => $toLocationId, + ':qty' => $qty, + ':note' => $note, + ]); + + $id = $stmt->fetchColumn(); + if ($id === false) { + throw new RuntimeException('Konnte Lagerabgang nicht schreiben'); + } + + return (int) $id; +} + +function switch_current_lot(PDO $pdo, int $productId, int $oldCurrentLotId, int $storageLocationId): int +{ + $closeStmt = $pdo->prepare( + "UPDATE stock_lot + SET status = 'closed', updated_at = NOW() + WHERE id = :id" + ); + $closeStmt->execute([':id' => $oldCurrentLotId]); + + $openStmt = $pdo->prepare( + "SELECT id + FROM stock_lot + WHERE product_id = :product_id + AND status = 'open' + ORDER BY id + LIMIT 1 + FOR UPDATE" + ); + $openStmt->execute([':product_id' => $productId]); + $newCurrentLotId = $openStmt->fetchColumn(); + + if ($newCurrentLotId === false) { + throw new RuntimeException("Keine offene Platzhalter-Charge fuer Produkt {$productId} vorhanden"); + } + + $promoteStmt = $pdo->prepare( + "UPDATE stock_lot + SET status = 'current', + updated_at = NOW() + WHERE id = :id" + ); + $promoteStmt->execute([':id' => (int) $newCurrentLotId]); + + $createOpenStmt = $pdo->prepare( + "INSERT INTO stock_lot (product_id, status, created_at, updated_at) + VALUES (:product_id, 'open', NOW(), NOW()) + RETURNING id" + ); + $createOpenStmt->execute([':product_id' => $productId]); + $createdOpenLotId = $createOpenStmt->fetchColumn(); + if ($createdOpenLotId === false) { + throw new RuntimeException("Konnte keine neue offene Platzhalter-Charge fuer Produkt {$productId} anlegen"); + } + + return (int) $newCurrentLotId; +} + +function allocate_components_for_line( + PDO $pdo, + int $orderId, + int $lineId, + int $lineNo, + array $components, + float $lineQty, + array $locations +): array { + if ($components === []) { + throw new RuntimeException("Keine Komponenten fuer Verkaufsposition {$lineNo} gefunden"); + } + + $allocationInsert = $pdo->prepare( + "INSERT INTO sales_order_line_lot_allocation ( + sales_order_line_id, product_id, lot_id, qty, allocation_status, stock_move_id, created_at, updated_at + ) VALUES ( + :sales_order_line_id, :product_id, :lot_id, :qty, 'allocated', :stock_move_id, NOW(), NOW() + )" + ); + + foreach ($components as $component) { + $productId = (int) $component['product_id']; + $remaining = $lineQty * (float) $component['qty_per_item']; + $guard = 0; + + while ($remaining > 0.0000001) { + $guard++; + if ($guard > 100) { + throw new RuntimeException("Allokationsschutz ausgelost fuer Produkt {$productId}"); + } + + $current = get_current_lot_balance_for_update($pdo, $productId); + $lotId = $current['lot_id']; + $available = $current['qty_net']; + + if ($available <= 0.0000001) { + $lotId = switch_current_lot($pdo, $productId, $lotId, (int) $locations['storage']); + $current = get_current_lot_balance_for_update($pdo, $productId); + $available = $current['qty_net']; + $lotId = $current['lot_id']; + } + + if ($available <= 0.0000001) { + throw new RuntimeException("Kein verfuegbarer Bestand fuer Produkt {$productId}"); + } + + $take = min($remaining, $available); + $stockMoveId = insert_stock_move_out( + $pdo, + $productId, + $lotId, + $take, + (int) $locations['storage'], + (int) $locations['dispatch'], + "otc-order:order={$orderId}:line={$lineNo}:product={$productId}" + ); + + $allocationInsert->execute([ + ':sales_order_line_id' => $lineId, + ':product_id' => $productId, + ':lot_id' => $lotId, + ':qty' => $take, + ':stock_move_id' => $stockMoveId, + ]); + + $remaining -= $take; + } + } + + return [ + 'allocated' => true, + ]; +} + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']); } @@ -16,7 +443,7 @@ if ($jsonInput === false || trim($jsonInput) === '') { try { $data = json_decode($jsonInput, true, 512, JSON_THROW_ON_ERROR); -} catch (JsonException $e) { +} catch (JsonException) { json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']); } @@ -26,7 +453,7 @@ if (!is_array($data)) { $required = ['products', 'totalPrice', 'paymentMethod', 'billing']; foreach ($required as $field) { - if (!isset($data[$field])) { + if (!array_key_exists($field, $data)) { json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]); } } @@ -34,7 +461,7 @@ foreach ($required as $field) { $products = $data['products']; $totalPrice = parse_number($data['totalPrice']); $paymentMethodCode = trim((string) $data['paymentMethod']); -$billing = $data['billing']; +$billing = is_array($data['billing']) ? $data['billing'] : []; if (!is_array($products) || count($products) === 0) { json_response(422, ['ok' => false, 'error' => 'No products specified']); @@ -44,51 +471,35 @@ if ($totalPrice === null || $totalPrice <= 0) { json_response(422, ['ok' => false, 'error' => 'Invalid total price']); } +$resolvedProducts = []; $totalQty = 0; foreach ($products as $product) { - if (!isset($product['qty']) || !isset($product['title'])) { + if (!is_array($product) || !isset($product['qty'], $product['title'])) { json_response(422, ['ok' => false, 'error' => 'Product missing title or qty']); } + $qty = parse_number($product['qty']); - if ($qty === null || $qty <= 0) { - json_response(422, ['ok' => false, 'error' => 'Invalid product quantity']); + $title = trim((string) $product['title']); + if ($qty === null || $qty <= 0 || $title === '') { + json_response(422, ['ok' => false, 'error' => 'Invalid product quantity or title']); } - $totalQty += $qty; + + $resolvedProducts[] = [ + 'qty' => (float) $qty, + 'title' => $title, + ]; + $totalQty += (float) $qty; +} + +if ($totalQty <= 0) { + json_response(422, ['ok' => false, 'error' => 'No product quantity specified']); } try { $pdo = connect_database(); $pdo->beginTransaction(); - $partyName = trim($billing['firstName'] . ' ' . $billing['lastName']); - $partyName = $partyName === '' ? 'OTC Kunde' : $partyName; - - $stmt = $pdo->prepare(' - INSERT INTO party (type, name, status, created_at, updated_at) - VALUES (\'customer\', :name, \'active\', NOW(), NOW()) - RETURNING id - '); - $stmt->execute([':name' => $partyName]); - $partyId = (int) $stmt->fetchColumn(); - - $stmt = $pdo->prepare(' - INSERT INTO address ( - party_id, type, first_name, last_name, street, house_number, zip, city, - country_name, created_at, updated_at - ) VALUES ( - :party_id, \'billing\', :first_name, :last_name, :street, :house_number, :zip, :city, - \'Switzerland\', NOW(), NOW() - ) - '); - $stmt->execute([ - ':party_id' => $partyId, - ':first_name' => trim($billing['firstName'] ?? ''), - ':last_name' => trim($billing['lastName'] ?? ''), - ':street' => trim($billing['street'] ?? ''), - ':house_number' => trim($billing['houseNumber'] ?? ''), - ':zip' => trim($billing['zip'] ?? ''), - ':city' => trim($billing['city'] ?? ''), - ]); + $locations = get_default_location_ids($pdo); $paymentMethodStmt = $pdo->prepare('SELECT id FROM payment_method WHERE code = :code LIMIT 1'); $paymentMethodStmt->execute([':code' => $paymentMethodCode]); @@ -96,243 +507,143 @@ try { if ($paymentMethodId === false) { throw new RuntimeException('Invalid payment method'); } - $paymentMethodId = (int) $paymentMethodId; - $orderStmt = $pdo->prepare(' - INSERT INTO sales_order ( - external_ref, party_id, order_source, order_status, payment_status, payment_method_id, - total_amount, currency, imported_at, created_at, updated_at - ) VALUES ( - :external_ref, :party_id, \'direct\', \'imported\', \'paid\', :payment_method_id, - :total_amount, \'CHF\', NOW(), NOW(), NOW() - ) - RETURNING id, external_ref - '); - $orderStmt->execute([ - ':external_ref' => '', + $partyName = trim(((string) ($billing['firstName'] ?? '')) . ' ' . ((string) ($billing['lastName'] ?? ''))); + $partyName = $partyName === '' ? 'OTC Kunde' : $partyName; + + $partyStmt = $pdo->prepare( + "INSERT INTO party (type, name, status, created_at, updated_at) + VALUES ('customer', :name, 'active', NOW(), NOW()) + RETURNING id" + ); + $partyStmt->execute([':name' => $partyName]); + $partyId = $partyStmt->fetchColumn(); + if ($partyId === false) { + throw new RuntimeException('Could not create party'); + } + $partyId = (int) $partyId; + + $addressStmt = $pdo->prepare( + "INSERT INTO address ( + party_id, type, first_name, last_name, street, house_number, zip, city, country_name, created_at, updated_at + ) VALUES ( + :party_id, 'billing', :first_name, :last_name, :street, :house_number, :zip, :city, 'Switzerland', NOW(), NOW() + )" + ); + $addressStmt->execute([ ':party_id' => $partyId, - ':payment_method_id' => $paymentMethodId, + ':first_name' => trim((string) ($billing['firstName'] ?? '')), + ':last_name' => trim((string) ($billing['lastName'] ?? '')), + ':street' => trim((string) ($billing['street'] ?? '')), + ':house_number' => trim((string) ($billing['houseNumber'] ?? '')), + ':zip' => trim((string) ($billing['zip'] ?? '')), + ':city' => trim((string) ($billing['city'] ?? '')), + ]); + + $orderStmt = $pdo->prepare( + "INSERT INTO sales_order ( + external_ref, party_id, order_source, order_status, payment_status, payment_method_id, + amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency, imported_at, created_at, updated_at + ) VALUES ( + '', :party_id, 'direct', 'imported', 'paid', :payment_method_id, + :amount_net, 0, 0, 0, :total_amount, 'CHF', NOW(), NOW(), NOW() + ) + RETURNING id, external_ref" + ); + $orderStmt->execute([ + ':party_id' => $partyId, + ':payment_method_id' => (int) $paymentMethodId, + ':amount_net' => $totalPrice, ':total_amount' => $totalPrice, ]); $order = $orderStmt->fetch(); if ($order === false) { throw new RuntimeException('Could not create order'); } + $orderId = (int) $order['id']; - $externalRef = $order['external_ref']; + $externalRef = (string) $order['external_ref']; - $unitPrice = $totalPrice / $totalQty; + $lineInsert = $pdo->prepare( + "INSERT INTO sales_order_line ( + sales_order_id, line_no, sellable_item_id, raw_external_article_number, raw_external_title, + qty, unit_price, line_total, created_at, updated_at + ) VALUES ( + :sales_order_id, :line_no, :sellable_item_id, :article_number, :title, + :qty, :unit_price, :line_total, NOW(), NOW() + ) + RETURNING id" + ); - $lineInsert = $pdo->prepare(' - INSERT INTO sales_order_line ( - sales_order_id, line_no, raw_external_title, qty, unit_price, line_total, - created_at, updated_at - ) VALUES ( - :sales_order_id, :line_no, :title, :qty, :unit_price, :line_total, - NOW(), NOW() - ) - RETURNING id - '); + $remainingTotal = round((float) $totalPrice, 2); + $remainingQty = (float) $totalQty; + $lineNo = 0; - $locationsStmt = $pdo->query(' - SELECT - (SELECT id FROM location WHERE type = \'storage\' ORDER BY id LIMIT 1) AS storage_id, - (SELECT id FROM location WHERE type = \'dispatch\' ORDER BY id LIMIT 1) AS dispatch_id - '); - $locations = $locationsStmt->fetch(); - $storageId = (int) $locations['storage_id']; - $dispatchId = (int) $locations['dispatch_id']; + foreach ($resolvedProducts as $product) { + $lineNo++; + $qty = (float) $product['qty']; + $title = $product['title']; - foreach ($products as $index => $product) { - $qty = parse_number($product['qty']); - $title = trim($product['title']); - $lineTotal = round($qty * $unitPrice, 2); + $resolvedProduct = resolve_otc_product($pdo, $title); + $resolvedSellable = resolve_otc_sellable_item($pdo, (int) $resolvedProduct['id']); + + if ($lineNo === count($resolvedProducts)) { + $unitPrice = round($remainingTotal / $qty, 4); + $lineTotal = $remainingTotal; + } else { + $unitPrice = round((float) $totalPrice / (float) $totalQty, 4); + $lineTotal = round($qty * $unitPrice, 2); + $remainingTotal = round($remainingTotal - $lineTotal, 2); + $remainingQty -= $qty; + } $lineInsert->execute([ ':sales_order_id' => $orderId, - ':line_no' => $index + 1, - ':title' => $title, + ':line_no' => $lineNo, + ':sellable_item_id' => (int) $resolvedSellable['id'], + ':article_number' => $resolvedSellable['article_number'], + ':title' => $resolvedSellable['display_name'], ':qty' => $qty, ':unit_price' => $unitPrice, ':line_total' => $lineTotal, ]); - $lineId = (int) $lineInsert->fetchColumn(); - - $resolveProductStmt = $pdo->prepare(' - SELECT id FROM product WHERE name ILIKE :pattern ORDER BY id LIMIT 1 - '); - - $pattern = ''; - if (strpos($title, 'Shiitake') !== false) { - $pattern = '%Shiitake%'; - } elseif (strpos($title, 'Reishi') !== false) { - $pattern = '%Reishi%'; - } elseif (strpos($title, 'Lion') !== false) { - $pattern = '%Lion%'; - } elseif (strpos($title, 'Chaga') !== false) { - $pattern = '%Chaga%'; - } - - $productId = null; - if ($pattern !== '') { - $resolveProductStmt->execute([':pattern' => $pattern]); - $productId = $resolveProductStmt->fetchColumn(); - } - if ($productId === false) { - $productStmt = $pdo->prepare(' - INSERT INTO product (sku, name, status, uom, created_at, updated_at) - VALUES (:sku, :name, \'active\', \'unit\', NOW(), NOW()) - RETURNING id - '); - $productStmt->execute([ - ':sku' => 'OTC-' . preg_replace('/[^A-Za-z0-9]/', '', substr($title, 0, 20)), - ':name' => $title, - ]); - $productId = (int) $productStmt->fetchColumn(); - } else { - $productId = (int) $productId; + $lineId = $lineInsert->fetchColumn(); + if ($lineId === false) { + throw new RuntimeException("Could not create line {$lineNo}"); } - $sellableItemStmt = $pdo->prepare(' - SELECT id FROM sellable_item WHERE item_code = :item_code ORDER BY id LIMIT 1 - '); - $sellableItemStmt->execute([':item_code' => 'OTC-' . $productId]); - $sellableItemId = $sellableItemStmt->fetchColumn(); - if ($sellableItemId === false) { - $sellableInsert = $pdo->prepare(' - INSERT INTO sellable_item (item_code, display_name, status, created_at, updated_at) - VALUES (:item_code, :display_name, \'active\', NOW(), NOW()) - RETURNING id - '); - $sellableInsert->execute([ - ':item_code' => 'OTC-' . $productId, - ':display_name' => $title, - ]); - $sellableItemId = (int) $sellableInsert->fetchColumn(); - } else { - $sellableItemId = (int) $sellableItemId; - } - - $updateLineStmt = $pdo->prepare(' - UPDATE sales_order_line - SET sellable_item_id = :sellable_item_id - WHERE id = :id - '); - $updateLineStmt->execute([ - ':sellable_item_id' => $sellableItemId, - ':id' => $lineId, - ]); - - $currentLotStmt = $pdo->prepare(' - SELECT sl.id, COALESCE(v.qty_net, 0) AS qty_net - FROM stock_lot sl - LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id - WHERE sl.product_id = :product_id - AND sl.status = \'current\' - LIMIT 1 - FOR UPDATE - '); - $currentLotStmt->execute([':product_id' => $productId]); - $lot = $currentLotStmt->fetch(); - if ($lot === false || (float) $lot['qty_net'] < $qty) { - $closeLotStmt = $pdo->prepare(' - UPDATE stock_lot - SET status = \'closed\', updated_at = NOW() - WHERE id = :id - '); - $closeLotStmt->execute([':id' => (int) $lot['id']]); - - $openLotStmt = $pdo->prepare(' - SELECT id FROM stock_lot - WHERE product_id = :product_id - AND status = \'open\' - ORDER BY id LIMIT 1 - FOR UPDATE - '); - $openLotStmt->execute([':product_id' => $productId]); - $openLotId = $openLotStmt->fetchColumn(); - if ($openLotId === false) { - $createOpenLotStmt = $pdo->prepare(' - INSERT INTO stock_lot (product_id, status, created_at, updated_at) - VALUES (:product_id, \'open\', NOW(), NOW()) - RETURNING id - '); - $createOpenLotStmt->execute([':product_id' => $productId]); - $openLotId = (int) $createOpenLotStmt->fetchColumn(); - } else { - $openLotId = (int) $openLotId; - } - - $promoteLotStmt = $pdo->prepare(' - UPDATE stock_lot - SET status = \'current\', - lot_number = COALESCE(lot_number, \'OTC-\' || :product_id || \'-\' || :lot_id), - updated_at = NOW() - WHERE id = :lot_id - '); - $promoteLotStmt->execute([ - ':product_id' => $productId, - ':lot_id' => $openLotId, - ]); - - $lotId = $openLotId; - } else { - $lotId = (int) $lot['id']; - } - - $stockMoveStmt = $pdo->prepare(' - INSERT INTO stock_move ( - product_id, lot_id, from_location_id, to_location_id, qty, move_type, - move_date, note, created_at, updated_at - ) VALUES ( - :product_id, :lot_id, :from_location_id, :to_location_id, :qty, \'out\', - NOW(), :note, NOW(), NOW() - ) - RETURNING id - '); - $stockMoveStmt->execute([ - ':product_id' => $productId, - ':lot_id' => $lotId, - ':from_location_id' => $storageId, - ':to_location_id' => $dispatchId, - ':qty' => $qty, - ':note' => 'OTC order ' . $orderId . ': ' . $title, - ]); - $stockMoveId = (int) $stockMoveStmt->fetchColumn(); - - $allocationStmt = $pdo->prepare(' - INSERT INTO sales_order_line_lot_allocation ( - sales_order_line_id, product_id, lot_id, qty, allocation_status, - stock_move_id, created_at, updated_at - ) VALUES ( - :line_id, :product_id, :lot_id, :qty, \'allocated\', - :stock_move_id, NOW(), NOW() - ) - '); - $allocationStmt->execute([ - ':line_id' => $lineId, - ':product_id' => $productId, - ':lot_id' => $lotId, - ':qty' => $qty, - ':stock_move_id' => $stockMoveId, - ]); + $components = get_item_components($pdo, (int) $resolvedSellable['id']); + allocate_components_for_line( + $pdo, + $orderId, + (int) $lineId, + $lineNo, + $components, + $qty, + $locations + ); } $pdo->commit(); + $env = expand_env_values(parse_env_file(__DIR__ . '/../../.env')); + $excelTrigger = trigger_excel_webhook($externalRef, $env); + json_response(201, [ 'ok' => true, 'orderId' => $orderId, 'externalRef' => $externalRef, 'partyId' => $partyId, + 'excelTrigger' => $excelTrigger, 'message' => 'OTC order created successfully', ]); -} catch (Exception $e) { - if (isset($pdo) && $pdo->inTransaction()) { +} catch (Throwable $e) { + if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) { $pdo->rollBack(); } + json_response(500, [ 'ok' => false, 'error' => 'Internal server error: ' . $e->getMessage(), ]); -} \ No newline at end of file +} diff --git a/public/otc/index.php b/public/otc/index.php index 0b07a2e..e5804a5 100644 --- a/public/otc/index.php +++ b/public/otc/index.php @@ -187,6 +187,56 @@ margin-top: 20px; display: none; } + + .overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + display: none; + align-items: center; + justify-content: center; + padding: 20px; + z-index: 1000; + } + + .overlay.is-visible { + display: flex; + } + + .overlay-card { + width: min(420px, 100%); + background: #ffffff; + border-radius: 12px; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22); + padding: 28px 24px 22px; + text-align: center; + } + + .overlay-title { + font-size: 26px; + font-weight: 700; + color: #1f2937; + margin-bottom: 10px; + } + + .overlay-text { + font-size: 16px; + color: #4b5563; + margin-bottom: 18px; + } + + .overlay-order { + font-size: 15px; + color: #0f172a; + background: #f3f4f6; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 18px; + } + + .overlay-close { + margin-top: 0; + } .hidden { display: none; @@ -334,6 +384,15 @@ Bestellung erfolgreich erfasst! Die Bestellnummer wird automatisch generiert. + + - \ No newline at end of file +