Add architecture module map

This commit is contained in:
2026-06-15 09:58:33 +02:00
parent 39d936cfba
commit c6b5a0572c
19 changed files with 1134 additions and 2372 deletions
+54
View File
@@ -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
-182
View File
@@ -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"
}
}
-104
View File
@@ -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!).
-26
View File
@@ -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": {}
}
}
-161
View File
@@ -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)
-58
View File
@@ -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
-108
View File
@@ -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;"
```
-208
View File
@@ -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
+115
View File
@@ -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
+239
View File
@@ -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:
+1 -1
View File
@@ -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`
+42
View File
@@ -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
-16
View File
@@ -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"
}
]
}
+41 -54
View File
@@ -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',
];
}
// Authentication headers (same mechanism as shipping_label_flow)
$payload = [
'Bestellnummer' => $externalRef,
];
$headers = [];
$secret = env_value('N8N_WEBHOOK_SECRET', $localEnv);
if ($secret !== '') {
@@ -412,9 +398,10 @@ function trigger_excel_webhook(PDO $pdo, int $orderId, array $localEnv): array
$headers['X-API-Key'] = $secret;
$headers['Authorization'] = 'Bearer ' . $secret;
}
$result = post_json($url, $data, $headers, 20);
throttle_webhook_channel('excel', 10);
$result = post_json($url, $payload, $headers, 20);
return [
'enabled' => true,
'ok' => $result['ok'],
@@ -1597,7 +1584,7 @@ try {
$pdo->commit();
$labelTrigger = trigger_shipping_label_flow($data, $env);
$excelTrigger = trigger_excel_webhook($pdo, $orderId, $env);
$excelTrigger = trigger_excel_webhook($externalRef, $env);
json_response(200, [
'ok' => true,
+548 -237
View File
@@ -2,9 +2,436 @@
declare(strict_types=1);
require_once __DIR__ . '/../../includes/db.php';
require_once __DIR__ . '/../../includes/webhook_throttle.php';
header('Content-Type: application/json; charset=utf-8');
function derive_excel_webhook_url(array $localEnv): string
{
$explicit = env_value('N8N_EXCEL_WEBHOOK_URL', $localEnv);
if ($explicit !== '') {
return $explicit;
}
$legacy = env_value('N8N_OUTBOUND_URL_ADRESSE', $localEnv);
if ($legacy !== '' && str_contains(strtolower($legacy), 'excel_befuellen')) {
return $legacy;
}
$base = env_value('N8N_BASE_URL', $localEnv);
if ($base === '') {
return '';
}
$root = preg_replace('#/api/v1/?$#', '', rtrim($base, '/'));
if (!is_string($root) || $root === '') {
return '';
}
return $root . '/webhook/excel_befuellen';
}
function post_json(string $url, array $payload, array $headers = [], int $timeoutSeconds = 15): array
{
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($body === false) {
return ['ok' => false, 'status' => 0, 'body' => '', 'error' => 'Could not encode payload'];
}
$headerLines = ['Content-Type: application/json'];
foreach ($headers as $name => $value) {
if ($name === '' || $value === '') {
continue;
}
$headerLines[] = $name . ': ' . $value;
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headerLines),
'content' => $body,
'timeout' => $timeoutSeconds,
'ignore_errors' => true,
],
]);
$responseBody = @file_get_contents($url, false, $context);
$responseHeaders = $http_response_header ?? [];
$status = 0;
if (isset($responseHeaders[0]) && preg_match('#HTTP/\S+\s+(\d{3})#', $responseHeaders[0], $m) === 1) {
$status = (int) $m[1];
}
if ($responseBody === false) {
$responseBody = '';
}
return [
'ok' => $status >= 200 && $status < 300,
'status' => $status,
'body' => substr($responseBody, 0, 500),
'error' => ($status === 0) ? 'Request failed or timed out' : '',
];
}
function trigger_excel_webhook(string $externalRef, array $localEnv): array
{
$url = derive_excel_webhook_url($localEnv);
if ($url === '') {
return [
'enabled' => false,
'ok' => false,
'message' => 'Excel webhook URL not configured',
];
}
$headers = [];
$secret = env_value('N8N_WEBHOOK_SECRET', $localEnv);
if ($secret !== '') {
$headers['X-Webhook-Secret'] = $secret;
$headers['X-N8N-Secret'] = $secret;
$headers['X-API-Key'] = $secret;
$headers['Authorization'] = 'Bearer ' . $secret;
}
throttle_webhook_channel('excel', 10);
$result = post_json($url, ['Bestellnummer' => $externalRef], $headers, 20);
return [
'enabled' => true,
'ok' => $result['ok'],
'status' => $result['status'],
'url' => $url,
'message' => $result['ok'] ? 'Excel webhook triggered' : ($result['error'] !== '' ? $result['error'] : 'Excel webhook returned non-2xx'),
'responseBody' => $result['body'],
];
}
function normalize_match_key(string $value): string
{
$value = trim(mb_strtolower($value, 'UTF-8'));
if ($value === '') {
return '';
}
if (function_exists('iconv')) {
$transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
if ($transliterated !== false) {
$value = $transliterated;
}
}
$value = preg_replace('/[^a-z0-9]+/', ' ', $value) ?? '';
return trim($value);
}
function detect_product_family_key(string $normalizedName): ?string
{
if (str_contains($normalizedName, 'lion') && str_contains($normalizedName, 'mane')) {
return 'lionsmane';
}
if (str_contains($normalizedName, 'chaga')) {
return 'chaga';
}
if (str_contains($normalizedName, 'reishi')) {
return 'reishi';
}
if (str_contains($normalizedName, 'shiitake')) {
return 'shiitake';
}
return null;
}
function resolve_otc_product(PDO $pdo, string $title): array
{
$normalizedTitle = normalize_match_key($title);
$familyKey = detect_product_family_key($normalizedTitle);
if ($familyKey === null) {
throw new RuntimeException("Kein Produkt-Matching fuer '{$title}' gefunden");
}
$stmt = $pdo->query(
"SELECT id, sku, name
FROM product
WHERE status = 'active'
ORDER BY id"
);
foreach ($stmt->fetchAll() as $product) {
$productName = (string) ($product['name'] ?? '');
$productKey = detect_product_family_key(normalize_match_key($productName));
if ($productKey === $familyKey) {
return [
'id' => (int) $product['id'],
'sku' => (string) $product['sku'],
'name' => $productName,
];
}
}
throw new RuntimeException("Kein aktives ERP-Produkt fuer '{$title}' gefunden");
}
function resolve_otc_sellable_item(PDO $pdo, int $productId): array
{
$stmt = $pdo->prepare(
"SELECT
si.id,
si.display_name,
eia.external_article_number
FROM sellable_item si
JOIN sellable_item_component sic ON sic.sellable_item_id = si.id
LEFT JOIN external_item_alias eia
ON eia.sellable_item_id = si.id
AND eia.source_system = 'wix'
AND eia.is_active = TRUE
WHERE si.status = 'active'
AND sic.product_id = :product_id
AND sic.qty_per_item = 1
AND NOT EXISTS (
SELECT 1
FROM sellable_item_component sic_other
WHERE sic_other.sellable_item_id = si.id
AND sic_other.id <> sic.id
)
ORDER BY
CASE WHEN eia.external_article_number IS NULL OR eia.external_article_number = '' THEN 1 ELSE 0 END,
eia.external_article_number,
si.id
LIMIT 1"
);
$stmt->execute([':product_id' => $productId]);
$row = $stmt->fetch();
if ($row === false) {
throw new RuntimeException("Kein Einzelartikel fuer Produkt-ID {$productId} gefunden");
}
return [
'id' => (int) $row['id'],
'display_name' => (string) $row['display_name'],
'article_number' => trim((string) ($row['external_article_number'] ?? '')),
];
}
function get_default_location_ids(PDO $pdo): array
{
$row = $pdo->query(
"SELECT
(SELECT id FROM location WHERE type = 'storage' ORDER BY id LIMIT 1) AS storage_id,
(SELECT id FROM location WHERE type = 'dispatch' ORDER BY id LIMIT 1) AS dispatch_id"
)->fetch();
if (!is_array($row) || $row['storage_id'] === null || $row['dispatch_id'] === null) {
throw new RuntimeException('Erforderliche Lagerorte fehlen');
}
return [
'storage' => (int) $row['storage_id'],
'dispatch' => (int) $row['dispatch_id'],
];
}
function get_item_components(PDO $pdo, int $sellableItemId): array
{
$stmt = $pdo->prepare(
'SELECT product_id, qty_per_item
FROM sellable_item_component
WHERE sellable_item_id = :sellable_item_id
ORDER BY id'
);
$stmt->execute([':sellable_item_id' => $sellableItemId]);
return $stmt->fetchAll();
}
function get_current_lot_balance_for_update(PDO $pdo, int $productId): array
{
$stmt = $pdo->prepare(
"SELECT
sl.id AS lot_id,
COALESCE((
SELECT v.qty_net
FROM v_stock_lot_balance v
WHERE v.stock_lot_id = sl.id
), 0) AS qty_net
FROM stock_lot sl
WHERE sl.product_id = :product_id
AND sl.status = 'current'
LIMIT 1
FOR UPDATE"
);
$stmt->execute([':product_id' => $productId]);
$row = $stmt->fetch();
if ($row === false) {
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
}
return [
'lot_id' => (int) $row['lot_id'],
'qty_net' => (float) $row['qty_net'],
];
}
function insert_stock_move_out(
PDO $pdo,
int $productId,
int $lotId,
float $qty,
int $fromLocationId,
int $toLocationId,
string $note
): int {
$stmt = $pdo->prepare(
"INSERT INTO stock_move (
product_id, lot_id, from_location_id, to_location_id, qty, move_type, move_date, note, created_at, updated_at
) VALUES (
:product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'out', NOW(), :note, NOW(), NOW()
)
RETURNING id"
);
$stmt->execute([
':product_id' => $productId,
':lot_id' => $lotId,
':from_location_id' => $fromLocationId,
':to_location_id' => $toLocationId,
':qty' => $qty,
':note' => $note,
]);
$id = $stmt->fetchColumn();
if ($id === false) {
throw new RuntimeException('Konnte Lagerabgang nicht schreiben');
}
return (int) $id;
}
function switch_current_lot(PDO $pdo, int $productId, int $oldCurrentLotId, int $storageLocationId): int
{
$closeStmt = $pdo->prepare(
"UPDATE stock_lot
SET status = 'closed', updated_at = NOW()
WHERE id = :id"
);
$closeStmt->execute([':id' => $oldCurrentLotId]);
$openStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'open'
ORDER BY id
LIMIT 1
FOR UPDATE"
);
$openStmt->execute([':product_id' => $productId]);
$newCurrentLotId = $openStmt->fetchColumn();
if ($newCurrentLotId === false) {
throw new RuntimeException("Keine offene Platzhalter-Charge fuer Produkt {$productId} vorhanden");
}
$promoteStmt = $pdo->prepare(
"UPDATE stock_lot
SET status = 'current',
updated_at = NOW()
WHERE id = :id"
);
$promoteStmt->execute([':id' => (int) $newCurrentLotId]);
$createOpenStmt = $pdo->prepare(
"INSERT INTO stock_lot (product_id, status, created_at, updated_at)
VALUES (:product_id, 'open', NOW(), NOW())
RETURNING id"
);
$createOpenStmt->execute([':product_id' => $productId]);
$createdOpenLotId = $createOpenStmt->fetchColumn();
if ($createdOpenLotId === false) {
throw new RuntimeException("Konnte keine neue offene Platzhalter-Charge fuer Produkt {$productId} anlegen");
}
return (int) $newCurrentLotId;
}
function allocate_components_for_line(
PDO $pdo,
int $orderId,
int $lineId,
int $lineNo,
array $components,
float $lineQty,
array $locations
): array {
if ($components === []) {
throw new RuntimeException("Keine Komponenten fuer Verkaufsposition {$lineNo} gefunden");
}
$allocationInsert = $pdo->prepare(
"INSERT INTO sales_order_line_lot_allocation (
sales_order_line_id, product_id, lot_id, qty, allocation_status, stock_move_id, created_at, updated_at
) VALUES (
:sales_order_line_id, :product_id, :lot_id, :qty, 'allocated', :stock_move_id, NOW(), NOW()
)"
);
foreach ($components as $component) {
$productId = (int) $component['product_id'];
$remaining = $lineQty * (float) $component['qty_per_item'];
$guard = 0;
while ($remaining > 0.0000001) {
$guard++;
if ($guard > 100) {
throw new RuntimeException("Allokationsschutz ausgelost fuer Produkt {$productId}");
}
$current = get_current_lot_balance_for_update($pdo, $productId);
$lotId = $current['lot_id'];
$available = $current['qty_net'];
if ($available <= 0.0000001) {
$lotId = switch_current_lot($pdo, $productId, $lotId, (int) $locations['storage']);
$current = get_current_lot_balance_for_update($pdo, $productId);
$available = $current['qty_net'];
$lotId = $current['lot_id'];
}
if ($available <= 0.0000001) {
throw new RuntimeException("Kein verfuegbarer Bestand fuer Produkt {$productId}");
}
$take = min($remaining, $available);
$stockMoveId = insert_stock_move_out(
$pdo,
$productId,
$lotId,
$take,
(int) $locations['storage'],
(int) $locations['dispatch'],
"otc-order:order={$orderId}:line={$lineNo}:product={$productId}"
);
$allocationInsert->execute([
':sales_order_line_id' => $lineId,
':product_id' => $productId,
':lot_id' => $lotId,
':qty' => $take,
':stock_move_id' => $stockMoveId,
]);
$remaining -= $take;
}
}
return [
'allocated' => true,
];
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']);
}
@@ -16,7 +443,7 @@ if ($jsonInput === false || trim($jsonInput) === '') {
try {
$data = json_decode($jsonInput, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
} catch (JsonException) {
json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']);
}
@@ -26,7 +453,7 @@ if (!is_array($data)) {
$required = ['products', 'totalPrice', 'paymentMethod', 'billing'];
foreach ($required as $field) {
if (!isset($data[$field])) {
if (!array_key_exists($field, $data)) {
json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]);
}
}
@@ -34,7 +461,7 @@ foreach ($required as $field) {
$products = $data['products'];
$totalPrice = parse_number($data['totalPrice']);
$paymentMethodCode = trim((string) $data['paymentMethod']);
$billing = $data['billing'];
$billing = is_array($data['billing']) ? $data['billing'] : [];
if (!is_array($products) || count($products) === 0) {
json_response(422, ['ok' => false, 'error' => 'No products specified']);
@@ -44,51 +471,35 @@ if ($totalPrice === null || $totalPrice <= 0) {
json_response(422, ['ok' => false, 'error' => 'Invalid total price']);
}
$resolvedProducts = [];
$totalQty = 0;
foreach ($products as $product) {
if (!isset($product['qty']) || !isset($product['title'])) {
if (!is_array($product) || !isset($product['qty'], $product['title'])) {
json_response(422, ['ok' => false, 'error' => 'Product missing title or qty']);
}
$qty = parse_number($product['qty']);
if ($qty === null || $qty <= 0) {
json_response(422, ['ok' => false, 'error' => 'Invalid product quantity']);
$title = trim((string) $product['title']);
if ($qty === null || $qty <= 0 || $title === '') {
json_response(422, ['ok' => false, 'error' => 'Invalid product quantity or title']);
}
$totalQty += $qty;
$resolvedProducts[] = [
'qty' => (float) $qty,
'title' => $title,
];
$totalQty += (float) $qty;
}
if ($totalQty <= 0) {
json_response(422, ['ok' => false, 'error' => 'No product quantity specified']);
}
try {
$pdo = connect_database();
$pdo->beginTransaction();
$partyName = trim($billing['firstName'] . ' ' . $billing['lastName']);
$partyName = $partyName === '' ? 'OTC Kunde' : $partyName;
$stmt = $pdo->prepare('
INSERT INTO party (type, name, status, created_at, updated_at)
VALUES (\'customer\', :name, \'active\', NOW(), NOW())
RETURNING id
');
$stmt->execute([':name' => $partyName]);
$partyId = (int) $stmt->fetchColumn();
$stmt = $pdo->prepare('
INSERT INTO address (
party_id, type, first_name, last_name, street, house_number, zip, city,
country_name, created_at, updated_at
) VALUES (
:party_id, \'billing\', :first_name, :last_name, :street, :house_number, :zip, :city,
\'Switzerland\', NOW(), NOW()
)
');
$stmt->execute([
':party_id' => $partyId,
':first_name' => trim($billing['firstName'] ?? ''),
':last_name' => trim($billing['lastName'] ?? ''),
':street' => trim($billing['street'] ?? ''),
':house_number' => trim($billing['houseNumber'] ?? ''),
':zip' => trim($billing['zip'] ?? ''),
':city' => trim($billing['city'] ?? ''),
]);
$locations = get_default_location_ids($pdo);
$paymentMethodStmt = $pdo->prepare('SELECT id FROM payment_method WHERE code = :code LIMIT 1');
$paymentMethodStmt->execute([':code' => $paymentMethodCode]);
@@ -96,243 +507,143 @@ try {
if ($paymentMethodId === false) {
throw new RuntimeException('Invalid payment method');
}
$paymentMethodId = (int) $paymentMethodId;
$orderStmt = $pdo->prepare('
INSERT INTO sales_order (
external_ref, party_id, order_source, order_status, payment_status, payment_method_id,
total_amount, currency, imported_at, created_at, updated_at
) VALUES (
:external_ref, :party_id, \'direct\', \'imported\', \'paid\', :payment_method_id,
:total_amount, \'CHF\', NOW(), NOW(), NOW()
)
RETURNING id, external_ref
');
$orderStmt->execute([
':external_ref' => '',
$partyName = trim(((string) ($billing['firstName'] ?? '')) . ' ' . ((string) ($billing['lastName'] ?? '')));
$partyName = $partyName === '' ? 'OTC Kunde' : $partyName;
$partyStmt = $pdo->prepare(
"INSERT INTO party (type, name, status, created_at, updated_at)
VALUES ('customer', :name, 'active', NOW(), NOW())
RETURNING id"
);
$partyStmt->execute([':name' => $partyName]);
$partyId = $partyStmt->fetchColumn();
if ($partyId === false) {
throw new RuntimeException('Could not create party');
}
$partyId = (int) $partyId;
$addressStmt = $pdo->prepare(
"INSERT INTO address (
party_id, type, first_name, last_name, street, house_number, zip, city, country_name, created_at, updated_at
) VALUES (
:party_id, 'billing', :first_name, :last_name, :street, :house_number, :zip, :city, 'Switzerland', NOW(), NOW()
)"
);
$addressStmt->execute([
':party_id' => $partyId,
':payment_method_id' => $paymentMethodId,
':first_name' => trim((string) ($billing['firstName'] ?? '')),
':last_name' => trim((string) ($billing['lastName'] ?? '')),
':street' => trim((string) ($billing['street'] ?? '')),
':house_number' => trim((string) ($billing['houseNumber'] ?? '')),
':zip' => trim((string) ($billing['zip'] ?? '')),
':city' => trim((string) ($billing['city'] ?? '')),
]);
$orderStmt = $pdo->prepare(
"INSERT INTO sales_order (
external_ref, party_id, order_source, order_status, payment_status, payment_method_id,
amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency, imported_at, created_at, updated_at
) VALUES (
'', :party_id, 'direct', 'imported', 'paid', :payment_method_id,
:amount_net, 0, 0, 0, :total_amount, 'CHF', NOW(), NOW(), NOW()
)
RETURNING id, external_ref"
);
$orderStmt->execute([
':party_id' => $partyId,
':payment_method_id' => (int) $paymentMethodId,
':amount_net' => $totalPrice,
':total_amount' => $totalPrice,
]);
$order = $orderStmt->fetch();
if ($order === false) {
throw new RuntimeException('Could not create order');
}
$orderId = (int) $order['id'];
$externalRef = $order['external_ref'];
$externalRef = (string) $order['external_ref'];
$unitPrice = $totalPrice / $totalQty;
$lineInsert = $pdo->prepare(
"INSERT INTO sales_order_line (
sales_order_id, line_no, sellable_item_id, raw_external_article_number, raw_external_title,
qty, unit_price, line_total, created_at, updated_at
) VALUES (
:sales_order_id, :line_no, :sellable_item_id, :article_number, :title,
:qty, :unit_price, :line_total, NOW(), NOW()
)
RETURNING id"
);
$lineInsert = $pdo->prepare('
INSERT INTO sales_order_line (
sales_order_id, line_no, raw_external_title, qty, unit_price, line_total,
created_at, updated_at
) VALUES (
:sales_order_id, :line_no, :title, :qty, :unit_price, :line_total,
NOW(), NOW()
)
RETURNING id
');
$remainingTotal = round((float) $totalPrice, 2);
$remainingQty = (float) $totalQty;
$lineNo = 0;
$locationsStmt = $pdo->query('
SELECT
(SELECT id FROM location WHERE type = \'storage\' ORDER BY id LIMIT 1) AS storage_id,
(SELECT id FROM location WHERE type = \'dispatch\' ORDER BY id LIMIT 1) AS dispatch_id
');
$locations = $locationsStmt->fetch();
$storageId = (int) $locations['storage_id'];
$dispatchId = (int) $locations['dispatch_id'];
foreach ($resolvedProducts as $product) {
$lineNo++;
$qty = (float) $product['qty'];
$title = $product['title'];
foreach ($products as $index => $product) {
$qty = parse_number($product['qty']);
$title = trim($product['title']);
$lineTotal = round($qty * $unitPrice, 2);
$resolvedProduct = resolve_otc_product($pdo, $title);
$resolvedSellable = resolve_otc_sellable_item($pdo, (int) $resolvedProduct['id']);
if ($lineNo === count($resolvedProducts)) {
$unitPrice = round($remainingTotal / $qty, 4);
$lineTotal = $remainingTotal;
} else {
$unitPrice = round((float) $totalPrice / (float) $totalQty, 4);
$lineTotal = round($qty * $unitPrice, 2);
$remainingTotal = round($remainingTotal - $lineTotal, 2);
$remainingQty -= $qty;
}
$lineInsert->execute([
':sales_order_id' => $orderId,
':line_no' => $index + 1,
':title' => $title,
':line_no' => $lineNo,
':sellable_item_id' => (int) $resolvedSellable['id'],
':article_number' => $resolvedSellable['article_number'],
':title' => $resolvedSellable['display_name'],
':qty' => $qty,
':unit_price' => $unitPrice,
':line_total' => $lineTotal,
]);
$lineId = (int) $lineInsert->fetchColumn();
$resolveProductStmt = $pdo->prepare('
SELECT id FROM product WHERE name ILIKE :pattern ORDER BY id LIMIT 1
');
$pattern = '';
if (strpos($title, 'Shiitake') !== false) {
$pattern = '%Shiitake%';
} elseif (strpos($title, 'Reishi') !== false) {
$pattern = '%Reishi%';
} elseif (strpos($title, 'Lion') !== false) {
$pattern = '%Lion%';
} elseif (strpos($title, 'Chaga') !== false) {
$pattern = '%Chaga%';
}
$productId = null;
if ($pattern !== '') {
$resolveProductStmt->execute([':pattern' => $pattern]);
$productId = $resolveProductStmt->fetchColumn();
}
if ($productId === false) {
$productStmt = $pdo->prepare('
INSERT INTO product (sku, name, status, uom, created_at, updated_at)
VALUES (:sku, :name, \'active\', \'unit\', NOW(), NOW())
RETURNING id
');
$productStmt->execute([
':sku' => 'OTC-' . preg_replace('/[^A-Za-z0-9]/', '', substr($title, 0, 20)),
':name' => $title,
]);
$productId = (int) $productStmt->fetchColumn();
} else {
$productId = (int) $productId;
$lineId = $lineInsert->fetchColumn();
if ($lineId === false) {
throw new RuntimeException("Could not create line {$lineNo}");
}
$sellableItemStmt = $pdo->prepare('
SELECT id FROM sellable_item WHERE item_code = :item_code ORDER BY id LIMIT 1
');
$sellableItemStmt->execute([':item_code' => 'OTC-' . $productId]);
$sellableItemId = $sellableItemStmt->fetchColumn();
if ($sellableItemId === false) {
$sellableInsert = $pdo->prepare('
INSERT INTO sellable_item (item_code, display_name, status, created_at, updated_at)
VALUES (:item_code, :display_name, \'active\', NOW(), NOW())
RETURNING id
');
$sellableInsert->execute([
':item_code' => 'OTC-' . $productId,
':display_name' => $title,
]);
$sellableItemId = (int) $sellableInsert->fetchColumn();
} else {
$sellableItemId = (int) $sellableItemId;
}
$updateLineStmt = $pdo->prepare('
UPDATE sales_order_line
SET sellable_item_id = :sellable_item_id
WHERE id = :id
');
$updateLineStmt->execute([
':sellable_item_id' => $sellableItemId,
':id' => $lineId,
]);
$currentLotStmt = $pdo->prepare('
SELECT sl.id, COALESCE(v.qty_net, 0) AS qty_net
FROM stock_lot sl
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = \'current\'
LIMIT 1
FOR UPDATE
');
$currentLotStmt->execute([':product_id' => $productId]);
$lot = $currentLotStmt->fetch();
if ($lot === false || (float) $lot['qty_net'] < $qty) {
$closeLotStmt = $pdo->prepare('
UPDATE stock_lot
SET status = \'closed\', updated_at = NOW()
WHERE id = :id
');
$closeLotStmt->execute([':id' => (int) $lot['id']]);
$openLotStmt = $pdo->prepare('
SELECT id FROM stock_lot
WHERE product_id = :product_id
AND status = \'open\'
ORDER BY id LIMIT 1
FOR UPDATE
');
$openLotStmt->execute([':product_id' => $productId]);
$openLotId = $openLotStmt->fetchColumn();
if ($openLotId === false) {
$createOpenLotStmt = $pdo->prepare('
INSERT INTO stock_lot (product_id, status, created_at, updated_at)
VALUES (:product_id, \'open\', NOW(), NOW())
RETURNING id
');
$createOpenLotStmt->execute([':product_id' => $productId]);
$openLotId = (int) $createOpenLotStmt->fetchColumn();
} else {
$openLotId = (int) $openLotId;
}
$promoteLotStmt = $pdo->prepare('
UPDATE stock_lot
SET status = \'current\',
lot_number = COALESCE(lot_number, \'OTC-\' || :product_id || \'-\' || :lot_id),
updated_at = NOW()
WHERE id = :lot_id
');
$promoteLotStmt->execute([
':product_id' => $productId,
':lot_id' => $openLotId,
]);
$lotId = $openLotId;
} else {
$lotId = (int) $lot['id'];
}
$stockMoveStmt = $pdo->prepare('
INSERT INTO stock_move (
product_id, lot_id, from_location_id, to_location_id, qty, move_type,
move_date, note, created_at, updated_at
) VALUES (
:product_id, :lot_id, :from_location_id, :to_location_id, :qty, \'out\',
NOW(), :note, NOW(), NOW()
)
RETURNING id
');
$stockMoveStmt->execute([
':product_id' => $productId,
':lot_id' => $lotId,
':from_location_id' => $storageId,
':to_location_id' => $dispatchId,
':qty' => $qty,
':note' => 'OTC order ' . $orderId . ': ' . $title,
]);
$stockMoveId = (int) $stockMoveStmt->fetchColumn();
$allocationStmt = $pdo->prepare('
INSERT INTO sales_order_line_lot_allocation (
sales_order_line_id, product_id, lot_id, qty, allocation_status,
stock_move_id, created_at, updated_at
) VALUES (
:line_id, :product_id, :lot_id, :qty, \'allocated\',
:stock_move_id, NOW(), NOW()
)
');
$allocationStmt->execute([
':line_id' => $lineId,
':product_id' => $productId,
':lot_id' => $lotId,
':qty' => $qty,
':stock_move_id' => $stockMoveId,
]);
$components = get_item_components($pdo, (int) $resolvedSellable['id']);
allocate_components_for_line(
$pdo,
$orderId,
(int) $lineId,
$lineNo,
$components,
$qty,
$locations
);
}
$pdo->commit();
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
$excelTrigger = trigger_excel_webhook($externalRef, $env);
json_response(201, [
'ok' => true,
'orderId' => $orderId,
'externalRef' => $externalRef,
'partyId' => $partyId,
'excelTrigger' => $excelTrigger,
'message' => 'OTC order created successfully',
]);
} catch (Exception $e) {
if (isset($pdo) && $pdo->inTransaction()) {
} catch (Throwable $e) {
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
$pdo->rollBack();
}
json_response(500, [
'ok' => false,
'error' => 'Internal server error: ' . $e->getMessage(),
]);
}
}
+93 -2
View File
@@ -187,6 +187,56 @@
margin-top: 20px;
display: none;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 1000;
}
.overlay.is-visible {
display: flex;
}
.overlay-card {
width: min(420px, 100%);
background: #ffffff;
border-radius: 12px;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22);
padding: 28px 24px 22px;
text-align: center;
}
.overlay-title {
font-size: 26px;
font-weight: 700;
color: #1f2937;
margin-bottom: 10px;
}
.overlay-text {
font-size: 16px;
color: #4b5563;
margin-bottom: 18px;
}
.overlay-order {
font-size: 15px;
color: #0f172a;
background: #f3f4f6;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 18px;
}
.overlay-close {
margin-top: 0;
}
.hidden {
display: none;
@@ -334,6 +384,15 @@
Bestellung erfolgreich erfasst! Die Bestellnummer wird automatisch generiert.
</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 = [
@@ -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';
@@ -492,4 +583,4 @@
updatePriceBreakdown();
</script>
</body>
</html>
</html>