Add architecture module map
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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!).
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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;"
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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/<modul>/...`
|
||||
- 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/<modul>/...`
|
||||
- Arbeit an einem Submodul: `docs/architektur/technical_architecture.md` plus `docs/modules/<modul>/<submodul>/...`
|
||||
- `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
|
||||
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function throttle_webhook_channel(string $channel, int $minIntervalSeconds): void
|
||||
{
|
||||
if ($channel === '' || $minIntervalSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sanitizedChannel = preg_replace('/[^a-z0-9_-]+/i', '_', $channel) ?? 'default';
|
||||
$lockPath = sys_get_temp_dir() . '/erp_naurua_webhook_throttle_' . $sanitizedChannel . '.lock';
|
||||
|
||||
$handle = fopen($lockPath, 'c+');
|
||||
if ($handle === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!flock($handle, LOCK_EX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
rewind($handle);
|
||||
$raw = stream_get_contents($handle);
|
||||
$lastSentAt = is_string($raw) ? (float) trim($raw) : 0.0;
|
||||
$now = microtime(true);
|
||||
$waitSeconds = ($lastSentAt + $minIntervalSeconds) - $now;
|
||||
|
||||
if ($waitSeconds > 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);
|
||||
}
|
||||
}
|
||||
@@ -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 = `<!doctype html>\n<html lang=\"de\"><head>\n <meta charset=\"utf-8\">\n <link rel=\"stylesheet\" href=\"styles.css\">\n</head><body>\n <div class=\"label\">\n <div class=\"line\">An</div>\n <div class=\"line\">${d.Vorname || \"\"} ${d.Nachname || \"\"}</div>\n <div class=\"line\">${d.Strasse || \"\"} ${d.Hausnummer || \"\"}</div>\n <div class=\"line\">${d.PLZ || \"\"} ${d.Stadt || \"\"}</div>\n ${d.Land ? `<div class=\"line\">${d.Land}</div>` : ``}\n </div>\n</body></html>`;\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 <mathias.kurt.glaeser@gmail.com>",
|
||||
"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 = `<!doctype html>\n<html lang=\"de\"><head>\n <meta charset=\"utf-8\">\n <link rel=\"stylesheet\" href=\"styles.css\">\n</head><body>\n <div class=\"label\">\n <div class=\"line\">An</div>\n <div class=\"line\">${d.Vorname || \"\"} ${d.Nachname || \"\"}</div>\n <div class=\"line\">${d.Strasse || \"\"} ${d.Hausnummer || \"\"}</div>\n <div class=\"line\">${d.PLZ || \"\"} ${d.Stadt || \"\"}</div>\n ${d.Land ? `<div class=\"line\">${d.Land}</div>` : ``}\n </div>\n</body></html>`;\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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+36
-49
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/includes/webhook_throttle.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
function json_response(int $status, array $payload): void
|
||||
@@ -252,6 +254,31 @@ function derive_label_webhook_url(array $localEnv): string
|
||||
return $root . '/webhook/naurua_erp_adressetikette';
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -348,62 +375,21 @@ function trigger_shipping_label_flow(array $order, array $localEnv): array
|
||||
];
|
||||
}
|
||||
|
||||
function trigger_excel_webhook(PDO $pdo, int $orderId, array $localEnv): array
|
||||
function trigger_excel_webhook(string $externalRef, array $localEnv): array
|
||||
{
|
||||
$url = env_value('N8N_OUTBOUND_URL_ADRESSE', $localEnv);
|
||||
if ($url === '' || strpos($url, 'excel_befuellen') === false) {
|
||||
$url = derive_excel_webhook_url($localEnv);
|
||||
if ($url === '') {
|
||||
return [
|
||||
'enabled' => 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',
|
||||
$payload = [
|
||||
'Bestellnummer' => $externalRef,
|
||||
];
|
||||
}
|
||||
|
||||
// Authentication headers (same mechanism as shipping_label_flow)
|
||||
$headers = [];
|
||||
$secret = env_value('N8N_WEBHOOK_SECRET', $localEnv);
|
||||
if ($secret !== '') {
|
||||
@@ -413,7 +399,8 @@ function trigger_excel_webhook(PDO $pdo, int $orderId, array $localEnv): array
|
||||
$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,
|
||||
@@ -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,
|
||||
|
||||
+544
-233
@@ -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,241 +507,141 @@ 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
|
||||
$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 (
|
||||
:external_ref, :party_id, \'direct\', \'imported\', \'paid\', :payment_method_id,
|
||||
:total_amount, \'CHF\', NOW(), NOW(), NOW()
|
||||
)
|
||||
RETURNING id, external_ref
|
||||
');
|
||||
$orderStmt->execute([
|
||||
':external_ref' => '',
|
||||
: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, raw_external_title, qty, unit_price, line_total,
|
||||
created_at, updated_at
|
||||
$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, :title, :qty, :unit_price, :line_total,
|
||||
NOW(), NOW()
|
||||
:sales_order_id, :line_no, :sellable_item_id, :article_number, :title,
|
||||
:qty, :unit_price, :line_total, NOW(), NOW()
|
||||
)
|
||||
RETURNING id
|
||||
');
|
||||
RETURNING id"
|
||||
);
|
||||
|
||||
$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'];
|
||||
$remainingTotal = round((float) $totalPrice, 2);
|
||||
$remainingQty = (float) $totalQty;
|
||||
$lineNo = 0;
|
||||
|
||||
foreach ($products as $index => $product) {
|
||||
$qty = parse_number($product['qty']);
|
||||
$title = trim($product['title']);
|
||||
foreach ($resolvedProducts as $product) {
|
||||
$lineNo++;
|
||||
$qty = (float) $product['qty'];
|
||||
$title = $product['title'];
|
||||
|
||||
$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%';
|
||||
$lineId = $lineInsert->fetchColumn();
|
||||
if ($lineId === false) {
|
||||
throw new RuntimeException("Could not create line {$lineNo}");
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$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(),
|
||||
|
||||
+92
-1
@@ -188,6 +188,56 @@
|
||||
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;
|
||||
}
|
||||
@@ -335,6 +385,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="successOverlay" class="overlay" aria-hidden="true">
|
||||
<div class="overlay-card" role="dialog" aria-modal="true" aria-labelledby="successOverlayTitle">
|
||||
<div id="successOverlayTitle" class="overlay-title">Bestellung erhalten</div>
|
||||
<div class="overlay-text">Die Bestellung wurde erfolgreich im System gespeichert.</div>
|
||||
<div id="successOverlayOrder" class="overlay-order">Bestellnummer wird angezeigt, sobald sie vorliegt.</div>
|
||||
<button id="closeOverlayBtn" class="overlay-close" type="button">Schliessen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const productInputs = [
|
||||
document.getElementById('product1'),
|
||||
@@ -351,6 +410,26 @@
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const errorEl = document.getElementById('error');
|
||||
const successEl = document.getElementById('success');
|
||||
const successOverlayEl = document.getElementById('successOverlay');
|
||||
const successOverlayOrderEl = document.getElementById('successOverlayOrder');
|
||||
const closeOverlayBtn = document.getElementById('closeOverlayBtn');
|
||||
|
||||
function closeSuccessOverlay() {
|
||||
successOverlayEl.classList.remove('is-visible');
|
||||
successOverlayEl.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
function openSuccessOverlay(externalRef) {
|
||||
if (externalRef) {
|
||||
successOverlayOrderEl.textContent = 'Bestellnummer: ' + externalRef;
|
||||
} else {
|
||||
successOverlayOrderEl.textContent = 'Bestellnummer wird angezeigt, sobald sie vorliegt.';
|
||||
}
|
||||
|
||||
successOverlayEl.classList.add('is-visible');
|
||||
successOverlayEl.setAttribute('aria-hidden', 'false');
|
||||
closeOverlayBtn.focus();
|
||||
}
|
||||
|
||||
function updatePriceBreakdown() {
|
||||
const totalQty = productInputs.reduce((sum, input) => sum + parseInt(input.value || 0), 0);
|
||||
@@ -423,6 +502,17 @@
|
||||
document.getElementById('houseNumber').addEventListener('input', validateForm);
|
||||
document.getElementById('zip').addEventListener('input', validateForm);
|
||||
document.getElementById('city').addEventListener('input', validateForm);
|
||||
closeOverlayBtn.addEventListener('click', closeSuccessOverlay);
|
||||
successOverlayEl.addEventListener('click', (event) => {
|
||||
if (event.target === successOverlayEl) {
|
||||
closeSuccessOverlay();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && successOverlayEl.classList.contains('is-visible')) {
|
||||
closeSuccessOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
async function submitOrder() {
|
||||
if (!validateForm()) {
|
||||
@@ -456,7 +546,7 @@
|
||||
errorEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/otc-order.php', {
|
||||
const response = await fetch('/public/api/otc-order.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -471,6 +561,7 @@
|
||||
productInputs.forEach(input => input.value = '0');
|
||||
totalPriceInput.value = '0.00';
|
||||
priceBreakdown.classList.add('hidden');
|
||||
openSuccessOverlay(result.externalRef || '');
|
||||
|
||||
setTimeout(() => {
|
||||
successEl.style.display = 'none';
|
||||
|
||||
Reference in New Issue
Block a user