Add ERP order import endpoint and n8n order-ingest flow export

This commit is contained in:
2026-03-29 20:45:00 +02:00
parent 09c978f8aa
commit b214723a46
18 changed files with 3554 additions and 0 deletions

262
docs/CONCEPT.md Normal file
View File

@@ -0,0 +1,262 @@
# Concept
## Vision
ERP Naurua bildet den operativen Kern fuer Einkauf, Lager und Auftragsabwicklung mit voller Nachvollziehbarkeit von Kontakten, Bestellungen und Chargen.
## Fachliches Zielbild
ERP Naurua dient als zentraler operativer Kern fuer einen Heilpilzhandel mit zwei Verkaufskanaelen: Online-Shop (Wix) und Direktverkauf (z. B. Marktstand). Online-Bestelldaten kommen ueber eine Integrationsschicht (n8n Webhook), Direktverkaeufe werden im ERP manuell als Tages-/Sammelerfassung gebucht. Das System ist intern fuer Lager, Chargen, MHD sowie Rueckverfolgung verantwortlich. Die Kundinnen und Kunden werden im gemeinsamen Kontaktmodell verwaltet (Kunde/Lieferant als Rollen); Direktverkaeufe duerfen ohne individuellen Kundenkontakt erfasst werden. Bestellungen werden mit Chargenbewegungen verknuepft, sodass jede Entnahme auf Charge und Lagerort zurueckfuehrbar ist.
Das Zielbild ist modular erweiterbar: Rechnungswesen, Versandautomatisierung und Beratung werden als Module angefuegt, ohne das Kernmodell zu zerbrechen. Die Prioritaet in Schritt 1 liegt auf Datenqualitaet, Rueckverfolgbarkeit und klaren, auditierbaren Bewegungen.
## Scope
Der erste Umsetzungsschritt umfasst ausschliesslich die Module, die mit `(1)` markiert wurden:
1. Lagerverwaltung mit Chargen, MHD, Zu- und Abgaengen
2. Bestellerfassung mit Chargenrueckverfolgung
3. Kontakt-Angaben inkl. Liefer- und Kundenangaben
## Non-Goals
Nicht Bestandteil von Schritt 1:
1. Kreditoren- und Debitoren-Stammdaten
2. Rechnungseingang (inkl. OCR)
3. Rechnungspruefung und Freigabe-Workflows
4. Automatische Zuordnung von Rechnungen zu Bestellungen/Lieferungen/Leistungen
5. Operative Nachverfolgung von Rechnungsvorgaengen
## Principles
1. Traceability first: Jede Warenbewegung und jede Bestellung muss auf Charge rueckfuehrbar sein; bei Direktverkauf ist ein Laufkundenfall ohne Kontakt zulaessig.
2. Daten vor UI-Komfort: Zuerst robuste Datenmodelle und Prozesse, danach Optimierungen.
3. Erweiterbarkeit: Schritt-1-Modelle muessen spaetere Finanz- und Rechnungsprozesse aufnehmen koennen.
4. Einfache Integrationsfaehigkeit: Importfaehigkeit fuer externe Bestelldaten ist von Beginn an mitzudenken.
5. Kanaltrennung: Online- und Direktverkaeufe muessen technisch eindeutig unterscheidbar sein.
## Users And Roles
1. Einkauf/Disposition: Erfasst und verwaltet Bestellungen.
2. Lager/Logistik: Pflegt Chargen, MHD sowie Warenzu- und -abgaenge.
3. Administration/Backoffice: Verwalten von Kunden- und Lieferkontakten.
## Domain Overview
Schritt 1 basiert auf drei Kern-Domaenen:
1. Kontakte: Kunden und Lieferanten mit abrechnungs- und lieferrelevanten Angaben.
2. Bestellungen: Bestellungskopf und Positionen aus Online-Import und Direktverkauf, gekoppelt an betroffene Chargen; Kontaktzuordnung ist fuer Direktverkauf optional.
3. Lager/Chargen: Bestandsfuehrung je Produkt/Charge inkl. MHD und Bewegungen.
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
Schritt-1-Systemkomponenten:
1. Kontaktmodul (CRUD, Basisspeicherung, optionale Felder fuer spaetere Erweiterung)
2. Bestellmodul (Bestellungskopf, Positionen, Upsert ueber externe Bestellnummer)
3. Artikel-Mapping (externe Shop-Artikel -> interne verkaufbare Artikel -> lagergefuehrte Produkte)
4. Lagermodul (Chargenstamm, MHD, Bewegungsjournal, aktueller Bestand)
5. Import-Schnittstelle fuer initiale Bestelldaten (n8n Webhook JSON)
6. Direktverkaufs-Erfassung (Tages-/Sammelverkauf ohne Kundenregistrierung)
## Grobmodell (Schema) Schritt 1
Die folgenden Entitaeten bilden das Zielschema fuer Schritt 1. Felder sind als grobe Vorschlaege zu verstehen und werden in der Detailphase konkretisiert.
Der Entwurf des SQL-Schemas liegt unter:
`/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/SCHEMA_PHASE1.sql`
Die ausfuehrbaren Migrationen liegen unter:
`/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/db/migrations/`
1. `party`
- Zweck: Kunde oder Lieferant (Rollenmodell).
- Kernfelder: `id`, `type` (customer/supplier/both), `name`, `email`, `phone`, `status`.
2. `address`
- Zweck: Liefer- und Rechnungsadressen pro Kontakt.
- Kernfelder: `id`, `party_id`, `type` (billing/shipping), `street`, `house_number`, `zip`, `city`, `state_code`, `country_name`, `country_iso2`.
3. `contact`
- Zweck: Ansprechpartner oder zusaetzliche Kontaktpunkte.
- Kernfelder: `id`, `party_id`, `first_name`, `last_name`, `email`, `phone`.
4. `product`
- Zweck: Lagergefuehrtes Produkt (Bestandsfuehrung, Charge, MHD).
- Kernfelder: `id`, `sku`, `name`, `status`, `uom`.
5. `sellable_item`
- Zweck: Verkaufbarer Shop-Artikel (kann Bundle sein).
- Kernfelder: `id`, `item_code`, `display_name`, `status`.
6. `external_item_alias`
- Zweck: Mapping von externen Webshop-Artikeldaten auf interne Artikel.
- Kernfelder: `id`, `source_system`, `external_article_number`, `external_title`, `title_normalized`, `sellable_item_id`.
7. `sellable_item_component`
- Zweck: Stueckliste je Shop-Artikel auf lagergefuehrte Produkte.
- Kernfelder: `id`, `sellable_item_id`, `product_id`, `qty_per_item`.
8. `warehouse`
- Zweck: Lagerstandort auf hoher Ebene.
- Kernfelder: `id`, `name`, `code`.
9. `location`
- Zweck: Lagerorte innerhalb eines Lagers.
- Kernfelder: `id`, `warehouse_id`, `code`, `name`, `type` (storage/receiving/dispatch).
10. `stock_lot`
- Zweck: Charge/Batch mit MHD.
- Kernfelder: `id`, `product_id`, `lot_number`, `mfg_date`, `expiry_date`, `status`, `sellout_date`, `warning_state`.
11. `payment_method` und `shipping_method`
- Zweck: Normalisierte interne Werte fuer Zahlungs- und Lieferart.
- Kernfelder: `id`, `code`, `label`, `is_active`.
12. `sales_order`
- Zweck: Bestellungskopf (Online-Shop und Direktverkauf).
- Kernfelder: `id`, `external_ref` (unique), `order_source`, `party_id` (optional bei Direktverkauf), `order_date`, `order_status`, `payment_status`, Summenfelder, `webhook_payload`.
13. `sales_order_line`
- Zweck: Bestellpositionen.
- Kernfelder: `id`, `sales_order_id`, `line_no`, `sellable_item_id`, `raw_external_article_number`, `raw_external_title`, `qty`, `unit_price`.
14. `stock_move`
- Zweck: Zu- und Abgaenge sowie Umlagerungen.
- Kernfelder: `id`, `product_id`, `lot_id`, `from_location_id`, `to_location_id`, `qty`, `move_type`, `move_date`.
15. `sales_order_line_lot_allocation`
- Zweck: Explizite Zuordnung Bestellposition <-> Charge (Rueckverfolgung).
- Kernfelder: `id`, `sales_order_line_id`, `product_id`, `lot_id`, `qty`, `stock_move_id`.
16. `audit_log`
- Zweck: Standardisierte Historisierung von Importen und Aenderungen.
- Kernfelder: `id`, `entity_name`, `entity_id`, `action`, `changed_at`, `before_data`, `after_data`.
17. `outbound_webhook_event`
- Zweck: Outbox-Queue fuer robuste ERP -> n8n Zustellung.
- Kernfelder: `id`, `event_type`, `event_key`, `aggregate_type`, `aggregate_id`, `payload`, `status`, `attempt_count`.
## Skizze (ERD)
```mermaid
erDiagram
PARTY ||--o{ ADDRESS : "has"
PARTY ||--o{ CONTACT : "has"
PARTY o|--o{ SALES_ORDER : "places_or_walkin"
SALES_ORDER ||--o{ SALES_ORDER_LINE : "contains"
SELLABLE_ITEM ||--o{ SALES_ORDER_LINE : "ordered_item"
SELLABLE_ITEM ||--o{ EXTERNAL_ITEM_ALIAS : "mapped_from"
SELLABLE_ITEM ||--o{ SELLABLE_ITEM_COMPONENT : "contains"
PRODUCT ||--o{ SELLABLE_ITEM_COMPONENT : "component"
WAREHOUSE ||--o{ LOCATION : "contains"
PRODUCT ||--o{ STOCK_LOT : "has"
PRODUCT ||--o{ STOCK_MOVE : "moves"
STOCK_LOT ||--o{ STOCK_MOVE : "tracks"
LOCATION ||--o{ STOCK_MOVE : "from_to"
SALES_ORDER_LINE ||--o{ SALES_ORDER_LINE_LOT_ALLOCATION : "allocates"
STOCK_LOT ||--o{ SALES_ORDER_LINE_LOT_ALLOCATION : "traces_to"
PRODUCT ||--o{ SALES_ORDER_LINE_LOT_ALLOCATION : "allocated_product"
```
## Phase-1 Beziehungen (fachlich)
1. Ein Kontakt (`party`) kann mehrere Adressen und Kontakte besitzen.
2. Eine Bestellung kann einem Kontakt zugeordnet sein (`sales_order.party_id`), bei Direktverkauf ist `NULL` zulaessig.
3. Eine Bestellung besteht aus mehreren Positionen (`sales_order_line`).
4. Jede Position referenziert genau einen verkaufbaren Artikel (`sellable_item`), nicht direkt ein Lagerprodukt.
5. Ein verkaufbarer Artikel kann aus mehreren lagergefuehrten Produkten bestehen (`sellable_item_component`).
6. Chargen (`stock_lot`) gehoeren zu genau einem lagergefuehrten Produkt (`product`).
7. Lagerbewegungen (`stock_move`) referenzieren Produkt und Charge und bewegen Menge von einem Lagerort zum anderen.
8. Rueckverfolgung erfolgt ueber `sales_order_line_lot_allocation` und kann je Position ueber mehrere Chargen gesplittet werden.
9. `sales_order.external_ref` ist eindeutig; fuer Online-Import ist es der Upsert-Schluessel, fuer Direktverkauf wird es im ERP mit `DIR-`-Praefix erzeugt.
10. Outbound-Ereignisse werden ueber `outbound_webhook_event` idempotent publiziert.
## Data And Integrations
Minimale Kernobjekte fuer Schritt 1:
1. Kontakt
2. Adresse (Rechnungsadresse, Lieferadresse)
3. Bestellung
4. Bestellposition
5. Verkaufbarer Artikel (`sellable_item`)
6. Lagerprodukt (`product`)
7. Artikel-Mapping (`external_item_alias`)
8. Artikel-Stueckliste (`sellable_item_component`)
9. Charge
10. Lagerbewegung (Zugang/Abgang)
11. Bestellpositions-zu-Chargen-Zuordnung (`sales_order_line_lot_allocation`)
12. Verkaufsquelle im Auftrag (`sales_order.order_source`: `wix` | `direct`)
Zuordnung zum Beispiel-Datensatz:
1. `BestellungNr`, `Zahlungsstatus`, Summenfelder -> Bestellung
2. `Vorname_*`, `Nachname_*`, `EmailKunde` -> Kontakt
3. `*_RgAdr`, `*_LfAdr` -> Adresse
4. `lineItems[*]` -> Bestellposition mit Rohdaten (`raw_external_article_number`, `raw_external_title`)
5. `lineItems[*]` + Alias-Mapping -> interner Artikel (`sellable_item`)
6. Artikel-Stueckliste -> benoetigte Lagerprodukte pro Position
7. Kommissionierung/Abgang -> Chargenzuordnung in `sales_order_line_lot_allocation`
Webhook-Regeln fuer Schritt 1 (Online-Shop):
1. Upsert erfolgt ueber `sales_order.external_ref = BestellungNr`.
2. Zahlungsstatus aus Online-Bestellung wird intern standardmaessig als `paid` gespeichert.
3. Rechnungsadresse darf leer sein; Lieferadresse wird normal erfasst.
4. Adressen speichern Klarname (`country_name`) und ISO-Code (`country_iso2`) parallel.
5. Adressdaten werden nur gespeichert, nicht serverseitig validiert (Validierung erfolgt im Shop).
Direktverkauf-Regeln fuer Schritt 1:
1. Direktverkaeufe werden im ERP erfasst (`sales_order.order_source = direct`) und kommen nicht ueber n8n-Webhook.
2. Die Bestellnummer wird im ERP erzeugt und hat den Praefix `DIR-` (z. B. `DIR-20260329-00017`).
3. `sales_order.party_id` ist optional; bei Laufkundschaft wird kein individueller Kontakt angelegt.
4. Positionen werden als Sammelverkauf mit Mengen je Produkt erfasst.
5. Der brutto Gesamtpreis kann auf Flaschenebene verteilt werden (Durchschnittspreis = Gesamtpreis / Gesamtmenge).
6. Zahlungsart wird explizit gespeichert (z. B. `twint`, `cash`, `paypal`, `bank_transfer`).
Lagerregeln fuer Schritt 1:
1. Pro Produkt existiert operativ genau eine `current`-Charge und genau eine `open`-Charge.
2. `open` darf nicht fehlen; sie ist der direkte Ueberlauf fuer den naechsten operativen Entnahmefall.
3. Statusfluss fuer Chargen: `open -> current -> closed`.
4. Bei Erreichen von `qty_net <= 0` auf der `current`-Charge erfolgt automatischer Wechsel auf die vorhandene `open`-Charge.
5. Nach dem Wechsel wird automatisch wieder eine neue `open`-Charge erzeugt (lot_number bleibt initial leer bis manuell gesetzt).
6. Negative Chargenbestaende sind nicht zulaessig.
7. Chargensalden (`in/out/net`) werden aus Bewegungen berechnet.
8. Jede Verkaufsbewegung muss einer Charge zugeordnet sein (kein chargenloser Abgang).
9. Verkaufsbuchung erfolgt standardmaessig gegen die `current`-Charge.
10. Korrekturen bleiben durch editierbare Datensaetze und/oder Korrekturbewegungen moeglich.
11. Abverkaufdatum wird systemintern berechnet; Warnstatus wird fuer UI bereitgestellt (ohne E-Mail-Prozess in Phase 1).
## Milestones
Schritt 1 wird in drei Modulpakete umgesetzt:
1. M1 Kontaktmodul: Datenmodell, API/Service, Basisspeicherung
2. M2 Bestellmodul: Bestellung + Positionen + optionale Kontaktverknuepfung + Upsert
3. M3 Lagermodul: Charge, MHD, Bewegungen, Bestandssicht, Rueckverfolgung zur Bestellung
Operative Ablaufdetails liegen unter:
`/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/PROCESS_PHASE1.md`
Komponenten-Spezifikation liegt unter:
`/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/SPEC_PHASE1_COMPONENTS.md`
DB-Migrationsdetails liegen unter:
`/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/db/README.md`
Erfolgskriterien Schritt 1:
1. Eine Bestellung kann mit Kunden-/Lieferkontakt oder als Direktverkauf ohne Kontakt erfasst werden.
2. Positionen koennen einer oder mehreren Chargen zugeordnet werden.
3. Lagerzugaenge/-abgaenge aktualisieren Bestand pro Charge konsistent.
4. Rueckverfolgung Bestellung <-> Charge <-> Lagerbewegung ist technisch vorhanden.
## Entscheidungen Fuer Umsetzung
1. Eindeutige Bestellidentifikation ueber `sales_order.external_ref` (Shop: `BestellungNr`, Direktverkauf: `DIR-...`); Dateneingang via Upsert.
2. Rechnungsadresse ist optional (`NULL` zulaessig), Lieferadresse wird separat gespeichert.
3. Interne Normalisierung von Zahlungs- und Liefermethoden ueber Mapping-Tabellen.
4. Land wird als Klarname und ISO-Code gespeichert.
5. Initial keine serverseitige Adressvalidierung.
6. Keine Buchhaltungs-/Rechnungsfunktion in Schritt 1.
7. Chargenrueckverfolgung wird ueber explizite Bestellpositions-Allokation aufgebaut.
8. Lagerbetrieb mit genau einer aktiven und einer vorbereiteten Charge pro Produkt (`current` + `open`).
9. Storno-Fall wird im Modell vorbereitet (Status `cancelled` + Freigabe von Reservierungen).
10. Chargennummer fuer neu vorbereitete Charge wird initial manuell durch Mitarbeitende gesetzt.
11. Abverkaufprognose und Warnlogik werden in die ERP-Datenbank uebernommen; E-Mail-Erinnerungen sind in Phase 1 deaktiviert.

221
docs/PROCESS_PHASE1.md Normal file
View File

@@ -0,0 +1,221 @@
# Phase 1 Process Spec
## Dokumentstatus
1. Typ: `operational`
2. Detaillierungsgrad: Event- und Ablaufebene
3. Zugehoerige Komponenten-Spezifikation: `/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/SPEC_PHASE1_COMPONENTS.md`
4. Normative Quelle: `/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/CONCEPT.md`
## Zweck
Diese Datei ist die operative Umsetzungsvorlage fuer Schritt 1. Sie uebersetzt die fachlichen Entscheidungen in konkrete Ablaufregeln auf Event-Ebene.
## Geltungsbereich
1. Bestellimport aus Wix via n8n Webhook
2. Kontakt- und Adressanlage
3. Chargenzuordnung und Lagerbewegung
4. Teil-/Vollstorno inklusive Gegenbuchung
5. Outbound-Webhook ERP -> n8n
6. Direktverkauf als manuelle Sammelerfassung (ohne Kundenregistrierung)
## Begriffe
1. `allocated`: Charge ist zugeordnet und Lagerabgang ist bereits gebucht.
2. `reserved`: Optionaler Zwischenstatus, in Phase 1 standardmaessig nicht verwendet.
3. `open`-Charge: vorbereitete naechste Charge je Produkt.
4. `current`-Charge: aktive Abgangscharge je Produkt.
5. `closed`-Charge: abgeschlossene Charge.
6. `order_source`: Herkunft der Bestellung (`wix` oder `direct`).
## Harte Invarianten
1. Pro Produkt genau eine `current`-Charge.
2. Pro Produkt genau eine `open`-Charge.
3. `open` darf nie fehlen.
4. Verkaufsabgang nie ohne Charge.
5. Negativbestand auf Charge ist nicht erlaubt.
6. Datensaetze werden nicht geloescht; Statusaenderung statt Delete.
7. Direktverkaeufe haben `external_ref` mit Praefix `DIR-`.
## Prozess A: `order.imported` (Inbound Webhook, Quelle `wix`)
### Input
Webhook-JSON aus dem Shop (Bestellung + lineItems).
### Schritte
1. Idempotenz pruefen ueber `BestellungNr` (`sales_order.external_ref`).
2. `sales_order` per Upsert speichern (`order_source = wix`, `payment_status = paid`).
3. `party`, `contact`, `address` speichern/aktualisieren.
4. Rechnungsadresse darf `NULL` sein; Lieferadresse normal speichern.
5. Pro `lineItem` internen `sellable_item` aufloesen:
1. zuerst ueber `external_article_number`
2. fallback ueber normalisierten Titel
6. Falls kein Mapping existiert:
1. Position trotzdem speichern (`raw_external_*` gesetzt)
2. in Audit als Mapping-Luecke markieren
7. Fuer gemappte Position jede `sellable_item_component` aufloesen.
8. Fuer jedes benoetigte Lagerprodukt Abgang auf `current`-Charge buchen:
1. `stock_move` mit `move_type = out`
2. `sales_order_line_lot_allocation` mit `allocation_status = allocated`
9. Chargensaldo gegen `v_stock_lot_balance` pruefen (kein negativer Bestand).
10. Wenn `current` nach Abgang `qty_net <= 0` hat:
1. alte `current` auf `closed`
2. vorhandene `open` auf `current`
3. neue `open` automatisch erzeugen (ohne `lot_number`)
11. Outbound-Event in Queue schreiben: `order.imported`.
12. Audit-Log schreiben (import + allocation + auto-switch falls erfolgt).
### Ergebnis
Bestellung ist vollstaendig erfasst, Chargen sind zugeordnet, Lager ist aktualisiert.
## Prozess E: `direct.sale.captured` (manuelle ERP-Erfassung)
### Trigger
Tages-/Sammelverkauf wird im ERP erfasst (z. B. Marktverkauf).
### Schritte
1. ERP erzeugt interne Bestellnummer mit Praefix `DIR-` (z. B. `DIR-20260329-00017`).
2. `sales_order` speichern mit:
1. `order_source = direct`
2. `party_id = NULL` (Laufkundschaft ohne Kontaktanlage)
3. `payment_status = paid`
3. Mengen je Produkt als `sales_order_line` speichern.
4. Gesamtpreis brutto wird auf Gesamtmenge verteilt und als `unit_price` je Zeile gespeichert.
5. Zahlungsart wird aus den Direktverkauf-Methoden gespeichert (`twint`, `cash`, `paypal`, `bank_transfer`).
6. Lagerabgang und Chargenzuordnung laufen identisch zu Prozess A.
7. Outbound-Event wird wie bei Import in die Queue gestellt (`order.imported` mit `orderSource = direct`).
8. Audit-Log schreibt `action = direct_sale_captured`.
## Prozess B: `order.cancelled.partial`
### Trigger
Teil-Storno fuer einzelne Position oder Teilmenge.
### Schritte
1. Ziel-`sales_order_line` laden, `cancel_qty` validieren.
2. `qty_cancelled` erhoehen, `line_status = partially_cancelled` setzen.
3. Bereits `allocated` Menge in gleicher Hoehe rueckbuchen:
1. `stock_move` mit `move_type = adjustment` und positivem Eingang
2. gleiche `lot_id` wie Ursprungsallokation
4. Zugehoerige `sales_order_line_lot_allocation` auf `cancelled` setzen oder anteilig splitten.
5. Wenn Gesamtstorno der Position erreicht ist, `line_status = cancelled`.
6. Wenn alle Positionen storniert sind, `sales_order.order_status = cancelled`.
7. Outbound-Event in Queue schreiben: `order.cancelled.partial`.
8. Audit-Log schreiben.
## Prozess C: `order.cancelled.full`
### Trigger
Vollstorno der Bestellung.
### Schritte
1. Alle offenen Positionen iterieren.
2. Je Position Restmenge stornieren wie in Prozess B.
3. `sales_order.order_status = cancelled`, `cancelled_at`, `cancelled_reason` setzen.
4. Outbound-Event in Queue schreiben: `order.cancelled.full`.
5. Audit-Log schreiben.
## Prozess D: `lot.auto_switched`
### Trigger
Nach Abgang ist `current`-Charge leer (`qty_net <= 0`).
### Schritte
1. Alte `current` auf `closed` setzen.
2. Existierende `open` auf `current` setzen.
3. Neue `open` fuer dasselbe Produkt anlegen:
1. `lot_number = NULL`
2. `status = open`
4. Outbound-Event in Queue schreiben: `lot.auto_switched`.
5. Audit-Log schreiben.
## Outbound Webhook ERP -> n8n
### Ziel
n8n erhaelt statusrelevante Bestell- und Chargenupdates aus dem ERP.
### Delivery Model
1. Outbox/Queue in DB: `outbound_webhook_event`.
2. Worker liest `pending`/`failed` nach `next_attempt_at`.
3. HTTP POST an n8n Incoming Webhook.
4. Bei 2xx: `sent`.
5. Bei Fehler: Retry mit Backoff, danach `dead_letter`.
### Security
1. Header `X-ERP-Signature`: HMAC-SHA256 ueber Raw Body.
2. Header `X-ERP-Event`: Eventtyp.
3. Header `X-ERP-Event-Key`: Idempotenzschluessel.
### Event Payload (Basis)
```json
{
"eventType": "order.imported",
"eventKey": "order.imported:10466:2026-03-29T17:00:00Z",
"occurredAt": "2026-03-29T17:00:00Z",
"order": {
"externalRef": "10466",
"orderSource": "wix",
"orderStatus": "imported",
"paymentStatus": "paid",
"amounts": {
"net": 49.95,
"shipping": 4.95,
"tax": 0,
"discount": 0,
"total": 54.90,
"currency": "CHF"
}
},
"lines": [
{
"lineNo": 1,
"qty": 1,
"qtyCancelled": 0,
"status": "allocated",
"allocations": [
{
"productSku": "REISHI_FLASCHE",
"lotNumber": "2412.003",
"qty": 1
}
]
}
]
}
```
## Retry Policy (Standardannahme)
1. Maximal 10 Versuche.
2. Exponential Backoff: 1m, 2m, 4m, 8m, ... bis 12h.
3. Danach `dead_letter` + operativer Alert.
## Abverkauf-Warnung (Phase 1)
1. Die Prognose wird im ERP intern berechnet (`fn_refresh_sellout_forecast`).
2. Ausgabe erfolgt als Felder/Status fuer die UI (`sellout_date`, `warning_state`), nicht per E-Mail.
3. Warnlogik: `due_60d` bei Abverkaufdatum in <= 60 Tagen, `due_now` bei heute/ueberfaellig.
## Offene Details (werden spaeter mit realem n8n-Setup finalisiert)
1. Finale n8n Ziel-URL pro Umgebung (dev/staging/prod).
2. Secret-Rotation fuer Signatur.
3. Dead-letter-Verarbeitung (manuell oder Requeue-Button).
4. Exakte Liste weiterer Events fuer spaetere Module.

25
docs/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Docs Index
This folder is the single home for project documentation.
## Source Of Truth
- `CONCEPT.md` is the normative source for product and system concepts.
## Document Types
- Normative: `CONCEPT.md`
- Operational:
- `SPEC_PHASE1_COMPONENTS.md` (Komponenten-Spezifikation fuer Schritt 1)
- `PROCESS_PHASE1.md` (Ablauflogik fuer Import, Lager, Storno, Outbound-Webhook)
- Derived:
- `SCHEMA_PHASE1.sql` (Draft schema for Schritt 1)
- `../db/migrations/*.sql` (Ausfuehrbare Datenbankmigrationen)
- Historical: none yet
- Legacy: none yet
## How We Use This Folder
- Keep the docs root small and scannable.
- Add new operational docs only when there is a running process to describe.
- If a file only repeats the concept, move it out of the active root or delete it.

347
docs/SCHEMA_PHASE1.sql Normal file
View File

@@ -0,0 +1,347 @@
-- ERP Naurua - Phase 1 Draft Schema
-- Status: Draft (Intentional minimal constraints; to be hardened during implementation)
-- Target DB: PostgreSQL-compatible SQL
-- Scope: Lagerverwaltung (Chargen/MHD), Bestellerfassung, Kontaktangaben
-- NOTE
-- - Use this as a starting point for migrations, not as final truth.
-- - Add stricter constraints once business rules are confirmed.
CREATE TABLE party (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'customer', -- customer | supplier | both
name TEXT,
email TEXT,
phone TEXT,
phone_alt TEXT,
tax_id TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_party_email ON party(email);
CREATE TABLE address (
id BIGSERIAL PRIMARY KEY,
party_id BIGINT NOT NULL REFERENCES party(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- billing | shipping
first_name TEXT,
last_name TEXT,
company_name TEXT,
street TEXT,
house_number TEXT,
zip TEXT,
city TEXT,
state_code TEXT,
country_name TEXT,
country_iso2 CHAR(2),
raw_payload JSONB, -- preserves source formatting variants
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_address_party ON address(party_id);
CREATE INDEX idx_address_country_iso2 ON address(country_iso2);
CREATE TABLE contact (
id BIGSERIAL PRIMARY KEY,
party_id BIGINT NOT NULL REFERENCES party(id) ON DELETE CASCADE,
first_name TEXT,
last_name TEXT,
email TEXT,
phone TEXT,
position_title TEXT,
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_contact_party ON contact(party_id);
-- Lagergefuehrtes Produkt (Bestandsfuehrung, Charge, MHD)
CREATE TABLE product (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
uom TEXT NOT NULL DEFAULT 'unit',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_product_sku ON product(sku);
-- Verkaufbarer Shop-Artikel (kann Bundle sein)
CREATE TABLE sellable_item (
id BIGSERIAL PRIMARY KEY,
item_code TEXT NOT NULL, -- interne stabile Kennung
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_sellable_item_code ON sellable_item(item_code);
-- Mapping externer Shop-Daten auf internen Artikel
CREATE TABLE external_item_alias (
id BIGSERIAL PRIMARY KEY,
source_system TEXT NOT NULL DEFAULT 'wix',
external_article_number TEXT,
external_title TEXT,
title_normalized TEXT,
sellable_item_id BIGINT NOT NULL REFERENCES sellable_item(id) ON DELETE CASCADE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ext_alias_article_number ON external_item_alias(external_article_number);
CREATE INDEX idx_ext_alias_title_norm ON external_item_alias(title_normalized);
-- Stueckliste: welcher Artikel enthaelt welche lagergefuehrten Produkte
CREATE TABLE sellable_item_component (
id BIGSERIAL PRIMARY KEY,
sellable_item_id BIGINT NOT NULL REFERENCES sellable_item(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES product(id) ON DELETE RESTRICT,
qty_per_item NUMERIC(14, 4) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_component_qty_positive CHECK (qty_per_item > 0)
);
CREATE UNIQUE INDEX uq_item_component ON sellable_item_component(sellable_item_id, product_id);
CREATE INDEX idx_item_component_product ON sellable_item_component(product_id);
CREATE TABLE warehouse (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_warehouse_code ON warehouse(code);
CREATE TABLE location (
id BIGSERIAL PRIMARY KEY,
warehouse_id BIGINT NOT NULL REFERENCES warehouse(id) ON DELETE RESTRICT,
code TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- storage | receiving | dispatch | adjustment
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_location_warehouse ON location(warehouse_id);
CREATE UNIQUE INDEX uq_location_code_per_warehouse ON location(warehouse_id, code);
CREATE TABLE stock_lot (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES product(id) ON DELETE RESTRICT,
lot_number TEXT,
mfg_date DATE,
expiry_date DATE,
status TEXT NOT NULL DEFAULT 'open', -- open | current | closed
sellout_date DATE,
warning_state TEXT NOT NULL DEFAULT 'none', -- none | due_60d | due_now
supplier_lot_number TEXT,
supplier_name TEXT,
purchase_date DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_stock_lot_status CHECK (status IN ('open', 'current', 'closed')),
CONSTRAINT chk_stock_lot_warning_state CHECK (warning_state IN ('none', 'due_60d', 'due_now')),
CONSTRAINT chk_stock_lot_number_required_for_non_open CHECK (
status = 'open' OR lot_number IS NOT NULL
)
);
CREATE INDEX idx_stock_lot_product ON stock_lot(product_id);
CREATE UNIQUE INDEX uq_stock_lot_product_number ON stock_lot(product_id, lot_number);
CREATE UNIQUE INDEX uq_stock_lot_one_current_per_product ON stock_lot(product_id) WHERE status = 'current';
CREATE UNIQUE INDEX uq_stock_lot_one_open_per_product ON stock_lot(product_id) WHERE status = 'open';
CREATE TABLE payment_method (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
label TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_payment_method_code ON payment_method(code);
CREATE TABLE shipping_method (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
label TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_shipping_method_code ON shipping_method(code);
CREATE TABLE sales_order (
id BIGSERIAL PRIMARY KEY,
external_ref TEXT NOT NULL, -- Wix BestellungNr oder ERP-Ref (DIR-...)
order_source TEXT NOT NULL DEFAULT 'wix', -- wix | direct
party_id BIGINT REFERENCES party(id) ON DELETE RESTRICT, -- bei direct optional
order_date TIMESTAMP NOT NULL DEFAULT NOW(),
order_status TEXT NOT NULL DEFAULT 'received', -- received | imported | fulfilled | cancelled
payment_status TEXT NOT NULL DEFAULT 'paid', -- phase 1 fuehrt nur bezahlte Auftraege
payment_method_id BIGINT REFERENCES payment_method(id) ON DELETE RESTRICT,
shipping_method_id BIGINT REFERENCES shipping_method(id) ON DELETE RESTRICT,
amount_net NUMERIC(14, 2),
amount_shipping NUMERIC(14, 2),
amount_tax NUMERIC(14, 2),
amount_discount NUMERIC(14, 2),
total_amount NUMERIC(14, 2),
currency TEXT NOT NULL DEFAULT 'CHF',
webhook_payload JSONB,
imported_at TIMESTAMP NOT NULL DEFAULT NOW(),
cancelled_at TIMESTAMP,
cancelled_reason TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_sales_order_status CHECK (order_status IN ('received', 'imported', 'fulfilled', 'cancelled')),
CONSTRAINT chk_sales_order_payment_status CHECK (payment_status IN ('paid')),
CONSTRAINT chk_sales_order_source CHECK (order_source IN ('wix', 'direct')),
CONSTRAINT chk_sales_order_direct_ref_prefix CHECK (
order_source <> 'direct' OR external_ref LIKE 'DIR-%'
)
);
CREATE UNIQUE INDEX uq_sales_order_external_ref ON sales_order(external_ref);
CREATE INDEX idx_sales_order_party ON sales_order(party_id);
CREATE INDEX idx_sales_order_source ON sales_order(order_source);
CREATE TABLE sales_order_line (
id BIGSERIAL PRIMARY KEY,
sales_order_id BIGINT NOT NULL REFERENCES sales_order(id) ON DELETE CASCADE,
line_no INTEGER NOT NULL,
sellable_item_id BIGINT REFERENCES sellable_item(id) ON DELETE RESTRICT,
raw_external_article_number TEXT,
raw_external_title TEXT,
qty NUMERIC(14, 4) NOT NULL,
qty_cancelled NUMERIC(14, 4) NOT NULL DEFAULT 0,
line_status TEXT NOT NULL DEFAULT 'allocated', -- allocated | partially_cancelled | cancelled
unit_price NUMERIC(14, 4),
line_total NUMERIC(14, 2),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_sales_order_line_qty_positive CHECK (qty > 0),
CONSTRAINT chk_sales_order_line_qty_cancelled_range CHECK (qty_cancelled >= 0 AND qty_cancelled <= qty),
CONSTRAINT chk_sales_order_line_status CHECK (line_status IN ('allocated', 'partially_cancelled', 'cancelled'))
);
CREATE UNIQUE INDEX uq_sales_order_line_no ON sales_order_line(sales_order_id, line_no);
CREATE INDEX idx_sales_order_line_order ON sales_order_line(sales_order_id);
CREATE INDEX idx_sales_order_line_sellable_item ON sales_order_line(sellable_item_id);
CREATE INDEX idx_sales_order_line_status ON sales_order_line(line_status);
CREATE TABLE stock_move (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES product(id) ON DELETE RESTRICT,
lot_id BIGINT NOT NULL REFERENCES stock_lot(id) ON DELETE RESTRICT,
from_location_id BIGINT REFERENCES location(id) ON DELETE RESTRICT,
to_location_id BIGINT REFERENCES location(id) ON DELETE RESTRICT,
qty NUMERIC(14, 4) NOT NULL,
move_type TEXT NOT NULL, -- in | out | transfer | adjustment
move_date TIMESTAMP NOT NULL DEFAULT NOW(),
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_stock_move_qty_positive CHECK (qty > 0),
CONSTRAINT chk_stock_move_type CHECK (move_type IN ('in', 'out', 'transfer', 'adjustment'))
);
CREATE INDEX idx_stock_move_product ON stock_move(product_id);
CREATE INDEX idx_stock_move_lot ON stock_move(lot_id);
CREATE INDEX idx_stock_move_from_location ON stock_move(from_location_id);
CREATE INDEX idx_stock_move_to_location ON stock_move(to_location_id);
-- Explizite Rueckverfolgung: welche Charge wurde fuer welche Bestellposition verwendet
CREATE TABLE sales_order_line_lot_allocation (
id BIGSERIAL PRIMARY KEY,
sales_order_line_id BIGINT NOT NULL REFERENCES sales_order_line(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES product(id) ON DELETE RESTRICT,
lot_id BIGINT NOT NULL REFERENCES stock_lot(id) ON DELETE RESTRICT,
qty NUMERIC(14, 4) NOT NULL,
allocation_status TEXT NOT NULL DEFAULT 'allocated', -- reserved | allocated | released | cancelled
released_at TIMESTAMP,
stock_move_id BIGINT REFERENCES stock_move(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_line_lot_qty_positive CHECK (qty > 0),
CONSTRAINT chk_line_lot_alloc_status CHECK (allocation_status IN ('reserved', 'allocated', 'released', 'cancelled'))
);
CREATE INDEX idx_line_lot_alloc_line ON sales_order_line_lot_allocation(sales_order_line_id);
CREATE INDEX idx_line_lot_alloc_product ON sales_order_line_lot_allocation(product_id);
CREATE INDEX idx_line_lot_alloc_lot ON sales_order_line_lot_allocation(lot_id);
CREATE INDEX idx_line_lot_alloc_stock_move ON sales_order_line_lot_allocation(stock_move_id);
CREATE INDEX idx_line_lot_alloc_status ON sales_order_line_lot_allocation(allocation_status);
-- Berechnete Chargensalden (Lagereingang/-ausgang/-netto)
CREATE VIEW v_stock_lot_balance AS
SELECT
sl.id AS stock_lot_id,
sl.product_id,
COALESCE(SUM(CASE WHEN sm.move_type = 'in' THEN sm.qty ELSE 0 END), 0) AS qty_in,
COALESCE(SUM(CASE WHEN sm.move_type = 'out' THEN sm.qty ELSE 0 END), 0) AS qty_out,
COALESCE(SUM(
CASE
WHEN sm.move_type = 'in' THEN sm.qty
WHEN sm.move_type = 'out' THEN -sm.qty
ELSE 0
END
), 0) AS qty_net
FROM stock_lot sl
LEFT JOIN stock_move sm ON sm.lot_id = sl.id
GROUP BY sl.id, sl.product_id;
-- Operative Guardrail-Trigger:
-- 1) keine negativen Chargensalden
-- 2) wenn aktuelle Charge <= 0, auf offene Charge umschalten und neue offene Charge vorbereiten
-- Hinweis: Die konkrete Trigger-Implementierung erfolgt in der Migrationsphase.
-- Standard Audit Trail (vorbereitet fuer spaetere Module)
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
entity_name TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL, -- insert | update | delete | import | allocate
changed_by TEXT,
changed_at TIMESTAMP NOT NULL DEFAULT NOW(),
before_data JSONB,
after_data JSONB,
context JSONB
);
CREATE INDEX idx_audit_log_entity ON audit_log(entity_name, entity_id);
CREATE INDEX idx_audit_log_changed_at ON audit_log(changed_at);
-- Outbound-Webhook Queue (ERP -> n8n)
CREATE TABLE outbound_webhook_event (
id BIGSERIAL PRIMARY KEY,
event_type TEXT NOT NULL, -- order.imported | order.cancelled.partial | order.cancelled.full | lot.auto_switched
event_key TEXT NOT NULL, -- idempotency key for consumers
aggregate_type TEXT NOT NULL, -- sales_order | stock_lot | sales_order_line
aggregate_id TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | processing | sent | failed | dead_letter
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_attempt_at TIMESTAMP,
last_error TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
sent_at TIMESTAMP,
CONSTRAINT chk_outbound_webhook_status CHECK (status IN ('pending', 'processing', 'sent', 'failed', 'dead_letter'))
);
CREATE UNIQUE INDEX uq_outbound_webhook_event_key ON outbound_webhook_event(event_key);
CREATE INDEX idx_outbound_webhook_status_next ON outbound_webhook_event(status, next_attempt_at);

View File

@@ -0,0 +1,264 @@
# Phase 1 Component Specification
## Dokumentstatus
1. Typ: `operational`
2. Zweck: verbindliche Umsetzungs-Spezifikation fuer Schritt 1 Komponenten
3. Normative Referenz: `/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/CONCEPT.md`
4. Abgeleitete Referenz: `/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/SCHEMA_PHASE1.sql`
5. Prozessreferenz: `/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/PROCESS_PHASE1.md`
6. Implementierungsreferenz: `/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/db/migrations/`
## Scope
Diese Spezifikation deckt alle Schritt-1-Komponenten ab:
1. Bestellerfassung
2. Kontakt- und Adressdaten
3. Artikel-Mapping und Stuecklisten
4. Lagerverwaltung mit Chargen, MHD, Zu-/Abgaengen
5. Chargenrueckverfolgung
6. Teil-/Vollstorno
7. Inbound/Outbound Webhooks
8. Audit und technische Guardrails
## Globale Invarianten
1. `sales_order.external_ref` ist eindeutig und dient als Upsert-Schluessel (Shop: `BestellungNr`, Direktverkauf: `DIR-...`).
2. Datensaetze werden nicht geloescht; Statusaenderungen und Audit-Trail werden verwendet.
3. Verkaufsbewegungen sind immer chargengebunden.
4. Negativbestand ist unzulaessig.
5. Pro Produkt existieren exakt eine `current`-Charge und eine `open`-Charge.
6. `open` darf nie fehlen.
7. Zahlungsstatus aus Shop wird intern auf `paid` normalisiert.
8. Direktverkaeufe haben `order_source = direct` und `external_ref` mit `DIR-`-Praefix.
## Komponente 1: Bestellerfassung
### Ziel
Persistente Erfassung von Bestellungen aus Shop und Direktverkauf inkl. Positionen, Summen und Import-/Erfassungsdaten.
### Eingangsquelle
1. n8n Inbound Webhook mit Shop-JSON (`order_source = wix`)
2. Manuelle ERP-Erfassung fuer Direktverkauf (`order_source = direct`)
### Datenregeln
1. Upsert auf `sales_order.external_ref` fuer Shop-Bestellungen.
2. Direktbestellungen erhalten ERP-interne Nummern mit `DIR-`-Praefix.
3. `order_status` initial `imported`.
4. Summenfelder werden direkt gespeichert (`amount_net`, `amount_shipping`, `amount_tax`, `amount_discount`, `total_amount`).
5. `webhook_payload` wird fuer Shop-Rohdaten gespeichert (Nachvollziehbarkeit).
6. Fuer Direktverkauf darf `party_id` leer sein (Laufkundschaft).
### Fehlerverhalten
1. Bei fehlender `BestellungNr`: Import ablehnen, Audit-Log `import_rejected`.
2. Bei unbekanntem Artikelfehler: Bestellung speichern, Position mit Rohdaten speichern, Mapping-Luecke markieren.
### Akzeptanzkriterien
1. Wiederholter Import derselben `BestellungNr` erzeugt kein Duplikat.
2. Direktbestellungen sind ueber den `DIR-`-Praefix eindeutig erkennbar.
3. Positionsdaten bleiben stabil und auditierbar.
## Komponente 2: Kontakt- und Adressdaten
### Ziel
Speicherung von Kundenkontakt, Lieferadresse und optionaler Rechnungsadresse.
### Datenregeln
1. Rechnungsadresse darf vollstaendig `NULL` sein.
2. Lieferadresse wird als eigener Datensatz gespeichert.
3. Land wird immer doppelt gespeichert: `country_name` + `country_iso2`.
4. Keine serverseitige Adressvalidierung in Phase 1.
5. Ausnahmefaelle bei Name/Firma werden unveraendert uebernommen.
6. Bei Direktverkauf ist keine Kontakt-/Adressanlage erforderlich.
### Akzeptanzkriterien
1. Bestellung kann ohne Rechnungsadresse gespeichert werden.
2. Liefer- und Rechnungsadresse sind getrennt auswertbar.
## Komponente 3: Artikel-Mapping und Stuecklisten
### Ziel
Stabile Entkopplung von Shop-Artikeln und lagergefuehrten Produkten.
### Datenmodell
1. `sellable_item`: verkaufbarer Artikel (auch Bundles).
2. `external_item_alias`: Mapping Shop-Artikelnummer/Titel -> `sellable_item`.
3. `sellable_item_component`: Stueckliste `sellable_item` -> `product` mit Menge.
### Aufloesungsreihenfolge
1. Match ueber `external_article_number`.
2. Fallback ueber normalisierten Titel.
3. Wenn beides fehlschlaegt: Position als unmapped speichern und auditieren.
### Akzeptanzkriterien
1. Bundle-Artikel koennen auf mehrere Lagerprodukte aufgeloest werden.
2. Ohne Mapping bleibt Bestellung dennoch erfassbar.
## Komponente 4: Lagerverwaltung und Chargen
### Ziel
Konsistente Bestandsfuehrung je Charge inklusive Auto-Wechsel.
### Statusmodell Charge
1. `open`: vorbereitete Charge
2. `current`: aktive Entnahmecharge
3. `closed`: abgeschlossene Charge
### Lebenszyklus
1. `open -> current -> closed`
2. Wenn `current` auf `qty_net <= 0` faellt:
1. alte `current` wird `closed`
2. vorhandene `open` wird `current`
3. neue `open` wird auto-angelegt
### Feldregeln
1. `lot_number` bei `open` darf leer sein.
2. `lot_number` fuer `current/closed` ist Pflicht.
3. Chargennummer ist pro Produkt eindeutig.
4. Chargensalden werden aus `stock_move` berechnet (`v_stock_lot_balance`).
5. Abverkaufprognose pro aktueller Charge wird im System gepflegt (`sellout_date`, `warning_state`).
### Korrekturregeln
1. Korrekturen erfolgen ueber `adjustment`-Bewegungen.
2. Keine direkten Netto-Setzungen als Betriebsprozess.
### Akzeptanzkriterien
1. Nach jeder Entnahme bleibt die Invariante `1x current + 1x open` erhalten.
2. Negativbestand wird technisch verhindert.
3. Warnstatus fuer UI ist ohne E-Mail nutzbar (`none`, `due_60d`, `due_now`).
## Komponente 5: Chargenrueckverfolgung
### Ziel
Lueckenlose Rueckverfolgung Bestellung -> Position -> Produkt -> Charge -> Lagerbewegung.
### Datenregeln
1. Jede Entnahme schreibt `stock_move` (`move_type = out`) mit `lot_id`.
2. Pro Entnahme wird `sales_order_line_lot_allocation` geschrieben.
3. `allocation_status` in Phase 1 standardmaessig `allocated`.
### Akzeptanzkriterien
1. Fuer jede ausgelieferte Position ist die verwendete Charge abfragbar.
2. Rueckverfolgung funktioniert auch nach Teilstorno.
## Komponente 6: Storno (Teil/Voll)
### Ziel
Revisionssicheres Storno ohne physisches Loeschen.
### Datenregeln
1. Teil- und Vollstorno sind erlaubt.
2. `sales_order_line.qty_cancelled` wird fortgeschrieben.
3. `line_status`: `allocated`, `partially_cancelled`, `cancelled`.
4. Bei Storno von bereits `allocated` Mengen erfolgt Gegenbuchung als `adjustment in` auf dieselbe Charge.
5. Bestellung bleibt erhalten; Statuswechsel statt Delete.
### Akzeptanzkriterien
1. Historische Ursprungs- und Korrekturbewegungen bleiben sichtbar.
2. Vollstorno setzt `sales_order.order_status = cancelled`.
## Komponente 7: Webhooks
### Inbound (n8n -> ERP)
1. Transport: JSON via HTTP.
2. Idempotenzschluessel: `BestellungNr`.
3. Verarbeitung: Upsert Bestellung, Kontakt, Positionen, Lagerabgang, Audit.
4. Quelle wird als `order_source = wix` gespeichert.
### Direkt (ERP intern)
1. Transport: manuelle Erfassung in ERP-Maske.
2. Nummernlogik: ERP erzeugt `external_ref` mit `DIR-`-Praefix.
3. Verarbeitung: Bestellung (optional ohne Kontakt), Positionen, Lagerabgang, Audit.
### Outbound (ERP -> n8n)
1. Transport: Queue-basierter POST ueber `outbound_webhook_event`.
2. Events Phase 1:
1. `order.imported`
2. `order.cancelled.partial`
3. `order.cancelled.full`
4. `lot.auto_switched`
3. Signatur: `X-ERP-Signature` (HMAC-SHA256).
4. Zustellung: Retry mit Backoff; final `dead_letter`.
### Akzeptanzkriterien
1. Event-Zustellung ist idempotent (`event_key` eindeutig).
2. Fehlgeschlagene Events sind operativ auffindbar.
3. Outbound-Payload enthaelt die Quelle (`order_source`), damit `wix` und `direct` getrennt auswertbar sind.
## Komponente 8: Audit und Betriebssicherheit
### Audit
1. Jede fachliche Aenderung schreibt `audit_log`.
2. Pflichtfelder: `entity_name`, `entity_id`, `action`, `changed_at`.
3. Vorher/Nachher-Daten werden als JSON gespeichert, wenn verfuegbar.
### Guardrails
1. Check-Constraints fuer Statuswerte.
2. Check-Constraints fuer Mengenbereiche.
3. Unique-Constraints fuer kritische Eindeutigkeiten.
4. Outbox-Statusmaschine fuer robuste externe Zustellung.
## Komponenten-Matrix
1. `Bestellerfassung`
Zustandsquelle: `sales_order`, `sales_order_line`
Hauptereignisse: `order.imported`, `order.cancelled.*`
2. `Kontakt/Adressen`
Zustandsquelle: `party`, `contact`, `address`
Hauptereignisse: `order.imported`
3. `Artikel-Mapping`
Zustandsquelle: `sellable_item`, `external_item_alias`, `sellable_item_component`
Hauptereignisse: `order.imported`
4. `Lagerverwaltung`
Zustandsquelle: `stock_lot`, `stock_move`, `v_stock_lot_balance`
Hauptereignisse: `order.imported`, `lot.auto_switched`, `order.cancelled.*`
5. `Rueckverfolgung`
Zustandsquelle: `sales_order_line_lot_allocation`
Hauptereignisse: alle Abgaenge und Stornos
6. `Outbound-Integration`
Zustandsquelle: `outbound_webhook_event`
Hauptereignisse: alle publizierten Domain-Events
## Nicht in Phase 1
1. Rechnungswesen, Buchungssaetze, OCR-Verarbeitung.
2. Automatisierte Preis-/Steuerneuberechnung.
3. Vollautomatische Chargennummerngenerierung nach Produktklasse.
## Change Governance
1. Konzeptaenderungen zuerst in `CONCEPT.md`.
2. Prozess- und Betriebsablauf in `PROCESS_PHASE1.md`.
3. Technische Ableitung in `SCHEMA_PHASE1.sql`.
4. Diese Spezifikation synchronisiert alle Komponenten auf Umsetzungsniveau.