Add ERP order import endpoint and n8n order-ingest flow export
This commit is contained in:
262
docs/CONCEPT.md
Normal file
262
docs/CONCEPT.md
Normal 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
221
docs/PROCESS_PHASE1.md
Normal 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
25
docs/README.md
Normal 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
347
docs/SCHEMA_PHASE1.sql
Normal 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);
|
||||
264
docs/SPEC_PHASE1_COMPONENTS.md
Normal file
264
docs/SPEC_PHASE1_COMPONENTS.md
Normal 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.
|
||||
Reference in New Issue
Block a user