diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12d4015 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Environment files +.env +.env.* +!.env.example + +# macOS +.DS_Store diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..56192f3 --- /dev/null +++ b/db/README.md @@ -0,0 +1,55 @@ +# Database Migrations + +## Reihenfolge + +1. `db/migrations/0001_phase1_core.sql` +2. `db/migrations/0002_phase1_seed_methods.sql` +3. `db/migrations/0003_phase1_inventory_forecast.sql` +4. `db/migrations/0004_phase1_direct_sales.sql` + +## Beispielausfuehrung + +```bash +psql "$DATABASE_URL" -f db/migrations/0001_phase1_core.sql +psql "$DATABASE_URL" -f db/migrations/0002_phase1_seed_methods.sql +psql "$DATABASE_URL" -f db/migrations/0003_phase1_inventory_forecast.sql +psql "$DATABASE_URL" -f db/migrations/0004_phase1_direct_sales.sql +``` + +## Enthaltene Kernlogik in `0001` + +1. Phase-1 Tabellen, Indizes und Constraints +2. Bestands-View `v_stock_lot_balance` +3. Trigger fuer: + 1. Negativbestand-Schutz + 2. Auto-Anlage von `current` + `open` bei neuem Produkt + 3. Auto-Status fuer Bestellpositionen (`allocated`/`partially_cancelled`/`cancelled`) + 4. Auto-Chargenwechsel bei leerer `current`-Charge + 5. Outbox-Events fuer Outbound-Webhook + +## Hinweis + +ENV-Werte fuer Outbound-Zustellung (n8n URL/Secret) werden spaeter von der Applikation/Worker genutzt. Die DB-Migration legt dafuer die Outbox-Struktur bereits an. + +## Abverkaufdatum und Warnung + +Die Migration `0003` fuehrt die interne Prognosefunktion ein: + +```sql +SELECT fn_refresh_sellout_forecast(60, 60); +``` + +UI-Read-Modell: + +```sql +SELECT * FROM v_stock_lot_ui_alerts ORDER BY product_name, status DESC, lot_number; +``` + +## Direktverkauf (Migration `0004`) + +`0004` erweitert Bestellungen um `order_source` (`wix` | `direct`) und erlaubt Direktverkaeufe ohne Kontaktzuordnung (`party_id = NULL`). Zusaetzlich: + +1. Guardrail: Direktbestellungen muessen `external_ref` mit `DIR-`-Praefix haben. +2. Generator-Funktion: `fn_next_direct_sales_order_ref(...)`. +3. Trigger: automatische Vergabe einer `DIR-...` Nummer, falls leer. +4. Zahlungsarten fuer Direktverkauf: `cash`, `paypal` (zus. zu bestehenden Methoden). diff --git a/db/migrations/0001_phase1_core.sql b/db/migrations/0001_phase1_core.sql new file mode 100644 index 0000000..bfdb9e4 --- /dev/null +++ b/db/migrations/0001_phase1_core.sql @@ -0,0 +1,699 @@ +BEGIN; + +-- ERP Naurua Phase 1 core schema (executable migration) +-- PostgreSQL target. + +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(), + CONSTRAINT chk_party_type CHECK (type IN ('customer', 'supplier', 'both')), + CONSTRAINT chk_party_status CHECK (status IN ('active', 'inactive')) +); + +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, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT chk_address_type CHECK (type IN ('billing', 'shipping')) +); + +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); + +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(), + CONSTRAINT chk_product_status CHECK (status IN ('active', 'inactive')) +); + +CREATE UNIQUE INDEX uq_product_sku ON product(sku); + +CREATE TABLE sellable_item ( + id BIGSERIAL PRIMARY KEY, + item_code TEXT NOT NULL, + 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(), + CONSTRAINT chk_sellable_item_status CHECK (status IN ('active', 'inactive')) +); + +CREATE UNIQUE INDEX uq_sellable_item_code ON sellable_item(item_code); + +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); + +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(), + CONSTRAINT chk_location_type CHECK (type IN ('storage', 'receiving', 'dispatch', 'adjustment')) +); + +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 + 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_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, + party_id BIGINT NOT NULL REFERENCES party(id) ON DELETE RESTRICT, + 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', + 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')) +); + +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 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); + +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); + +CREATE TABLE audit_log ( + id BIGSERIAL PRIMARY KEY, + entity_name TEXT NOT NULL, + entity_id TEXT NOT NULL, + action TEXT NOT NULL, + 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); + +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, + aggregate_type TEXT NOT NULL, + 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); + +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 + WHEN sm.move_type = 'adjustment' AND sm.to_location_id IS NOT NULL AND sm.from_location_id IS NULL THEN sm.qty + WHEN sm.move_type = 'adjustment' AND sm.from_location_id IS NOT NULL AND sm.to_location_id IS NULL 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; + +CREATE OR REPLACE FUNCTION fn_stock_move_delta( + p_move_type TEXT, + p_qty NUMERIC, + p_from_location_id BIGINT, + p_to_location_id BIGINT +) RETURNS NUMERIC +LANGUAGE plpgsql +AS $$ +BEGIN + IF p_move_type = 'in' THEN + RETURN p_qty; + END IF; + + IF p_move_type = 'out' THEN + RETURN -p_qty; + END IF; + + IF p_move_type = 'adjustment' THEN + IF p_to_location_id IS NOT NULL AND p_from_location_id IS NULL THEN + RETURN p_qty; + END IF; + IF p_from_location_id IS NOT NULL AND p_to_location_id IS NULL THEN + RETURN -p_qty; + END IF; + RETURN 0; + END IF; + + RETURN 0; +END; +$$; + +CREATE OR REPLACE FUNCTION fn_stock_move_validate() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_lot_product_id BIGINT; + v_lot_status TEXT; + v_current_net NUMERIC; + v_delta NUMERIC; + v_projected_net NUMERIC; +BEGIN + SELECT product_id, status + INTO v_lot_product_id, v_lot_status + FROM stock_lot + WHERE id = NEW.lot_id; + + IF v_lot_product_id IS NULL THEN + RAISE EXCEPTION 'Stock lot % not found', NEW.lot_id; + END IF; + + IF v_lot_product_id <> NEW.product_id THEN + RAISE EXCEPTION 'stock_move.product_id (%) does not match lot product_id (%)', NEW.product_id, v_lot_product_id; + END IF; + + IF NEW.move_type = 'out' AND v_lot_status <> 'current' THEN + RAISE EXCEPTION 'Outgoing stock move requires lot status current (lot %, status %)', NEW.lot_id, v_lot_status; + END IF; + + IF NEW.move_type = 'in' AND NEW.to_location_id IS NULL THEN + RAISE EXCEPTION 'Incoming stock move requires to_location_id'; + END IF; + + IF NEW.move_type = 'out' AND NEW.from_location_id IS NULL THEN + RAISE EXCEPTION 'Outgoing stock move requires from_location_id'; + END IF; + + IF NEW.move_type = 'transfer' AND (NEW.from_location_id IS NULL OR NEW.to_location_id IS NULL) THEN + RAISE EXCEPTION 'Transfer requires both from_location_id and to_location_id'; + END IF; + + IF NEW.move_type = 'adjustment' AND ( + (NEW.from_location_id IS NULL AND NEW.to_location_id IS NULL) + OR (NEW.from_location_id IS NOT NULL AND NEW.to_location_id IS NOT NULL) + ) THEN + RAISE EXCEPTION 'Adjustment requires exactly one of from_location_id or to_location_id'; + END IF; + + SELECT COALESCE(SUM( + CASE + WHEN move_type = 'in' THEN qty + WHEN move_type = 'out' THEN -qty + WHEN move_type = 'adjustment' AND to_location_id IS NOT NULL AND from_location_id IS NULL THEN qty + WHEN move_type = 'adjustment' AND from_location_id IS NOT NULL AND to_location_id IS NULL THEN -qty + ELSE 0 + END + ), 0) + INTO v_current_net + FROM stock_move + WHERE lot_id = NEW.lot_id; + + v_delta := fn_stock_move_delta(NEW.move_type, NEW.qty, NEW.from_location_id, NEW.to_location_id); + v_projected_net := v_current_net + v_delta; + + IF v_projected_net < 0 THEN + RAISE EXCEPTION 'Negative stock is not allowed for lot % (projected net %)', NEW.lot_id, v_projected_net; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_stock_move_validate +BEFORE INSERT OR UPDATE ON stock_move +FOR EACH ROW +EXECUTE FUNCTION fn_stock_move_validate(); + +CREATE OR REPLACE FUNCTION fn_product_bootstrap_lots() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_current_lot_number TEXT; +BEGIN + v_current_lot_number := format('INIT-%s-001', NEW.id); + + INSERT INTO stock_lot (product_id, lot_number, status) + VALUES (NEW.id, v_current_lot_number, 'current'); + + INSERT INTO stock_lot (product_id, lot_number, status) + VALUES (NEW.id, NULL, 'open'); + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_product_bootstrap_lots +AFTER INSERT ON product +FOR EACH ROW +EXECUTE FUNCTION fn_product_bootstrap_lots(); + +CREATE OR REPLACE FUNCTION fn_sync_sales_order_line_status() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.qty_cancelled = 0 THEN + NEW.line_status := 'allocated'; + ELSIF NEW.qty_cancelled < NEW.qty THEN + NEW.line_status := 'partially_cancelled'; + ELSE + NEW.line_status := 'cancelled'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_sync_sales_order_line_status +BEFORE INSERT OR UPDATE OF qty, qty_cancelled ON sales_order_line +FOR EACH ROW +EXECUTE FUNCTION fn_sync_sales_order_line_status(); + +CREATE OR REPLACE FUNCTION fn_enqueue_event( + p_event_type TEXT, + p_event_key TEXT, + p_aggregate_type TEXT, + p_aggregate_id TEXT, + p_payload JSONB +) RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO outbound_webhook_event ( + event_type, + event_key, + aggregate_type, + aggregate_id, + payload + ) VALUES ( + p_event_type, + p_event_key, + p_aggregate_type, + p_aggregate_id, + p_payload + ) + ON CONFLICT (event_key) DO NOTHING; +END; +$$; + +CREATE OR REPLACE FUNCTION fn_enqueue_sales_order_events() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_event_key TEXT; +BEGIN + IF TG_OP = 'INSERT' THEN + v_event_key := format('order.imported:%s', NEW.external_ref); + PERFORM fn_enqueue_event( + 'order.imported', + v_event_key, + 'sales_order', + NEW.id::TEXT, + jsonb_build_object( + 'orderId', NEW.id, + 'externalRef', NEW.external_ref, + 'orderStatus', NEW.order_status, + 'paymentStatus', NEW.payment_status, + 'occurredAt', NOW() + ) + ); + ELSIF TG_OP = 'UPDATE' THEN + IF OLD.order_status IS DISTINCT FROM NEW.order_status AND NEW.order_status = 'cancelled' THEN + v_event_key := format('order.cancelled.full:%s:%s', NEW.external_ref, COALESCE(NEW.cancelled_at, NOW())); + PERFORM fn_enqueue_event( + 'order.cancelled.full', + v_event_key, + 'sales_order', + NEW.id::TEXT, + jsonb_build_object( + 'orderId', NEW.id, + 'externalRef', NEW.external_ref, + 'orderStatus', NEW.order_status, + 'cancelledAt', COALESCE(NEW.cancelled_at, NOW()), + 'cancelledReason', NEW.cancelled_reason + ) + ); + END IF; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_enqueue_sales_order_events +AFTER INSERT OR UPDATE OF order_status ON sales_order +FOR EACH ROW +EXECUTE FUNCTION fn_enqueue_sales_order_events(); + +CREATE OR REPLACE FUNCTION fn_enqueue_partial_cancel_events() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_order_id BIGINT; + v_external_ref TEXT; + v_event_key TEXT; +BEGIN + IF NEW.line_status = 'partially_cancelled' AND OLD.line_status IS DISTINCT FROM NEW.line_status THEN + SELECT so.id, so.external_ref + INTO v_order_id, v_external_ref + FROM sales_order so + WHERE so.id = NEW.sales_order_id; + + v_event_key := format('order.cancelled.partial:%s:%s:%s', v_external_ref, NEW.id, NEW.qty_cancelled); + + PERFORM fn_enqueue_event( + 'order.cancelled.partial', + v_event_key, + 'sales_order_line', + NEW.id::TEXT, + jsonb_build_object( + 'orderId', v_order_id, + 'externalRef', v_external_ref, + 'lineId', NEW.id, + 'lineNo', NEW.line_no, + 'qty', NEW.qty, + 'qtyCancelled', NEW.qty_cancelled, + 'lineStatus', NEW.line_status, + 'occurredAt', NOW() + ) + ); + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_enqueue_partial_cancel_events +AFTER UPDATE OF line_status, qty_cancelled ON sales_order_line +FOR EACH ROW +EXECUTE FUNCTION fn_enqueue_partial_cancel_events(); + +CREATE OR REPLACE FUNCTION fn_auto_switch_lot_when_depleted() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_product_id BIGINT; + v_open_lot_id BIGINT; + v_current_net NUMERIC; + v_event_key TEXT; +BEGIN + -- only relevant when the updated lot is currently active + SELECT product_id + INTO v_product_id + FROM stock_lot + WHERE id = NEW.lot_id; + + -- if lot is not current anymore, nothing to do + IF NOT EXISTS ( + SELECT 1 FROM stock_lot WHERE id = NEW.lot_id AND status = 'current' + ) THEN + RETURN NEW; + END IF; + + SELECT qty_net + INTO v_current_net + FROM v_stock_lot_balance + WHERE stock_lot_id = NEW.lot_id; + + IF COALESCE(v_current_net, 0) > 0 THEN + RETURN NEW; + END IF; + + -- lock all lots for this product to avoid race conditions + PERFORM 1 + FROM stock_lot + WHERE product_id = v_product_id + FOR UPDATE; + + -- confirm current lot is still current after lock + IF NOT EXISTS ( + SELECT 1 FROM stock_lot WHERE id = NEW.lot_id AND status = 'current' + ) THEN + RETURN NEW; + END IF; + + UPDATE stock_lot + SET status = 'closed', updated_at = NOW() + WHERE id = NEW.lot_id; + + SELECT id + INTO v_open_lot_id + FROM stock_lot + WHERE product_id = v_product_id + AND status = 'open' + ORDER BY id + LIMIT 1 + FOR UPDATE; + + IF v_open_lot_id IS NULL THEN + RAISE EXCEPTION 'No open lot available for product % during auto switch', v_product_id; + END IF; + + UPDATE stock_lot + SET status = 'current', updated_at = NOW() + WHERE id = v_open_lot_id; + + INSERT INTO stock_lot (product_id, lot_number, status) + VALUES (v_product_id, NULL, 'open'); + + v_event_key := format('lot.auto_switched:%s:%s', v_product_id, txid_current()); + + PERFORM fn_enqueue_event( + 'lot.auto_switched', + v_event_key, + 'stock_lot', + NEW.lot_id::TEXT, + jsonb_build_object( + 'productId', v_product_id, + 'closedLotId', NEW.lot_id, + 'newCurrentLotId', v_open_lot_id, + 'occurredAt', NOW() + ) + ); + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_auto_switch_lot_when_depleted +AFTER INSERT ON stock_move +FOR EACH ROW +WHEN (NEW.move_type IN ('out', 'adjustment')) +EXECUTE FUNCTION fn_auto_switch_lot_when_depleted(); + +COMMIT; diff --git a/db/migrations/0002_phase1_seed_methods.sql b/db/migrations/0002_phase1_seed_methods.sql new file mode 100644 index 0000000..0aba908 --- /dev/null +++ b/db/migrations/0002_phase1_seed_methods.sql @@ -0,0 +1,16 @@ +BEGIN; + +INSERT INTO payment_method (code, label) +VALUES + ('card', 'Kredit-/Debitkarten'), + ('twint', 'TWINT'), + ('bank_transfer', 'Bankueberweisung') +ON CONFLICT (code) DO NOTHING; + +INSERT INTO shipping_method (code, label) +VALUES + ('post_standard', 'Versand mit Die Schweizerische Post (1-3 Werktage)'), + ('pickup', 'Abholung') +ON CONFLICT (code) DO NOTHING; + +COMMIT; diff --git a/db/migrations/0003_phase1_inventory_forecast.sql b/db/migrations/0003_phase1_inventory_forecast.sql new file mode 100644 index 0000000..0b14c1a --- /dev/null +++ b/db/migrations/0003_phase1_inventory_forecast.sql @@ -0,0 +1,307 @@ +BEGIN; + +-- Extend stock_lot for in-system sellout forecast and warnings. +ALTER TABLE stock_lot + ADD COLUMN IF NOT EXISTS sellout_date DATE, + ADD COLUMN IF NOT EXISTS warning_state TEXT NOT NULL DEFAULT 'none'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_stock_lot_warning_state' + ) THEN + ALTER TABLE stock_lot + ADD CONSTRAINT chk_stock_lot_warning_state + CHECK (warning_state IN ('none', 'due_60d', 'due_now')); + END IF; +END; +$$; + +-- Compute and persist sellout forecast for current lots. +-- Mirrors existing n8n idea: +-- - last 60 days sales +-- - frequency in hours per product +-- - sellout date based on current lot net stock +-- - warning when <= 60 days +CREATE OR REPLACE FUNCTION fn_refresh_sellout_forecast( + p_window_days INTEGER DEFAULT 60, + p_warning_days INTEGER DEFAULT 60 +) RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + WITH sale_events AS ( + SELECT + regexp_replace(p.name, '[0-9]+$', '') AS base_product, + a.created_at AS sold_at, + GREATEST(1, FLOOR(a.qty)::INT) AS qty_units + FROM sales_order_line_lot_allocation a + JOIN product p ON p.id = a.product_id + WHERE a.allocation_status = 'allocated' + AND a.created_at >= NOW() - make_interval(days => p_window_days) + AND a.created_at <= NOW() + ), + exploded AS ( + SELECT + base_product, + sold_at + ((gs.n - 1) * INTERVAL '1 millisecond') AS sold_at + FROM sale_events e + JOIN LATERAL generate_series(1, e.qty_units) AS gs(n) ON TRUE + ), + ranked AS ( + SELECT + base_product, + sold_at, + LAG(sold_at) OVER (PARTITION BY base_product ORDER BY sold_at) AS prev_sold_at + FROM exploded + ), + frequencies AS ( + SELECT + base_product, + COUNT(*) AS n_events, + AVG(EXTRACT(EPOCH FROM (sold_at - prev_sold_at)) / 3600.0) + FILTER (WHERE prev_sold_at IS NOT NULL) AS avg_hours + FROM ranked + GROUP BY base_product + ), + current_lots AS ( + SELECT + sl.id AS lot_id, + sl.product_id, + regexp_replace(p.name, '[0-9]+$', '') AS base_product, + b.qty_net, + f.avg_hours, + CASE + WHEN b.qty_net > 0 AND f.avg_hours IS NOT NULL THEN + (NOW() + ((b.qty_net * f.avg_hours) * INTERVAL '1 hour'))::DATE + ELSE NULL + END AS calc_sellout_date + FROM stock_lot sl + JOIN product p ON p.id = sl.product_id + JOIN v_stock_lot_balance b ON b.stock_lot_id = sl.id + LEFT JOIN frequencies f ON f.base_product = regexp_replace(p.name, '[0-9]+$', '') + WHERE sl.status = 'current' + ) + UPDATE stock_lot sl + SET + sellout_date = c.calc_sellout_date, + warning_state = CASE + WHEN c.calc_sellout_date IS NULL THEN 'none' + WHEN c.calc_sellout_date <= CURRENT_DATE THEN 'due_now' + WHEN c.calc_sellout_date <= CURRENT_DATE + p_warning_days THEN 'due_60d' + ELSE 'none' + END, + updated_at = NOW() + FROM current_lots c + WHERE sl.id = c.lot_id; + + -- Non-current lots do not carry active sellout warnings. + UPDATE stock_lot + SET + sellout_date = NULL, + warning_state = 'none', + updated_at = NOW() + WHERE status <> 'current' + AND (sellout_date IS NOT NULL OR warning_state <> 'none'); +END; +$$; + +CREATE OR REPLACE VIEW v_stock_lot_ui_alerts AS +SELECT + sl.id, + sl.product_id, + p.sku, + p.name AS product_name, + sl.lot_number, + sl.status, + b.qty_in, + b.qty_out, + b.qty_net, + sl.sellout_date, + sl.warning_state +FROM stock_lot sl +JOIN product p ON p.id = sl.product_id +JOIN v_stock_lot_balance b ON b.stock_lot_id = sl.id; + +-- Seed current inventory snapshot from legacy system (2026-03-29) +DO $$ +DECLARE + v_wh_id BIGINT; + v_loc_receiving BIGINT; + v_loc_storage BIGINT; + v_loc_dispatch BIGINT; + + v_chaga_id BIGINT; + v_reishi_id BIGINT; + v_shiitake_id BIGINT; + v_lions_id BIGINT; + + v_lot_id BIGINT; +BEGIN + -- idempotency guard + IF EXISTS ( + SELECT 1 + FROM stock_lot + WHERE lot_number IN ('2412.001', '2412.003', '2402.004', '2506.002', '2510.001', '2601.003', '2510.004', '2510.002') + ) THEN + RETURN; + END IF; + + INSERT INTO warehouse (code, name) + VALUES ('MAIN', 'Hauptlager') + ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name + RETURNING id INTO v_wh_id; + + IF v_wh_id IS NULL THEN + SELECT id INTO v_wh_id FROM warehouse WHERE code = 'MAIN'; + END IF; + + INSERT INTO location (warehouse_id, code, name, type) + VALUES + (v_wh_id, 'RECV', 'Wareneingang', 'receiving'), + (v_wh_id, 'STOCK', 'Lagerbestand', 'storage'), + (v_wh_id, 'DISP', 'Versand', 'dispatch') + ON CONFLICT (warehouse_id, code) DO NOTHING; + + SELECT id INTO v_loc_receiving FROM location WHERE warehouse_id = v_wh_id AND code = 'RECV'; + SELECT id INTO v_loc_storage FROM location WHERE warehouse_id = v_wh_id AND code = 'STOCK'; + SELECT id INTO v_loc_dispatch FROM location WHERE warehouse_id = v_wh_id AND code = 'DISP'; + + INSERT INTO product (sku, name) + VALUES + ('CHAGA_FLASCHE', 'ChagaFlaschen'), + ('REISHI_FLASCHE', 'ReishiFlaschen'), + ('SHIITAKE_FLASCHE', 'ShiitakeFlaschen'), + ('LIONSMANE_FLASCHE', 'LionsManeFlaschen') + ON CONFLICT (sku) DO UPDATE SET name = EXCLUDED.name; + + SELECT id INTO v_chaga_id FROM product WHERE sku = 'CHAGA_FLASCHE'; + SELECT id INTO v_reishi_id FROM product WHERE sku = 'REISHI_FLASCHE'; + SELECT id INTO v_shiitake_id FROM product WHERE sku = 'SHIITAKE_FLASCHE'; + SELECT id INTO v_lions_id FROM product WHERE sku = 'LIONSMANE_FLASCHE'; + + -- remove product bootstrap lots created by trigger for these products + DELETE FROM stock_lot + WHERE product_id IN (v_chaga_id, v_reishi_id, v_shiitake_id, v_lions_id) + AND ( + lot_number LIKE 'INIT-%' + OR (lot_number IS NULL AND status = 'open' AND NOT EXISTS (SELECT 1 FROM stock_move sm WHERE sm.lot_id = stock_lot.id)) + ); + + -- helper pattern: create lot as current, post in/out, then set final status + + -- 1) 2412.001 Chaga closed in=100 out=100 + UPDATE stock_lot SET status = 'closed' WHERE product_id = v_chaga_id AND status = 'current'; + INSERT INTO stock_lot (product_id, lot_number, status) VALUES (v_chaga_id, '2412.001', 'current') RETURNING id INTO v_lot_id; + INSERT INTO stock_move (product_id, lot_id, to_location_id, qty, move_type, note) + VALUES (v_chaga_id, v_lot_id, v_loc_storage, 100, 'in', 'legacy_seed'); + INSERT INTO stock_move (product_id, lot_id, from_location_id, to_location_id, qty, move_type, note) + VALUES (v_chaga_id, v_lot_id, v_loc_storage, v_loc_dispatch, 100, 'out', 'legacy_seed'); + UPDATE stock_lot SET status = 'closed' WHERE id = v_lot_id; + + -- 2) 2412.003 Reishi current in=150 out=151 net=0 (plus adjustment +1) + UPDATE stock_lot SET status = 'closed' WHERE product_id = v_reishi_id AND status = 'current'; + INSERT INTO stock_lot (product_id, lot_number, status) VALUES (v_reishi_id, '2412.003', 'current') RETURNING id INTO v_lot_id; + INSERT INTO stock_move (product_id, lot_id, to_location_id, qty, move_type, note) + VALUES (v_reishi_id, v_lot_id, v_loc_storage, 150, 'in', 'legacy_seed'); + INSERT INTO stock_move (product_id, lot_id, from_location_id, to_location_id, qty, move_type, note) + VALUES (v_reishi_id, v_lot_id, v_loc_storage, v_loc_dispatch, 151, 'out', 'legacy_seed'); + INSERT INTO stock_move (product_id, lot_id, to_location_id, qty, move_type, note) + VALUES (v_reishi_id, v_lot_id, v_loc_storage, 1, 'adjustment', 'legacy_seed_correction'); + + -- 3) 2402.004 Shiitake closed in=73 out=73 + UPDATE stock_lot SET status = 'closed' WHERE product_id = v_shiitake_id AND status = 'current'; + INSERT INTO stock_lot (product_id, lot_number, status) VALUES (v_shiitake_id, '2402.004', 'current') RETURNING id INTO v_lot_id; + INSERT INTO stock_move (product_id, lot_id, to_location_id, qty, move_type, note) + VALUES (v_shiitake_id, v_lot_id, v_loc_storage, 73, 'in', 'legacy_seed'); + INSERT INTO stock_move (product_id, lot_id, from_location_id, to_location_id, qty, move_type, note) + VALUES (v_shiitake_id, v_lot_id, v_loc_storage, v_loc_dispatch, 73, 'out', 'legacy_seed'); + UPDATE stock_lot SET status = 'closed' WHERE id = v_lot_id; + + -- 4) 2506.002 Lions closed in=250 out=250 + UPDATE stock_lot SET status = 'closed' WHERE product_id = v_lions_id AND status = 'current'; + INSERT INTO stock_lot (product_id, lot_number, status) VALUES (v_lions_id, '2506.002', 'current') RETURNING id INTO v_lot_id; + INSERT INTO stock_move (product_id, lot_id, to_location_id, qty, move_type, note) + VALUES (v_lions_id, v_lot_id, v_loc_storage, 250, 'in', 'legacy_seed'); + INSERT INTO stock_move (product_id, lot_id, from_location_id, to_location_id, qty, move_type, note) + VALUES (v_lions_id, v_lot_id, v_loc_storage, v_loc_dispatch, 250, 'out', 'legacy_seed'); + UPDATE stock_lot SET status = 'closed' WHERE id = v_lot_id; + + -- 5) 2510.001 Chaga current in=250 out=87 sellout=2026-11-04 + UPDATE stock_lot SET status = 'closed' WHERE product_id = v_chaga_id AND status = 'current'; + INSERT INTO stock_lot (product_id, lot_number, status, sellout_date) + VALUES (v_chaga_id, '2510.001', 'current', DATE '2026-11-04') RETURNING id INTO v_lot_id; + INSERT INTO stock_move (product_id, lot_id, to_location_id, qty, move_type, note) + VALUES (v_chaga_id, v_lot_id, v_loc_storage, 250, 'in', 'legacy_seed'); + INSERT INTO stock_move (product_id, lot_id, from_location_id, to_location_id, qty, move_type, note) + VALUES (v_chaga_id, v_lot_id, v_loc_storage, v_loc_dispatch, 87, 'out', 'legacy_seed'); + + -- 6) 2601.003 Reishi open in=250 out=6 + -- Temporarily move current lot 2412.003 to closed, seed movement on 2601.003, then restore 2412.003 to current. + UPDATE stock_lot + SET status = 'closed' + WHERE product_id = v_reishi_id + AND lot_number = '2412.003'; + INSERT INTO stock_lot (product_id, lot_number, status) + VALUES (v_reishi_id, '2601.003', 'current') RETURNING id INTO v_lot_id; + INSERT INTO stock_move (product_id, lot_id, to_location_id, qty, move_type, note) + VALUES (v_reishi_id, v_lot_id, v_loc_storage, 250, 'in', 'legacy_seed'); + INSERT INTO stock_move (product_id, lot_id, from_location_id, to_location_id, qty, move_type, note) + VALUES (v_reishi_id, v_lot_id, v_loc_storage, v_loc_dispatch, 6, 'out', 'legacy_seed'); + UPDATE stock_lot SET status = 'open' WHERE id = v_lot_id; + UPDATE stock_lot + SET status = 'current' + WHERE product_id = v_reishi_id + AND lot_number = '2412.003'; + + -- 7) 2510.004 Shiitake current in=250 out=30 sellout=2028-07-18 + UPDATE stock_lot SET status = 'closed' WHERE product_id = v_shiitake_id AND status = 'current'; + INSERT INTO stock_lot (product_id, lot_number, status, sellout_date) + VALUES (v_shiitake_id, '2510.004', 'current', DATE '2028-07-18') RETURNING id INTO v_lot_id; + INSERT INTO stock_move (product_id, lot_id, to_location_id, qty, move_type, note) + VALUES (v_shiitake_id, v_lot_id, v_loc_storage, 250, 'in', 'legacy_seed'); + INSERT INTO stock_move (product_id, lot_id, from_location_id, to_location_id, qty, move_type, note) + VALUES (v_shiitake_id, v_lot_id, v_loc_storage, v_loc_dispatch, 30, 'out', 'legacy_seed'); + + -- 8) 2510.002 Lions current in=300 out=79 sellout=2026-12-23 + UPDATE stock_lot SET status = 'closed' WHERE product_id = v_lions_id AND status = 'current'; + INSERT INTO stock_lot (product_id, lot_number, status, sellout_date) + VALUES (v_lions_id, '2510.002', 'current', DATE '2026-12-23') RETURNING id INTO v_lot_id; + INSERT INTO stock_move (product_id, lot_id, to_location_id, qty, move_type, note) + VALUES (v_lions_id, v_lot_id, v_loc_storage, 300, 'in', 'legacy_seed'); + INSERT INTO stock_move (product_id, lot_id, from_location_id, to_location_id, qty, move_type, note) + VALUES (v_lions_id, v_lot_id, v_loc_storage, v_loc_dispatch, 79, 'out', 'legacy_seed'); + + -- 9) Chaga open in=200 out=0 (no lot number yet) + INSERT INTO stock_lot (product_id, lot_number, status) + VALUES (v_chaga_id, NULL, 'open') RETURNING id INTO v_lot_id; + INSERT INTO stock_move (product_id, lot_id, to_location_id, qty, move_type, note) + VALUES (v_chaga_id, v_lot_id, v_loc_storage, 200, 'in', 'legacy_seed'); + + -- 10) Shiitake open in=200 out=0 + INSERT INTO stock_lot (product_id, lot_number, status) + VALUES (v_shiitake_id, NULL, 'open') RETURNING id INTO v_lot_id; + INSERT INTO stock_move (product_id, lot_id, to_location_id, qty, move_type, note) + VALUES (v_shiitake_id, v_lot_id, v_loc_storage, 200, 'in', 'legacy_seed'); + + -- 11) Lions open in=200 out=0 + INSERT INTO stock_lot (product_id, lot_number, status) + VALUES (v_lions_id, NULL, 'open') RETURNING id INTO v_lot_id; + INSERT INTO stock_move (product_id, lot_id, to_location_id, qty, move_type, note) + VALUES (v_lions_id, v_lot_id, v_loc_storage, 200, 'in', 'legacy_seed'); + + -- warning flags for seeded current lots based on static sellout date snapshot + UPDATE stock_lot + SET warning_state = CASE + WHEN sellout_date IS NULL THEN 'none' + WHEN sellout_date <= CURRENT_DATE THEN 'due_now' + WHEN sellout_date <= CURRENT_DATE + 60 THEN 'due_60d' + ELSE 'none' + END + WHERE status = 'current'; +END; +$$; + +COMMIT; diff --git a/db/migrations/0004_phase1_direct_sales.sql b/db/migrations/0004_phase1_direct_sales.sql new file mode 100644 index 0000000..d7cecc8 --- /dev/null +++ b/db/migrations/0004_phase1_direct_sales.sql @@ -0,0 +1,185 @@ +BEGIN; + +-- Enable direct/manual sales orders (e.g. market booth daily capture). +-- Direct orders are identifiable by order_source=direct and external_ref prefix DIR-. + +ALTER TABLE sales_order + ADD COLUMN IF NOT EXISTS order_source TEXT NOT NULL DEFAULT 'wix'; + +ALTER TABLE sales_order + ALTER COLUMN party_id DROP NOT NULL; + +UPDATE sales_order +SET order_source = 'wix' +WHERE order_source IS NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_sales_order_source' + ) THEN + ALTER TABLE sales_order + ADD CONSTRAINT chk_sales_order_source + CHECK (order_source IN ('wix', 'direct')); + END IF; +END; +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_sales_order_direct_ref_prefix' + ) THEN + ALTER TABLE sales_order + ADD CONSTRAINT chk_sales_order_direct_ref_prefix + CHECK (order_source <> 'direct' OR external_ref LIKE 'DIR-%'); + END IF; +END; +$$; + +CREATE INDEX IF NOT EXISTS idx_sales_order_source ON sales_order(order_source); + +CREATE SEQUENCE IF NOT EXISTS seq_sales_order_direct_ref; + +CREATE OR REPLACE FUNCTION fn_next_direct_sales_order_ref( + p_order_date DATE DEFAULT CURRENT_DATE +) RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + v_seq BIGINT; +BEGIN + v_seq := nextval('seq_sales_order_direct_ref'); + RETURN format( + 'DIR-%s-%s', + to_char(p_order_date, 'YYYYMMDD'), + lpad(v_seq::TEXT, 5, '0') + ); +END; +$$; + +CREATE OR REPLACE FUNCTION fn_sales_order_assign_direct_ref() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.order_source = 'direct' AND (NEW.external_ref IS NULL OR btrim(NEW.external_ref) = '') THEN + NEW.external_ref := fn_next_direct_sales_order_ref(COALESCE(NEW.order_date::DATE, CURRENT_DATE)); + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_sales_order_assign_direct_ref ON sales_order; + +CREATE TRIGGER trg_sales_order_assign_direct_ref +BEFORE INSERT OR UPDATE OF order_source, external_ref, order_date ON sales_order +FOR EACH ROW +EXECUTE FUNCTION fn_sales_order_assign_direct_ref(); + +CREATE OR REPLACE FUNCTION fn_enqueue_sales_order_events() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_event_key TEXT; +BEGIN + IF TG_OP = 'INSERT' THEN + v_event_key := format('order.imported:%s', NEW.external_ref); + PERFORM fn_enqueue_event( + 'order.imported', + v_event_key, + 'sales_order', + NEW.id::TEXT, + jsonb_build_object( + 'orderId', NEW.id, + 'externalRef', NEW.external_ref, + 'orderSource', NEW.order_source, + 'orderStatus', NEW.order_status, + 'paymentStatus', NEW.payment_status, + 'occurredAt', NOW() + ) + ); + ELSIF TG_OP = 'UPDATE' THEN + IF OLD.order_status IS DISTINCT FROM NEW.order_status AND NEW.order_status = 'cancelled' THEN + v_event_key := format('order.cancelled.full:%s:%s', NEW.external_ref, COALESCE(NEW.cancelled_at, NOW())); + PERFORM fn_enqueue_event( + 'order.cancelled.full', + v_event_key, + 'sales_order', + NEW.id::TEXT, + jsonb_build_object( + 'orderId', NEW.id, + 'externalRef', NEW.external_ref, + 'orderSource', NEW.order_source, + 'orderStatus', NEW.order_status, + 'cancelledAt', COALESCE(NEW.cancelled_at, NOW()), + 'cancelledReason', NEW.cancelled_reason + ) + ); + END IF; + END IF; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION fn_enqueue_partial_cancel_events() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_order_id BIGINT; + v_external_ref TEXT; + v_order_source TEXT; + v_event_key TEXT; +BEGIN + IF NEW.line_status = 'partially_cancelled' AND OLD.line_status IS DISTINCT FROM NEW.line_status THEN + SELECT so.id, so.external_ref, so.order_source + INTO v_order_id, v_external_ref, v_order_source + FROM sales_order so + WHERE so.id = NEW.sales_order_id; + + v_event_key := format('order.cancelled.partial:%s:%s:%s', v_external_ref, NEW.id, NEW.qty_cancelled); + + PERFORM fn_enqueue_event( + 'order.cancelled.partial', + v_event_key, + 'sales_order_line', + NEW.id::TEXT, + jsonb_build_object( + 'orderId', v_order_id, + 'externalRef', v_external_ref, + 'orderSource', v_order_source, + 'lineId', NEW.id, + 'lineNo', NEW.line_no, + 'qty', NEW.qty, + 'qtyCancelled', NEW.qty_cancelled, + 'lineStatus', NEW.line_status, + 'occurredAt', NOW() + ) + ); + END IF; + + RETURN NEW; +END; +$$; + +-- Payment methods used by direct/manual sales mask. +INSERT INTO payment_method (code, label) +VALUES + ('cash', 'Barzahlung'), + ('paypal', 'PayPal') +ON CONFLICT (code) DO NOTHING; + +UPDATE payment_method +SET label = 'Ueberweisung', updated_at = NOW() +WHERE code = 'bank_transfer' + AND label <> 'Ueberweisung'; + +COMMIT; diff --git a/deploy-staging.sh b/deploy-staging.sh new file mode 100755 index 0000000..5bc7f34 --- /dev/null +++ b/deploy-staging.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail +umask 022 + +ROOT="/volume2/webssd/erpnaurua/dev" +LOG="$ROOT/deploy.log" + +cd "$ROOT" || exit 1 + +{ + echo "----- $(date) -----" + whoami + echo "PATH=$PATH" +} >> "$LOG" + +/usr/bin/git fetch origin >> "$LOG" 2>&1 +/usr/bin/git reset --hard origin/main >> "$LOG" 2>&1 + +echo "DONE" >> "$LOG" diff --git a/deploy.php b/deploy.php new file mode 100644 index 0000000..337c0a1 --- /dev/null +++ b/deploy.php @@ -0,0 +1,55 @@ + /dev/null 2>&1 &'); +echo 'Deploy triggered'; diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md new file mode 100644 index 0000000..11a5fe5 --- /dev/null +++ b/docs/CONCEPT.md @@ -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. diff --git a/docs/PROCESS_PHASE1.md b/docs/PROCESS_PHASE1.md new file mode 100644 index 0000000..18beec4 --- /dev/null +++ b/docs/PROCESS_PHASE1.md @@ -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. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..5887736 --- /dev/null +++ b/docs/README.md @@ -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. diff --git a/docs/SCHEMA_PHASE1.sql b/docs/SCHEMA_PHASE1.sql new file mode 100644 index 0000000..2674461 --- /dev/null +++ b/docs/SCHEMA_PHASE1.sql @@ -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); diff --git a/docs/SPEC_PHASE1_COMPONENTS.md b/docs/SPEC_PHASE1_COMPONENTS.md new file mode 100644 index 0000000..a82ac05 --- /dev/null +++ b/docs/SPEC_PHASE1_COMPONENTS.md @@ -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. diff --git a/n8n/exports/current/bestell-eingang-online-shop.yNLjtV9yG0T6CqSr.json b/n8n/exports/current/bestell-eingang-online-shop.yNLjtV9yG0T6CqSr.json new file mode 100644 index 0000000..39c7d28 --- /dev/null +++ b/n8n/exports/current/bestell-eingang-online-shop.yNLjtV9yG0T6CqSr.json @@ -0,0 +1,580 @@ +{ + "updatedAt": "2026-03-29T18:33:43.163Z", + "createdAt": "2025-07-05T06:04:09.291Z", + "id": "yNLjtV9yG0T6CqSr", + "name": "Bestell-Eingang Online-Shop", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "format": "resolved", + "options": {} + }, + "type": "n8n-nodes-base.emailReadImap", + "typeVersion": 2, + "position": [ + -272, + -768 + ], + "id": "def6f3b1-b241-4e6c-89b1-63e4a0c12258", + "name": "Email Trigger (IMAP)", + "credentials": { + "imap": { + "id": "5jzK2kuSVJ8NWL1D", + "name": "IMAP account 4" + } + } + }, + { + "parameters": { + "content": "Eingang ist Bestellung", + "height": 520, + "width": 860 + }, + "type": "n8n-nodes-base.stickyNote", + "position": [ + 80, + -960 + ], + "typeVersion": 1, + "id": "cec90477-28bf-45fd-a220-72edb7e20297", + "name": "Sticky Note" + }, + { + "parameters": { + "modelId": { + "__rl": true, + "value": "deepseek/deepseek-v3.2", + "mode": "list", + "cachedResultName": "DEEPSEEK/DEEPSEEK-V3.2" + }, + "messages": { + "values": [ + { + "content": "=Your input is this HTML-Mail: {{ $json.html }}\nYou are a JSON extractor for our order-processing pipeline.\nGiven the raw email HTML/text, you must output exactly one JSON object matching our schema, with these keys (in any order):\n\n*Section Bestellinformationen*\nBestellungNr, Zahlungsstatus, Zahlungsmethode, \n*Section Zahlungsinformationen*\nVorname_RgAdr, Nachname_RgAdr, Strasse_RgAdr, Hausnummer_RgAdr, Stadt_RgAdr, Bundesland_RgAdr, PLZ_RgAdr, Land_RgAdr, EmailKunde, \n*Section Lieferinformationen*\nVorname_LfAdr, Nachname_LfAdr, Strasse_LfAdr, Hausnummer_LfAdr, Stadt_LfAdr, Bundesland_LfAdr, PLZ_LfAdr, Land_LfAdr,\n*Section Versandmethode\"\nLiefermethode\n\n*Section Bestelldetails*\nEach lineItems entry must be an object with exactly these keys: \ntitel (Name of the item), artikelnummer, preisEinheit (CHF Price per lineitem divided by #Items), artikelanzahl (Anzahl)\n*Bottom-Sections*\nNetto, Versandkosten, Mehrwertsteuer, Rabatt (add to total if several Rabatte exist in the section), Rabattcode (is the name of the rabatt in the left column on the line of the rabatt sum) Gesamtsumme, \n\n\nImportant: \n- Output only the JSON object (no surrounding array, no code fences, no explanations). \n- Use string values for all text fields and numbers for all numeric fields. \n- If a field is missing or empty, output an empty string (\"\") for text or 0 for numbers.\n\nExtract the order details from the given raw email (HTML or text) and respond with only the JSON object below—no code fences, no markdown, no surrounding array, no keys like index or message. It must match exactly this schema (ordering doesn’t matter):\n\n{\n \"BestellungNr\": string,\n \"Zahlungsstatus\": string,\n \"Zahlungsmethode\": string,\n \"Vorname_RgAdr\": string,\n \"Nachname_RgAdr\": string,\n \"Strasse_RgAdr\": string,\n \"Hausnummer_RgAdr\": string,\n \"Stadt_RgAdr\": string,\n \"Bundesland_RgAdr\": string,\n \"PLZ_RgAdr\": string,\n \"Land_RgAdr\": string,\n \"EmailKunde\": string,\n \"Vorname_LfAdr\": string,\n \"Nachname_LfAdr\": string,\n \"Strasse_LfAdr\": string,\n \"Hausnummer_LfAdr\": string,\n \"Stadt_LfAdr\": string,\n \"Bundesland_LfAdr\": string,\n \"PLZ_LfAdr\": string,\n \"Land_LfAdr\": string,\n \"Liefermethode\": string,\n \"Netto\": number,\n \"Versandkosten\": number,\n \"Mehrwertsteuer\": number,\n \"Rabatt\": number,\n \"Gesamtsumme\": number,\n\"Rabattcode\": string\n \"lineItems\": [\n {\n \"titel\": string,\n \"artikelnummer\": string,\n \"preisEinheit\": number,\n \"artikelanzahl\": number\n }\n // repeat for each line item\n ]\n}" + } + ] + }, + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.openAi", + "typeVersion": 1.8, + "position": [ + 144, + -784 + ], + "id": "7ab009e6-5c24-48cb-ad34-28886cfec035", + "name": "Message a model", + "retryOnFail": true, + "waitBetweenTries": 5000, + "credentials": { + "openAiApi": { + "id": "CQ31lEApDEhhVATP", + "name": "OpenAi OpenRouter" + } + } + }, + { + "parameters": { + "jsCode": "// n8n Code-Node – parse AI-Agent output into a clean JSON object\nreturn items.map(item => {\n // 1) Roh-String aus dem AI-Step ziehen\n let content = item.json.message?.content\n || item.json.content\n || '';\n \n // 2) Code-Fence und Markdown-Marker entfernen\n content = content\n .replace(/```json\\s*/g, '')\n .replace(/```/g, '')\n .trim();\n \n // 3) Falls der Agent eine Array-Antwort (z.B. “[ { … } ]”) liefert,\n // das Array parsen und das erste Objekt herausgreifen\n if (content.startsWith('[') && content.endsWith(']')) {\n let arr;\n try {\n arr = JSON.parse(content);\n } catch {\n throw new Error(`Kann AI-Array nicht parsen:\\n${content}`);\n }\n if (!Array.isArray(arr) || arr.length === 0) {\n throw new Error(`Erwartetes Array mit mindestens einem Element, bekam:\\n${content}`);\n }\n // Wenn das Array schon Deine Order-Objekte enthält (kein wrapper), nimm arr[0]\n // Andernfalls, falls es das n8n-wrapper-Format ist, grabe tiefer:\n content = JSON.stringify(\n typeof arr[0].message?.content === 'string'\n // falls das erste Element wieder ein wrapper-Objekt mit .message.content ist:\n ? JSON.parse(arr[0].message.content.replace(/```/g, '').trim())\n // sonst direkt das erste Element\n : arr[0]\n );\n }\n \n // 4) Jetzt das finale JSON parsen\n let data;\n try {\n data = JSON.parse(content);\n } catch (e) {\n throw new Error(`Failed to JSON.parse AI output:\\n${content}`);\n }\n \n // 5) Als neues Item zurückgeben\n return { json: data };\n});" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 480, + -784 + ], + "id": "a199695a-ed3f-4b16-b1c2-0dc5bf143fd8", + "name": "JSON Parsen1" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "dbe1657a-046d-44a2-a602-588bb6e2e83a", + "leftValue": "={{ $json.headers.subject }}", + "rightValue": "Neue Bestellung", + "operator": { + "type": "string", + "operation": "contains" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -64, + -768 + ], + "id": "8c16451e-288d-4348-b04e-d072a133bc8f", + "name": "If" + }, + { + "parameters": { + "method": "POST", + "url": "https://erpnaurua.imhochrain.ch/order-import.php", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "X-Webhook-Secret", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmODJlZDZhOC00NzA0LTRmODAtODYxNy02MjJmZDU5MTFkNTYiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiw" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json }}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + } + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 736, + -784 + ], + "id": "5c81fac4-8ca3-471a-a546-0e73995446a5", + "name": "Bestellung an ERP senden" + } + ], + "connections": { + "Email Trigger (IMAP)": { + "main": [ + [ + { + "node": "If", + "type": "main", + "index": 0 + } + ] + ] + }, + "Message a model": { + "main": [ + [ + { + "node": "JSON Parsen1", + "type": "main", + "index": 0 + } + ] + ] + }, + "JSON Parsen1": { + "main": [ + [ + { + "node": "Bestellung an ERP senden", + "type": "main", + "index": 0 + } + ] + ] + }, + "If": { + "main": [ + [ + { + "node": "Message a model", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bestellung an ERP senden": { + "main": [ + [] + ] + } + }, + "settings": { + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner", + "errorWorkflow": "QQ1KFafAxgMOjKWm", + "availableInMCP": false + }, + "staticData": { + "node:Email Trigger (IMAP)": {}, + "node:Email Trigger (IMAP)1": {} + }, + "meta": { + "templateCredsSetupCompleted": true + }, + "pinData": { + "Email Trigger (IMAP)": [ + { + "json": { + "headers": { + "return-path": "Return-Path: ", + "x-original-to": "X-Original-To: n8n_naurua_bestelleingang@imhochrain.ch", + "delivered-to": "Delivered-To: n8n_naurua_bestelleingang@imhochrain.ch", + "received-spf": "Received-SPF: pass (bounces.wixemails.com: Sender is authorized to use 'msprvs1=20545RoK9oTDl=bounces-17751-1668086@bounces.wixemails.com' in 'mfrom' identity (mechanism 'exists:%{i}._spf.sparkpostmail.com' matched)) receiver=hz_nas2; identity=mailfrom; envelope-from=\"msprvs1=20545RoK9oTDl=bounces-17751-1668086@bounces.wixemails.com\"; helo=mta-70-25-9.wix-shared.com.sparkpostmail.com; client-ip=156.70.25.9", + "authentication-results": "Authentication-Results: imhochrain.ch;\r\n\tdkim=pass (1024-bit key) header.d=stores-emails.com header.i=@stores-emails.com header.b=jsFBHei7", + "received": "Received: from [10.90.46.161] ([10.90.46.161])\r\n\tby i-043224e4cc422583f.mta1vrest.sd.prd.sparkpost (ecelerity 5.2.0.75340 r(msys-ecelerity:tags/5.2.0.8)) with REST\r\n\tid 27/8B-10073-3CDF4C96; Thu, 26 Mar 2026 09:34:59 +0000", + "x-msfbl": "X-MSFBL: I5/PwAFR1Omzb1UxJL4Y250GcVn79Tr/9VF5IMMBVfg=|eyJzdWJhY2NvdW50X2l\r\n\tkIjoiMTY2ODA4NiIsIm1lc3NhZ2VfaWQiOiI2OWMzYzNmZGM0Njk2NzMxMzg3MiI\r\n\tsImN1c3RvbWVyX2lkIjoiMTc3NTEiLCJyIjoibjhuX25hdXJ1YV9iZXN0ZWxsZWl\r\n\tuZ2FuZ0BpbWhvY2hyYWluLmNoIiwidGVuYW50X2lkIjoic3BjIn0=", + "dkim-signature": "DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=stores-emails.com;\r\n\ts=scph0326; t=1774517699; i=@stores-emails.com;\r\n\tbh=/4WZe0NPBZ4rRG9eBwLVkQNU0JfwTneiOpmKK/Ov38E=;\r\n\th=To:Message-ID:Date:Content-Type:List-Unsubscribe-Post:Subject:\r\n\t List-Unsubscribe:From:From:To:Cc:Subject;\r\n\tb=jsFBHei7R1LKcqpF2tE8f6vPPWTYlaIOtolOZtySbszRdJW+rWb+fS5RKfAoGWTsJ\r\n\t rBD4mmIjiGNHTdF+jAyPIevNXVG8ybRw7cXk8ASx8dc0IbRVP0V5C7ZfwXZGUi0MWA\r\n\t 3iWth0ouQNtVcG/fcOlN7cPUtUHqkwppHGClMZOk=", + "to": "To: n8n_naurua_bestelleingang@imhochrain.ch", + "message-id": "Message-ID: <27.8B.10073.3CDF4C96@i-043224e4cc422583f.mta1vrest.sd.prd.sparkpost>", + "date": "Date: Thu, 26 Mar 2026 09:34:59 +0000", + "content-type": "Content-Type: multipart/alternative; boundary=\"_----myAB/yV9XX69ShxBXnnVhA===_F6/8B-10073-3CDF4C96\"", + "mime-version": "MIME-Version: 1.0", + "reply-to": "Reply-To: naurua.ch@gmail.com", + "list-unsubscribe-post": "List-Unsubscribe-Post: List-Unsubscribe=One-Click", + "subject": "Subject: Neue Bestellung (#10466)", + "list-unsubscribe": "List-Unsubscribe: ", + "from": "From: \"Naurua GmbH\" ", + "x-abuse-id": "x-abuse-id: a052a266-8283-493d-8e9f-88a82683af01", + "precedence": "Precedence: Bulk", + "feedback-id": "Feedback-ID: a052a266-8283-493d-8e9f-88a82683af01:6b1c6765-7f6f-45df-b6ab-df8f41b90345:wixshoutout", + "x-mailscanner-id": "X-MailScanner-ID: 211D7339758.A58F0", + "x-mailscanner": "X-MailScanner: Found to be clean", + "x-mailscanner-from": "X-MailScanner-From: msprvs1=20545rok9otdl=bounces-17751-1668086@bounces.wixemails.com", + "x-spam-status": "X-Spam-Status: No" + }, + "html": "\n
\"\"/
\n
Auf deiner Website wurde eine Bestellung aufgegeben.‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ 
Die Nachricht ist nicht sichtbar? Im Browser ansehen

Neue Bestellung

Auf deiner Website wurde eine Bestellung aufgegeben.

Bestellung #10466 

Bestellinformationen

Bestellung #10466 

Datum:

26. März 2026 um 10:34 MEZ

Betrag:

CHF 54.90

Zahlungsstatus: bezahlt

Zahlungsmethode:

Kredit-/Debitkarten

Zahlungsinformationen

Holger Hannemann prakt. Naturarzt

Bahnhofplatz 11
9100 Herisau
Schweiz
 

Herisau, CH-AR, CH

9100 

mail@hannemann.ch 

Lieferinformationen

Holger Hannemann prakt. Naturarzt 

 

Bahnhofplatz 11 

 

Herisau CH-AR CH 

9100 

 

Versandmethode

shipping

Versand mit Die Schweizerische Post (1-3 Werktage)

Bestelldetails

Chaga Extrakt Tinktur 50 ml | 100% rein

Artikelnummer: 001.01

Preis: CHF 49.95

Anzahl: 1 

CHF 49.95

Zwischensumme

CHF 49.95

Versand

CHF 4.95

MwSt.

 CHF 0.00

Gesamtsumme

CHF 54.90

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

Neue Bestellung

Auf deiner Website wurde eine Bestellung aufgegeben.

Bestellung #10466  

Bestellinformationen

Bestelldetails

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

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

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

", + "subject": "Neue Bestellung (#10466)", + "date": "2026-03-26T09:34:59.000Z", + "to": { + "value": [ + { + "address": "n8n_naurua_bestelleingang@imhochrain.ch", + "name": "" + } + ], + "html": "n8n_naurua_bestelleingang@imhochrain.ch", + "text": "n8n_naurua_bestelleingang@imhochrain.ch" + }, + "from": { + "value": [ + { + "address": "naurua.ch@stores-emails.com", + "name": "Naurua GmbH" + } + ], + "html": "Naurua GmbH <naurua.ch@stores-emails.com>", + "text": "\"Naurua GmbH\" " + }, + "messageId": "<27.8B.10073.3CDF4C96@i-043224e4cc422583f.mta1vrest.sd.prd.sparkpost>", + "replyTo": { + "value": [ + { + "address": "naurua.ch@gmail.com", + "name": "" + } + ], + "html": "naurua.ch@gmail.com", + "text": "naurua.ch@gmail.com" + }, + "attributes": { + "uid": 266 + } + }, + "pairedItem": { + "item": 0 + } + } + ], + "Message a model": [ + { + "json": { + "index": 0, + "logprobs": null, + "finish_reason": "stop", + "native_finish_reason": "stop", + "message": { + "role": "assistant", + "content": "{\n \"BestellungNr\": \"10466\",\n \"Zahlungsstatus\": \"bezahlt\",\n \"Zahlungsmethode\": \"Kredit-/Debitkarten\",\n \"Vorname_RgAdr\": \"Holger\",\n \"Nachname_RgAdr\": \"Hannemann prakt. Naturarzt\",\n \"Strasse_RgAdr\": \"Bahnhofplatz\",\n \"Hausnummer_RgAdr\": \"11\",\n \"Stadt_RgAdr\": \"Herisau\",\n \"Bundesland_RgAdr\": \"CH-AR\",\n \"PLZ_RgAdr\": \"9100\",\n \"Land_RgAdr\": \"Schweiz\",\n \"EmailKunde\": \"mail@hannemann.ch\",\n \"Vorname_LfAdr\": \"Holger\",\n \"Nachname_LfAdr\": \"Hannemann prakt. Naturarzt\",\n \"Strasse_LfAdr\": \"Bahnhofplatz\",\n \"Hausnummer_LfAdr\": \"11\",\n \"Stadt_LfAdr\": \"Herisau\",\n \"Bundesland_LfAdr\": \"CH-AR\",\n \"PLZ_LfAdr\": \"9100\",\n \"Land_LfAdr\": \"CH\",\n \"Liefermethode\": \"Versand mit Die Schweizerische Post (1-3 Werktage)\",\n \"Netto\": 49.95,\n \"Versandkosten\": 4.95,\n \"Mehrwertsteuer\": 0.0,\n \"Rabatt\": 0.0,\n \"Rabattcode\": \"\",\n \"Gesamtsumme\": 54.90,\n \"lineItems\": [\n {\n \"titel\": \"Chaga Extrakt Tinktur 50 ml | 100% rein\",\n \"artikelnummer\": \"001.01\",\n \"preisEinheit\": 49.95,\n \"artikelanzahl\": 1\n }\n ]\n}", + "refusal": null, + "reasoning": null + } + }, + "pairedItem": { + "item": 0 + } + } + ] + }, + "versionId": "0d0f1eec-82c8-4b6e-9770-bbbeca45a905", + "activeVersionId": "0d0f1eec-82c8-4b6e-9770-bbbeca45a905", + "versionCounter": 221, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2025-07-05T06:04:09.312Z", + "createdAt": "2025-07-05T06:04:09.312Z", + "role": "workflow:owner", + "workflowId": "yNLjtV9yG0T6CqSr", + "projectId": "loIw8cF8XKYX00Ow", + "project": { + "updatedAt": "2025-06-07T09:04:27.150Z", + "createdAt": "2025-06-07T06:22:39.698Z", + "id": "loIw8cF8XKYX00Ow", + "name": "Mathias Gläser ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "f82ed6a8-4704-4f80-8617-622fd5911d56" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-03-29T18:33:43.165Z", + "createdAt": "2026-03-29T18:33:43.165Z", + "versionId": "0d0f1eec-82c8-4b6e-9770-bbbeca45a905", + "workflowId": "yNLjtV9yG0T6CqSr", + "nodes": [ + { + "parameters": { + "format": "resolved", + "options": {} + }, + "type": "n8n-nodes-base.emailReadImap", + "typeVersion": 2, + "position": [ + -272, + -768 + ], + "id": "def6f3b1-b241-4e6c-89b1-63e4a0c12258", + "name": "Email Trigger (IMAP)", + "credentials": { + "imap": { + "id": "5jzK2kuSVJ8NWL1D", + "name": "IMAP account 4" + } + } + }, + { + "parameters": { + "content": "Eingang ist Bestellung", + "height": 520, + "width": 860 + }, + "type": "n8n-nodes-base.stickyNote", + "position": [ + 80, + -960 + ], + "typeVersion": 1, + "id": "cec90477-28bf-45fd-a220-72edb7e20297", + "name": "Sticky Note" + }, + { + "parameters": { + "modelId": { + "__rl": true, + "value": "deepseek/deepseek-v3.2", + "mode": "list", + "cachedResultName": "DEEPSEEK/DEEPSEEK-V3.2" + }, + "messages": { + "values": [ + { + "content": "=Your input is this HTML-Mail: {{ $json.html }}\nYou are a JSON extractor for our order-processing pipeline.\nGiven the raw email HTML/text, you must output exactly one JSON object matching our schema, with these keys (in any order):\n\n*Section Bestellinformationen*\nBestellungNr, Zahlungsstatus, Zahlungsmethode, \n*Section Zahlungsinformationen*\nVorname_RgAdr, Nachname_RgAdr, Strasse_RgAdr, Hausnummer_RgAdr, Stadt_RgAdr, Bundesland_RgAdr, PLZ_RgAdr, Land_RgAdr, EmailKunde, \n*Section Lieferinformationen*\nVorname_LfAdr, Nachname_LfAdr, Strasse_LfAdr, Hausnummer_LfAdr, Stadt_LfAdr, Bundesland_LfAdr, PLZ_LfAdr, Land_LfAdr,\n*Section Versandmethode\"\nLiefermethode\n\n*Section Bestelldetails*\nEach lineItems entry must be an object with exactly these keys: \ntitel (Name of the item), artikelnummer, preisEinheit (CHF Price per lineitem divided by #Items), artikelanzahl (Anzahl)\n*Bottom-Sections*\nNetto, Versandkosten, Mehrwertsteuer, Rabatt (add to total if several Rabatte exist in the section), Rabattcode (is the name of the rabatt in the left column on the line of the rabatt sum) Gesamtsumme, \n\n\nImportant: \n- Output only the JSON object (no surrounding array, no code fences, no explanations). \n- Use string values for all text fields and numbers for all numeric fields. \n- If a field is missing or empty, output an empty string (\"\") for text or 0 for numbers.\n\nExtract the order details from the given raw email (HTML or text) and respond with only the JSON object below—no code fences, no markdown, no surrounding array, no keys like index or message. It must match exactly this schema (ordering doesn’t matter):\n\n{\n \"BestellungNr\": string,\n \"Zahlungsstatus\": string,\n \"Zahlungsmethode\": string,\n \"Vorname_RgAdr\": string,\n \"Nachname_RgAdr\": string,\n \"Strasse_RgAdr\": string,\n \"Hausnummer_RgAdr\": string,\n \"Stadt_RgAdr\": string,\n \"Bundesland_RgAdr\": string,\n \"PLZ_RgAdr\": string,\n \"Land_RgAdr\": string,\n \"EmailKunde\": string,\n \"Vorname_LfAdr\": string,\n \"Nachname_LfAdr\": string,\n \"Strasse_LfAdr\": string,\n \"Hausnummer_LfAdr\": string,\n \"Stadt_LfAdr\": string,\n \"Bundesland_LfAdr\": string,\n \"PLZ_LfAdr\": string,\n \"Land_LfAdr\": string,\n \"Liefermethode\": string,\n \"Netto\": number,\n \"Versandkosten\": number,\n \"Mehrwertsteuer\": number,\n \"Rabatt\": number,\n \"Gesamtsumme\": number,\n\"Rabattcode\": string\n \"lineItems\": [\n {\n \"titel\": string,\n \"artikelnummer\": string,\n \"preisEinheit\": number,\n \"artikelanzahl\": number\n }\n // repeat for each line item\n ]\n}" + } + ] + }, + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.openAi", + "typeVersion": 1.8, + "position": [ + 144, + -784 + ], + "id": "7ab009e6-5c24-48cb-ad34-28886cfec035", + "name": "Message a model", + "retryOnFail": true, + "waitBetweenTries": 5000, + "credentials": { + "openAiApi": { + "id": "CQ31lEApDEhhVATP", + "name": "OpenAi OpenRouter" + } + } + }, + { + "parameters": { + "jsCode": "// n8n Code-Node – parse AI-Agent output into a clean JSON object\nreturn items.map(item => {\n // 1) Roh-String aus dem AI-Step ziehen\n let content = item.json.message?.content\n || item.json.content\n || '';\n \n // 2) Code-Fence und Markdown-Marker entfernen\n content = content\n .replace(/```json\\s*/g, '')\n .replace(/```/g, '')\n .trim();\n \n // 3) Falls der Agent eine Array-Antwort (z.B. “[ { … } ]”) liefert,\n // das Array parsen und das erste Objekt herausgreifen\n if (content.startsWith('[') && content.endsWith(']')) {\n let arr;\n try {\n arr = JSON.parse(content);\n } catch {\n throw new Error(`Kann AI-Array nicht parsen:\\n${content}`);\n }\n if (!Array.isArray(arr) || arr.length === 0) {\n throw new Error(`Erwartetes Array mit mindestens einem Element, bekam:\\n${content}`);\n }\n // Wenn das Array schon Deine Order-Objekte enthält (kein wrapper), nimm arr[0]\n // Andernfalls, falls es das n8n-wrapper-Format ist, grabe tiefer:\n content = JSON.stringify(\n typeof arr[0].message?.content === 'string'\n // falls das erste Element wieder ein wrapper-Objekt mit .message.content ist:\n ? JSON.parse(arr[0].message.content.replace(/```/g, '').trim())\n // sonst direkt das erste Element\n : arr[0]\n );\n }\n \n // 4) Jetzt das finale JSON parsen\n let data;\n try {\n data = JSON.parse(content);\n } catch (e) {\n throw new Error(`Failed to JSON.parse AI output:\\n${content}`);\n }\n \n // 5) Als neues Item zurückgeben\n return { json: data };\n});" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 480, + -784 + ], + "id": "a199695a-ed3f-4b16-b1c2-0dc5bf143fd8", + "name": "JSON Parsen1" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "dbe1657a-046d-44a2-a602-588bb6e2e83a", + "leftValue": "={{ $json.headers.subject }}", + "rightValue": "Neue Bestellung", + "operator": { + "type": "string", + "operation": "contains" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -64, + -768 + ], + "id": "8c16451e-288d-4348-b04e-d072a133bc8f", + "name": "If" + }, + { + "parameters": { + "method": "POST", + "url": "https://erpnaurua.imhochrain.ch/order-import.php", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "X-Webhook-Secret", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmODJlZDZhOC00NzA0LTRmODAtODYxNy02MjJmZDU5MTFkNTYiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiw" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json }}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + } + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 736, + -784 + ], + "id": "5c81fac4-8ca3-471a-a546-0e73995446a5", + "name": "Bestellung an ERP senden" + } + ], + "connections": { + "Email Trigger (IMAP)": { + "main": [ + [ + { + "node": "If", + "type": "main", + "index": 0 + } + ] + ] + }, + "Message a model": { + "main": [ + [ + { + "node": "JSON Parsen1", + "type": "main", + "index": 0 + } + ] + ] + }, + "JSON Parsen1": { + "main": [ + [ + { + "node": "Bestellung an ERP senden", + "type": "main", + "index": 0 + } + ] + ] + }, + "If": { + "main": [ + [ + { + "node": "Message a model", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bestellung an ERP senden": { + "main": [ + [] + ] + } + }, + "authors": "Mathias Gläser", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-03-29T18:33:43.293Z", + "id": 144, + "workflowId": "yNLjtV9yG0T6CqSr", + "versionId": "0d0f1eec-82c8-4b6e-9770-bbbeca45a905", + "event": "activated", + "userId": "f82ed6a8-4704-4f80-8617-622fd5911d56" + }, + { + "createdAt": "2026-03-29T18:33:43.221Z", + "id": 143, + "workflowId": "yNLjtV9yG0T6CqSr", + "versionId": "0d0f1eec-82c8-4b6e-9770-bbbeca45a905", + "event": "deactivated", + "userId": "f82ed6a8-4704-4f80-8617-622fd5911d56" + } + ] + } +} diff --git a/n8n/exports/index.json b/n8n/exports/index.json new file mode 100644 index 0000000..6ab72a2 --- /dev/null +++ b/n8n/exports/index.json @@ -0,0 +1,11 @@ +{ + "exported_at": "2026-03-29T18:33:51Z", + "source": "n8n", + "workflows": [ + { + "id": "yNLjtV9yG0T6CqSr", + "name": "Bestell-Eingang Online-Shop", + "file": "n8n/exports/current/bestell-eingang-online-shop.yNLjtV9yG0T6CqSr.json" + } + ] +} diff --git a/order-import.php b/order-import.php new file mode 100644 index 0000000..0677daa --- /dev/null +++ b/order-import.php @@ -0,0 +1,442 @@ += 2) { + $first = $value[0]; + $last = $value[strlen($value) - 1]; + if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) { + $value = substr($value, 1, -1); + } + } + + $result[$key] = $value; + } + + return $result; +} + +function expand_env_values(array $env): array +{ + $expanded = $env; + $pattern = '/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/'; + + foreach ($expanded as $key => $value) { + $expanded[$key] = preg_replace_callback( + $pattern, + static function (array $matches) use (&$expanded): string { + $lookup = $matches[1]; + return (string) ($expanded[$lookup] ?? getenv($lookup) ?: ''); + }, + (string) $value + ) ?? (string) $value; + } + + return $expanded; +} + +function env_value(string $key, array $localEnv, string $default = ''): string +{ + $runtime = getenv($key); + if ($runtime !== false && $runtime !== '') { + return $runtime; + } + + if (isset($localEnv[$key]) && $localEnv[$key] !== '') { + return (string) $localEnv[$key]; + } + + return $default; +} + +function parse_number(mixed $value): ?float +{ + if ($value === null || $value === '') { + return null; + } + + if (is_int($value) || is_float($value)) { + return (float) $value; + } + + if (!is_string($value)) { + return null; + } + + $normalized = str_replace(["\u{00A0}", ' '], '', trim($value)); + $normalized = str_replace(',', '.', $normalized); + + if (!is_numeric($normalized)) { + return null; + } + + return (float) $normalized; +} + +function lookup_method_id(PDO $pdo, string $table, ?string $code): ?int +{ + if ($code === null || $code === '') { + return null; + } + + $stmt = $pdo->prepare("SELECT id FROM {$table} WHERE code = :code LIMIT 1"); + $stmt->execute([':code' => $code]); + $id = $stmt->fetchColumn(); + return $id === false ? null : (int) $id; +} + +function map_payment_code(string $input): ?string +{ + $v = strtolower(trim($input)); + if ($v === '') { + return null; + } + + if (str_contains($v, 'twint')) { + return 'twint'; + } + + if (str_contains($v, 'bank') || str_contains($v, 'vorauskasse') || str_contains($v, 'ueberweisung')) { + return 'bank_transfer'; + } + + if (str_contains($v, 'kredit') || str_contains($v, 'debit') || str_contains($v, 'card')) { + return 'card'; + } + + return null; +} + +function map_shipping_code(string $input): ?string +{ + $v = strtolower(trim($input)); + if ($v === '') { + return null; + } + + if (str_contains($v, 'abholung') || str_contains($v, 'pickup')) { + return 'pickup'; + } + + if (str_contains($v, 'post') || str_contains($v, 'versand')) { + return 'post_standard'; + } + + return null; +} + +function connect_database(array $localEnv): PDO +{ + $databaseUrl = env_value('DATABASE_URL', $localEnv); + if ($databaseUrl !== '') { + $parts = parse_url($databaseUrl); + if ($parts !== false && ($parts['scheme'] ?? '') === 'postgresql') { + $host = (string) ($parts['host'] ?? ''); + $port = (string) ($parts['port'] ?? '5432'); + $dbName = ltrim((string) ($parts['path'] ?? ''), '/'); + $user = (string) ($parts['user'] ?? ''); + $pass = (string) ($parts['pass'] ?? ''); + if ($host !== '' && $dbName !== '' && $user !== '') { + $dsn = "pgsql:host={$host};port={$port};dbname={$dbName}"; + return new PDO($dsn, $user, $pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } + } + } + + $host = env_value('DB_HOST', $localEnv); + $port = env_value('DB_PORT', $localEnv, '5432'); + $dbName = env_value('DB_NAME', $localEnv); + $user = env_value('DB_USER', $localEnv); + $pass = env_value('DB_PASSWORD', $localEnv); + + if ($host === '' || $dbName === '' || $user === '') { + throw new RuntimeException('Missing DB configuration'); + } + + $dsn = "pgsql:host={$host};port={$port};dbname={$dbName}"; + return new PDO($dsn, $user, $pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); +} + +function find_or_create_party(PDO $pdo, array $data): int +{ + $email = trim((string) ($data['EmailKunde'] ?? '')); + $firstName = trim((string) ($data['Vorname_RgAdr'] ?? '')); + $lastName = trim((string) ($data['Nachname_RgAdr'] ?? '')); + $name = trim($firstName . ' ' . $lastName); + if ($name === '') { + $name = 'Online-Shop Kunde'; + } + + if ($email !== '') { + $findStmt = $pdo->prepare('SELECT id FROM party WHERE lower(email) = lower(:email) ORDER BY id ASC LIMIT 1'); + $findStmt->execute([':email' => $email]); + $existing = $findStmt->fetchColumn(); + if ($existing !== false) { + $partyId = (int) $existing; + $updateStmt = $pdo->prepare('UPDATE party SET name = :name, updated_at = NOW() WHERE id = :id'); + $updateStmt->execute([':id' => $partyId, ':name' => $name]); + return $partyId; + } + } + + $insertStmt = $pdo->prepare( + 'INSERT INTO party (type, name, email, status, created_at, updated_at) + VALUES (\'customer\', :name, :email, \'active\', NOW(), NOW()) + RETURNING id' + ); + $insertStmt->execute([ + ':name' => $name, + ':email' => $email !== '' ? $email : null, + ]); + + $id = $insertStmt->fetchColumn(); + if ($id === false) { + throw new RuntimeException('Could not create party'); + } + + return (int) $id; +} + +function upsert_addresses(PDO $pdo, int $partyId, array $data): void +{ + $delete = $pdo->prepare('DELETE FROM address WHERE party_id = :party_id AND type IN (\'billing\', \'shipping\')'); + $delete->execute([':party_id' => $partyId]); + + $insert = $pdo->prepare( + 'INSERT INTO address ( + party_id, type, first_name, last_name, street, house_number, zip, city, state_code, country_name, raw_payload, created_at, updated_at + ) VALUES ( + :party_id, :type, :first_name, :last_name, :street, :house_number, :zip, :city, :state_code, :country_name, :raw_payload::jsonb, NOW(), NOW() + )' + ); + + $insert->execute([ + ':party_id' => $partyId, + ':type' => 'billing', + ':first_name' => trim((string) ($data['Vorname_RgAdr'] ?? '')), + ':last_name' => trim((string) ($data['Nachname_RgAdr'] ?? '')), + ':street' => trim((string) ($data['Strasse_RgAdr'] ?? '')), + ':house_number' => trim((string) ($data['Hausnummer_RgAdr'] ?? '')), + ':zip' => trim((string) ($data['PLZ_RgAdr'] ?? '')), + ':city' => trim((string) ($data['Stadt_RgAdr'] ?? '')), + ':state_code' => trim((string) ($data['Bundesland_RgAdr'] ?? '')), + ':country_name' => trim((string) ($data['Land_RgAdr'] ?? '')), + ':raw_payload' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + + $insert->execute([ + ':party_id' => $partyId, + ':type' => 'shipping', + ':first_name' => trim((string) ($data['Vorname_LfAdr'] ?? '')), + ':last_name' => trim((string) ($data['Nachname_LfAdr'] ?? '')), + ':street' => trim((string) ($data['Strasse_LfAdr'] ?? '')), + ':house_number' => trim((string) ($data['Hausnummer_LfAdr'] ?? '')), + ':zip' => trim((string) ($data['PLZ_LfAdr'] ?? '')), + ':city' => trim((string) ($data['Stadt_LfAdr'] ?? '')), + ':state_code' => trim((string) ($data['Bundesland_LfAdr'] ?? '')), + ':country_name' => trim((string) ($data['Land_LfAdr'] ?? '')), + ':raw_payload' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); +} + +if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']); +} + +$env = parse_env_file(__DIR__ . '/.env'); +$env = array_merge($env, parse_env_file(dirname(__DIR__) . '/.env')); +$env = expand_env_values($env); + +$expectedSecret = env_value('N8N_WEBHOOK_SECRET', $env); +$providedSecret = (string) ($_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? ''); + +if ($expectedSecret === '') { + json_response(500, ['ok' => false, 'error' => 'N8N_WEBHOOK_SECRET not configured']); +} + +if ($providedSecret === '' || !hash_equals($expectedSecret, $providedSecret)) { + json_response(401, ['ok' => false, 'error' => 'Unauthorized']); +} + +$rawPayload = file_get_contents('php://input'); +if ($rawPayload === false || trim($rawPayload) === '') { + json_response(400, ['ok' => false, 'error' => 'Empty payload']); +} + +try { + $data = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR); +} catch (JsonException) { + json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']); +} + +if (!is_array($data)) { + json_response(400, ['ok' => false, 'error' => 'JSON object expected']); +} + +$externalRef = trim((string) ($data['BestellungNr'] ?? '')); +if ($externalRef === '') { + json_response(422, ['ok' => false, 'error' => 'BestellungNr is required']); +} + +$lineItems = $data['lineItems'] ?? []; +if (!is_array($lineItems)) { + $lineItems = []; +} + +try { + $pdo = connect_database($env); + $pdo->beginTransaction(); + + $partyId = find_or_create_party($pdo, $data); + upsert_addresses($pdo, $partyId, $data); + + $paymentMethodId = lookup_method_id($pdo, 'payment_method', map_payment_code((string) ($data['Zahlungsmethode'] ?? ''))); + $shippingMethodId = lookup_method_id($pdo, 'shipping_method', map_shipping_code((string) ($data['Liefermethode'] ?? ''))); + + $orderStmt = $pdo->prepare( + 'INSERT INTO sales_order ( + external_ref, party_id, order_source, order_status, payment_status, payment_method_id, shipping_method_id, + amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency, webhook_payload, imported_at, created_at, updated_at + ) VALUES ( + :external_ref, :party_id, \'wix\', \'imported\', \'paid\', :payment_method_id, :shipping_method_id, + :amount_net, :amount_shipping, :amount_tax, :amount_discount, :total_amount, \'CHF\', :webhook_payload::jsonb, NOW(), NOW(), NOW() + ) + ON CONFLICT (external_ref) DO UPDATE SET + party_id = EXCLUDED.party_id, + order_source = EXCLUDED.order_source, + order_status = EXCLUDED.order_status, + payment_status = EXCLUDED.payment_status, + payment_method_id = EXCLUDED.payment_method_id, + shipping_method_id = EXCLUDED.shipping_method_id, + amount_net = EXCLUDED.amount_net, + amount_shipping = EXCLUDED.amount_shipping, + amount_tax = EXCLUDED.amount_tax, + amount_discount = EXCLUDED.amount_discount, + total_amount = EXCLUDED.total_amount, + currency = EXCLUDED.currency, + webhook_payload = EXCLUDED.webhook_payload, + imported_at = NOW(), + updated_at = NOW() + RETURNING id' + ); + + $orderStmt->execute([ + ':external_ref' => $externalRef, + ':party_id' => $partyId, + ':payment_method_id' => $paymentMethodId, + ':shipping_method_id' => $shippingMethodId, + ':amount_net' => parse_number($data['Netto'] ?? null), + ':amount_shipping' => parse_number($data['Versandkosten'] ?? null), + ':amount_tax' => parse_number($data['Mehrwertsteuer'] ?? null), + ':amount_discount' => parse_number($data['Rabatt'] ?? null), + ':total_amount' => parse_number($data['Gesamtsumme'] ?? null), + ':webhook_payload' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + + $orderId = $orderStmt->fetchColumn(); + if ($orderId === false) { + throw new RuntimeException('Could not upsert order'); + } + $orderId = (int) $orderId; + + $deleteLines = $pdo->prepare('DELETE FROM sales_order_line WHERE sales_order_id = :sales_order_id'); + $deleteLines->execute([':sales_order_id' => $orderId]); + + $lineInsert = $pdo->prepare( + 'INSERT INTO sales_order_line ( + sales_order_id, line_no, sellable_item_id, raw_external_article_number, raw_external_title, + qty, unit_price, line_total, created_at, updated_at + ) VALUES ( + :sales_order_id, :line_no, NULL, :article_number, :title, + :qty, :unit_price, :line_total, NOW(), NOW() + )' + ); + + $insertedLines = 0; + foreach ($lineItems as $index => $lineItem) { + if (!is_array($lineItem)) { + continue; + } + + $qty = parse_number($lineItem['artikelanzahl'] ?? null); + if ($qty === null || $qty <= 0) { + continue; + } + + $unitPrice = parse_number($lineItem['preisEinheit'] ?? null); + $lineTotal = $unitPrice !== null ? round($qty * $unitPrice, 2) : null; + + $lineInsert->execute([ + ':sales_order_id' => $orderId, + ':line_no' => $index + 1, + ':article_number' => trim((string) ($lineItem['artikelnummer'] ?? '')), + ':title' => trim((string) ($lineItem['titel'] ?? '')), + ':qty' => $qty, + ':unit_price' => $unitPrice, + ':line_total' => $lineTotal, + ]); + $insertedLines++; + } + + $pdo->commit(); + + json_response(200, [ + 'ok' => true, + 'orderId' => $orderId, + 'externalRef' => $externalRef, + 'lineItemsImported' => $insertedLines, + ]); +} catch (Throwable $e) { + if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) { + $pdo->rollBack(); + } + + json_response(500, [ + 'ok' => false, + 'error' => 'Order import failed', + 'detail' => $e->getMessage(), + ]); +} diff --git a/public/deploy.php b/public/deploy.php new file mode 100644 index 0000000..337c0a1 --- /dev/null +++ b/public/deploy.php @@ -0,0 +1,55 @@ + /dev/null 2>&1 &'); +echo 'Deploy triggered'; diff --git a/public/order-import.php b/public/order-import.php new file mode 100644 index 0000000..da2ef0a --- /dev/null +++ b/public/order-import.php @@ -0,0 +1,4 @@ +