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

This commit is contained in:
gitea_admin committed 2026-03-29 20:45:00 +02:00
1 parent 09c978f8aa
commit b214723a46
18 files changed
+3554

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Environment files
.env
.env.*
!.env.example
# macOS
.DS_Store
+55
View File
@@ -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).
+699
View File
@@ -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;
@@ -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;
@@ -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;
+185
View File
@@ -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;
+19
View File
@@ -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"
+55
View File
@@ -0,0 +1,55 @@
<?php
$method = $_SERVER['REQUEST_METHOD'] ?? '';
if ($method !== 'POST') {
http_response_code(405);
echo 'Method Not Allowed';
exit;
}
$payload = file_get_contents('php://input');
if ($payload === false || $payload === '') {
http_response_code(400);
echo 'Empty payload';
exit;
}
$event = $_SERVER['HTTP_X_GITEA_EVENT'] ?? '';
$secret = getenv('GITEA_WEBHOOK_SECRET');
$signature = $_SERVER['HTTP_X_GITEA_SIGNATURE'] ?? '';
if ($secret !== false && $secret !== '') {
if ($signature === '') {
http_response_code(401);
echo 'Missing signature';
exit;
}
$hash = hash_hmac('sha256', $payload, $secret, false);
if (!hash_equals($hash, $signature)) {
http_response_code(401);
echo 'Invalid signature';
exit;
}
}
$decoded = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo 'Invalid JSON';
exit;
}
if ($event !== 'push') {
http_response_code(202);
echo 'Ignored';
exit;
}
$ref = $decoded['ref'] ?? '';
if ($ref !== 'refs/heads/main') {
http_response_code(202);
echo 'Ignored';
exit;
}
exec('/bin/sudo -u admin_hz2 /bin/bash /volume2/webssd/erpnaurua/dev/deploy-staging.sh > /dev/null 2>&1 &');
echo 'Deploy triggered';
+262
View File
@@ -0,0 +1,262 @@
# Concept
## Vision
ERP Naurua bildet den operativen Kern fuer Einkauf, Lager und Auftragsabwicklung mit voller Nachvollziehbarkeit von Kontakten, Bestellungen und Chargen.
## Fachliches Zielbild
ERP Naurua dient als zentraler operativer Kern fuer einen Heilpilzhandel mit zwei Verkaufskanaelen: Online-Shop (Wix) und Direktverkauf (z. B. Marktstand). Online-Bestelldaten kommen ueber eine Integrationsschicht (n8n Webhook), Direktverkaeufe werden im ERP manuell als Tages-/Sammelerfassung gebucht. Das System ist intern fuer Lager, Chargen, MHD sowie Rueckverfolgung verantwortlich. Die Kundinnen und Kunden werden im gemeinsamen Kontaktmodell verwaltet (Kunde/Lieferant als Rollen); Direktverkaeufe duerfen ohne individuellen Kundenkontakt erfasst werden. Bestellungen werden mit Chargenbewegungen verknuepft, sodass jede Entnahme auf Charge und Lagerort zurueckfuehrbar ist.
Das Zielbild ist modular erweiterbar: Rechnungswesen, Versandautomatisierung und Beratung werden als Module angefuegt, ohne das Kernmodell zu zerbrechen. Die Prioritaet in Schritt 1 liegt auf Datenqualitaet, Rueckverfolgbarkeit und klaren, auditierbaren Bewegungen.
## Scope
Der erste Umsetzungsschritt umfasst ausschliesslich die Module, die mit `(1)` markiert wurden:
1. Lagerverwaltung mit Chargen, MHD, Zu- und Abgaengen
2. Bestellerfassung mit Chargenrueckverfolgung
3. Kontakt-Angaben inkl. Liefer- und Kundenangaben
## Non-Goals
Nicht Bestandteil von Schritt 1:
1. Kreditoren- und Debitoren-Stammdaten
2. Rechnungseingang (inkl. OCR)
3. Rechnungspruefung und Freigabe-Workflows
4. Automatische Zuordnung von Rechnungen zu Bestellungen/Lieferungen/Leistungen
5. Operative Nachverfolgung von Rechnungsvorgaengen
## Principles
1. Traceability first: Jede Warenbewegung und jede Bestellung muss auf Charge rueckfuehrbar sein; bei Direktverkauf ist ein Laufkundenfall ohne Kontakt zulaessig.
2. Daten vor UI-Komfort: Zuerst robuste Datenmodelle und Prozesse, danach Optimierungen.
3. Erweiterbarkeit: Schritt-1-Modelle muessen spaetere Finanz- und Rechnungsprozesse aufnehmen koennen.
4. Einfache Integrationsfaehigkeit: Importfaehigkeit fuer externe Bestelldaten ist von Beginn an mitzudenken.
5. Kanaltrennung: Online- und Direktverkaeufe muessen technisch eindeutig unterscheidbar sein.
## Users And Roles
1. Einkauf/Disposition: Erfasst und verwaltet Bestellungen.
2. Lager/Logistik: Pflegt Chargen, MHD sowie Warenzu- und -abgaenge.
3. Administration/Backoffice: Verwalten von Kunden- und Lieferkontakten.
## Domain Overview
Schritt 1 basiert auf drei Kern-Domaenen:
1. Kontakte: Kunden und Lieferanten mit abrechnungs- und lieferrelevanten Angaben.
2. Bestellungen: Bestellungskopf und Positionen aus Online-Import und Direktverkauf, gekoppelt an betroffene Chargen; Kontaktzuordnung ist fuer Direktverkauf optional.
3. Lager/Chargen: Bestandsfuehrung je Produkt/Charge inkl. MHD und Bewegungen.
Kernaussage: Eine Bestellung kann mehrere Positionen enthalten; jede Position kann einer oder mehreren Chargen zugeordnet werden; jede Charge hat Bewegungen und MHD-Status.
## System Overview
Schritt-1-Systemkomponenten:
1. Kontaktmodul (CRUD, Basisspeicherung, optionale Felder fuer spaetere Erweiterung)
2. Bestellmodul (Bestellungskopf, Positionen, Upsert ueber externe Bestellnummer)
3. Artikel-Mapping (externe Shop-Artikel -> interne verkaufbare Artikel -> lagergefuehrte Produkte)
4. Lagermodul (Chargenstamm, MHD, Bewegungsjournal, aktueller Bestand)
5. Import-Schnittstelle fuer initiale Bestelldaten (n8n Webhook JSON)
6. Direktverkaufs-Erfassung (Tages-/Sammelverkauf ohne Kundenregistrierung)
## Grobmodell (Schema) Schritt 1
Die folgenden Entitaeten bilden das Zielschema fuer Schritt 1. Felder sind als grobe Vorschlaege zu verstehen und werden in der Detailphase konkretisiert.
Der Entwurf des SQL-Schemas liegt unter:
`/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/SCHEMA_PHASE1.sql`
Die ausfuehrbaren Migrationen liegen unter:
`/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/db/migrations/`
1. `party`
- Zweck: Kunde oder Lieferant (Rollenmodell).
- Kernfelder: `id`, `type` (customer/supplier/both), `name`, `email`, `phone`, `status`.
2. `address`
- Zweck: Liefer- und Rechnungsadressen pro Kontakt.
- Kernfelder: `id`, `party_id`, `type` (billing/shipping), `street`, `house_number`, `zip`, `city`, `state_code`, `country_name`, `country_iso2`.
3. `contact`
- Zweck: Ansprechpartner oder zusaetzliche Kontaktpunkte.
- Kernfelder: `id`, `party_id`, `first_name`, `last_name`, `email`, `phone`.
4. `product`
- Zweck: Lagergefuehrtes Produkt (Bestandsfuehrung, Charge, MHD).
- Kernfelder: `id`, `sku`, `name`, `status`, `uom`.
5. `sellable_item`
- Zweck: Verkaufbarer Shop-Artikel (kann Bundle sein).
- Kernfelder: `id`, `item_code`, `display_name`, `status`.
6. `external_item_alias`
- Zweck: Mapping von externen Webshop-Artikeldaten auf interne Artikel.
- Kernfelder: `id`, `source_system`, `external_article_number`, `external_title`, `title_normalized`, `sellable_item_id`.
7. `sellable_item_component`
- Zweck: Stueckliste je Shop-Artikel auf lagergefuehrte Produkte.
- Kernfelder: `id`, `sellable_item_id`, `product_id`, `qty_per_item`.
8. `warehouse`
- Zweck: Lagerstandort auf hoher Ebene.
- Kernfelder: `id`, `name`, `code`.
9. `location`
- Zweck: Lagerorte innerhalb eines Lagers.
- Kernfelder: `id`, `warehouse_id`, `code`, `name`, `type` (storage/receiving/dispatch).
10. `stock_lot`
- Zweck: Charge/Batch mit MHD.
- Kernfelder: `id`, `product_id`, `lot_number`, `mfg_date`, `expiry_date`, `status`, `sellout_date`, `warning_state`.
11. `payment_method` und `shipping_method`
- Zweck: Normalisierte interne Werte fuer Zahlungs- und Lieferart.
- Kernfelder: `id`, `code`, `label`, `is_active`.
12. `sales_order`
- Zweck: Bestellungskopf (Online-Shop und Direktverkauf).
- Kernfelder: `id`, `external_ref` (unique), `order_source`, `party_id` (optional bei Direktverkauf), `order_date`, `order_status`, `payment_status`, Summenfelder, `webhook_payload`.
13. `sales_order_line`
- Zweck: Bestellpositionen.
- Kernfelder: `id`, `sales_order_id`, `line_no`, `sellable_item_id`, `raw_external_article_number`, `raw_external_title`, `qty`, `unit_price`.
14. `stock_move`
- Zweck: Zu- und Abgaenge sowie Umlagerungen.
- Kernfelder: `id`, `product_id`, `lot_id`, `from_location_id`, `to_location_id`, `qty`, `move_type`, `move_date`.
15. `sales_order_line_lot_allocation`
- Zweck: Explizite Zuordnung Bestellposition <-> Charge (Rueckverfolgung).
- Kernfelder: `id`, `sales_order_line_id`, `product_id`, `lot_id`, `qty`, `stock_move_id`.
16. `audit_log`
- Zweck: Standardisierte Historisierung von Importen und Aenderungen.
- Kernfelder: `id`, `entity_name`, `entity_id`, `action`, `changed_at`, `before_data`, `after_data`.
17. `outbound_webhook_event`
- Zweck: Outbox-Queue fuer robuste ERP -> n8n Zustellung.
- Kernfelder: `id`, `event_type`, `event_key`, `aggregate_type`, `aggregate_id`, `payload`, `status`, `attempt_count`.
## Skizze (ERD)
```mermaid
erDiagram
PARTY ||--o{ ADDRESS : "has"
PARTY ||--o{ CONTACT : "has"
PARTY o|--o{ SALES_ORDER : "places_or_walkin"
SALES_ORDER ||--o{ SALES_ORDER_LINE : "contains"
SELLABLE_ITEM ||--o{ SALES_ORDER_LINE : "ordered_item"
SELLABLE_ITEM ||--o{ EXTERNAL_ITEM_ALIAS : "mapped_from"
SELLABLE_ITEM ||--o{ SELLABLE_ITEM_COMPONENT : "contains"
PRODUCT ||--o{ SELLABLE_ITEM_COMPONENT : "component"
WAREHOUSE ||--o{ LOCATION : "contains"
PRODUCT ||--o{ STOCK_LOT : "has"
PRODUCT ||--o{ STOCK_MOVE : "moves"
STOCK_LOT ||--o{ STOCK_MOVE : "tracks"
LOCATION ||--o{ STOCK_MOVE : "from_to"
SALES_ORDER_LINE ||--o{ SALES_ORDER_LINE_LOT_ALLOCATION : "allocates"
STOCK_LOT ||--o{ SALES_ORDER_LINE_LOT_ALLOCATION : "traces_to"
PRODUCT ||--o{ SALES_ORDER_LINE_LOT_ALLOCATION : "allocated_product"
```
## Phase-1 Beziehungen (fachlich)
1. Ein Kontakt (`party`) kann mehrere Adressen und Kontakte besitzen.
2. Eine Bestellung kann einem Kontakt zugeordnet sein (`sales_order.party_id`), bei Direktverkauf ist `NULL` zulaessig.
3. Eine Bestellung besteht aus mehreren Positionen (`sales_order_line`).
4. Jede Position referenziert genau einen verkaufbaren Artikel (`sellable_item`), nicht direkt ein Lagerprodukt.
5. Ein verkaufbarer Artikel kann aus mehreren lagergefuehrten Produkten bestehen (`sellable_item_component`).
6. Chargen (`stock_lot`) gehoeren zu genau einem lagergefuehrten Produkt (`product`).
7. Lagerbewegungen (`stock_move`) referenzieren Produkt und Charge und bewegen Menge von einem Lagerort zum anderen.
8. Rueckverfolgung erfolgt ueber `sales_order_line_lot_allocation` und kann je Position ueber mehrere Chargen gesplittet werden.
9. `sales_order.external_ref` ist eindeutig; fuer Online-Import ist es der Upsert-Schluessel, fuer Direktverkauf wird es im ERP mit `DIR-`-Praefix erzeugt.
10. Outbound-Ereignisse werden ueber `outbound_webhook_event` idempotent publiziert.
## Data And Integrations
Minimale Kernobjekte fuer Schritt 1:
1. Kontakt
2. Adresse (Rechnungsadresse, Lieferadresse)
3. Bestellung
4. Bestellposition
5. Verkaufbarer Artikel (`sellable_item`)
6. Lagerprodukt (`product`)
7. Artikel-Mapping (`external_item_alias`)
8. Artikel-Stueckliste (`sellable_item_component`)
9. Charge
10. Lagerbewegung (Zugang/Abgang)
11. Bestellpositions-zu-Chargen-Zuordnung (`sales_order_line_lot_allocation`)
12. Verkaufsquelle im Auftrag (`sales_order.order_source`: `wix` | `direct`)
Zuordnung zum Beispiel-Datensatz:
1. `BestellungNr`, `Zahlungsstatus`, Summenfelder -> Bestellung
2. `Vorname_*`, `Nachname_*`, `EmailKunde` -> Kontakt
3. `*_RgAdr`, `*_LfAdr` -> Adresse
4. `lineItems[*]` -> Bestellposition mit Rohdaten (`raw_external_article_number`, `raw_external_title`)
5. `lineItems[*]` + Alias-Mapping -> interner Artikel (`sellable_item`)
6. Artikel-Stueckliste -> benoetigte Lagerprodukte pro Position
7. Kommissionierung/Abgang -> Chargenzuordnung in `sales_order_line_lot_allocation`
Webhook-Regeln fuer Schritt 1 (Online-Shop):
1. Upsert erfolgt ueber `sales_order.external_ref = BestellungNr`.
2. Zahlungsstatus aus Online-Bestellung wird intern standardmaessig als `paid` gespeichert.
3. Rechnungsadresse darf leer sein; Lieferadresse wird normal erfasst.
4. Adressen speichern Klarname (`country_name`) und ISO-Code (`country_iso2`) parallel.
5. Adressdaten werden nur gespeichert, nicht serverseitig validiert (Validierung erfolgt im Shop).
Direktverkauf-Regeln fuer Schritt 1:
1. Direktverkaeufe werden im ERP erfasst (`sales_order.order_source = direct`) und kommen nicht ueber n8n-Webhook.
2. Die Bestellnummer wird im ERP erzeugt und hat den Praefix `DIR-` (z. B. `DIR-20260329-00017`).
3. `sales_order.party_id` ist optional; bei Laufkundschaft wird kein individueller Kontakt angelegt.
4. Positionen werden als Sammelverkauf mit Mengen je Produkt erfasst.
5. Der brutto Gesamtpreis kann auf Flaschenebene verteilt werden (Durchschnittspreis = Gesamtpreis / Gesamtmenge).
6. Zahlungsart wird explizit gespeichert (z. B. `twint`, `cash`, `paypal`, `bank_transfer`).
Lagerregeln fuer Schritt 1:
1. Pro Produkt existiert operativ genau eine `current`-Charge und genau eine `open`-Charge.
2. `open` darf nicht fehlen; sie ist der direkte Ueberlauf fuer den naechsten operativen Entnahmefall.
3. Statusfluss fuer Chargen: `open -> current -> closed`.
4. Bei Erreichen von `qty_net <= 0` auf der `current`-Charge erfolgt automatischer Wechsel auf die vorhandene `open`-Charge.
5. Nach dem Wechsel wird automatisch wieder eine neue `open`-Charge erzeugt (lot_number bleibt initial leer bis manuell gesetzt).
6. Negative Chargenbestaende sind nicht zulaessig.
7. Chargensalden (`in/out/net`) werden aus Bewegungen berechnet.
8. Jede Verkaufsbewegung muss einer Charge zugeordnet sein (kein chargenloser Abgang).
9. Verkaufsbuchung erfolgt standardmaessig gegen die `current`-Charge.
10. Korrekturen bleiben durch editierbare Datensaetze und/oder Korrekturbewegungen moeglich.
11. Abverkaufdatum wird systemintern berechnet; Warnstatus wird fuer UI bereitgestellt (ohne E-Mail-Prozess in Phase 1).
## Milestones
Schritt 1 wird in drei Modulpakete umgesetzt:
1. M1 Kontaktmodul: Datenmodell, API/Service, Basisspeicherung
2. M2 Bestellmodul: Bestellung + Positionen + optionale Kontaktverknuepfung + Upsert
3. M3 Lagermodul: Charge, MHD, Bewegungen, Bestandssicht, Rueckverfolgung zur Bestellung
Operative Ablaufdetails liegen unter:
`/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/PROCESS_PHASE1.md`
Komponenten-Spezifikation liegt unter:
`/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/SPEC_PHASE1_COMPONENTS.md`
DB-Migrationsdetails liegen unter:
`/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/db/README.md`
Erfolgskriterien Schritt 1:
1. Eine Bestellung kann mit Kunden-/Lieferkontakt oder als Direktverkauf ohne Kontakt erfasst werden.
2. Positionen koennen einer oder mehreren Chargen zugeordnet werden.
3. Lagerzugaenge/-abgaenge aktualisieren Bestand pro Charge konsistent.
4. Rueckverfolgung Bestellung <-> Charge <-> Lagerbewegung ist technisch vorhanden.
## Entscheidungen Fuer Umsetzung
1. Eindeutige Bestellidentifikation ueber `sales_order.external_ref` (Shop: `BestellungNr`, Direktverkauf: `DIR-...`); Dateneingang via Upsert.
2. Rechnungsadresse ist optional (`NULL` zulaessig), Lieferadresse wird separat gespeichert.
3. Interne Normalisierung von Zahlungs- und Liefermethoden ueber Mapping-Tabellen.
4. Land wird als Klarname und ISO-Code gespeichert.
5. Initial keine serverseitige Adressvalidierung.
6. Keine Buchhaltungs-/Rechnungsfunktion in Schritt 1.
7. Chargenrueckverfolgung wird ueber explizite Bestellpositions-Allokation aufgebaut.
8. Lagerbetrieb mit genau einer aktiven und einer vorbereiteten Charge pro Produkt (`current` + `open`).
9. Storno-Fall wird im Modell vorbereitet (Status `cancelled` + Freigabe von Reservierungen).
10. Chargennummer fuer neu vorbereitete Charge wird initial manuell durch Mitarbeitende gesetzt.
11. Abverkaufprognose und Warnlogik werden in die ERP-Datenbank uebernommen; E-Mail-Erinnerungen sind in Phase 1 deaktiviert.
+221
View File
@@ -0,0 +1,221 @@
# Phase 1 Process Spec
## Dokumentstatus
1. Typ: `operational`
2. Detaillierungsgrad: Event- und Ablaufebene
3. Zugehoerige Komponenten-Spezifikation: `/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/SPEC_PHASE1_COMPONENTS.md`
4. Normative Quelle: `/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/CONCEPT.md`
## Zweck
Diese Datei ist die operative Umsetzungsvorlage fuer Schritt 1. Sie uebersetzt die fachlichen Entscheidungen in konkrete Ablaufregeln auf Event-Ebene.
## Geltungsbereich
1. Bestellimport aus Wix via n8n Webhook
2. Kontakt- und Adressanlage
3. Chargenzuordnung und Lagerbewegung
4. Teil-/Vollstorno inklusive Gegenbuchung
5. Outbound-Webhook ERP -> n8n
6. Direktverkauf als manuelle Sammelerfassung (ohne Kundenregistrierung)
## Begriffe
1. `allocated`: Charge ist zugeordnet und Lagerabgang ist bereits gebucht.
2. `reserved`: Optionaler Zwischenstatus, in Phase 1 standardmaessig nicht verwendet.
3. `open`-Charge: vorbereitete naechste Charge je Produkt.
4. `current`-Charge: aktive Abgangscharge je Produkt.
5. `closed`-Charge: abgeschlossene Charge.
6. `order_source`: Herkunft der Bestellung (`wix` oder `direct`).
## Harte Invarianten
1. Pro Produkt genau eine `current`-Charge.
2. Pro Produkt genau eine `open`-Charge.
3. `open` darf nie fehlen.
4. Verkaufsabgang nie ohne Charge.
5. Negativbestand auf Charge ist nicht erlaubt.
6. Datensaetze werden nicht geloescht; Statusaenderung statt Delete.
7. Direktverkaeufe haben `external_ref` mit Praefix `DIR-`.
## Prozess A: `order.imported` (Inbound Webhook, Quelle `wix`)
### Input
Webhook-JSON aus dem Shop (Bestellung + lineItems).
### Schritte
1. Idempotenz pruefen ueber `BestellungNr` (`sales_order.external_ref`).
2. `sales_order` per Upsert speichern (`order_source = wix`, `payment_status = paid`).
3. `party`, `contact`, `address` speichern/aktualisieren.
4. Rechnungsadresse darf `NULL` sein; Lieferadresse normal speichern.
5. Pro `lineItem` internen `sellable_item` aufloesen:
1. zuerst ueber `external_article_number`
2. fallback ueber normalisierten Titel
6. Falls kein Mapping existiert:
1. Position trotzdem speichern (`raw_external_*` gesetzt)
2. in Audit als Mapping-Luecke markieren
7. Fuer gemappte Position jede `sellable_item_component` aufloesen.
8. Fuer jedes benoetigte Lagerprodukt Abgang auf `current`-Charge buchen:
1. `stock_move` mit `move_type = out`
2. `sales_order_line_lot_allocation` mit `allocation_status = allocated`
9. Chargensaldo gegen `v_stock_lot_balance` pruefen (kein negativer Bestand).
10. Wenn `current` nach Abgang `qty_net <= 0` hat:
1. alte `current` auf `closed`
2. vorhandene `open` auf `current`
3. neue `open` automatisch erzeugen (ohne `lot_number`)
11. Outbound-Event in Queue schreiben: `order.imported`.
12. Audit-Log schreiben (import + allocation + auto-switch falls erfolgt).
### Ergebnis
Bestellung ist vollstaendig erfasst, Chargen sind zugeordnet, Lager ist aktualisiert.
## Prozess E: `direct.sale.captured` (manuelle ERP-Erfassung)
### Trigger
Tages-/Sammelverkauf wird im ERP erfasst (z. B. Marktverkauf).
### Schritte
1. ERP erzeugt interne Bestellnummer mit Praefix `DIR-` (z. B. `DIR-20260329-00017`).
2. `sales_order` speichern mit:
1. `order_source = direct`
2. `party_id = NULL` (Laufkundschaft ohne Kontaktanlage)
3. `payment_status = paid`
3. Mengen je Produkt als `sales_order_line` speichern.
4. Gesamtpreis brutto wird auf Gesamtmenge verteilt und als `unit_price` je Zeile gespeichert.
5. Zahlungsart wird aus den Direktverkauf-Methoden gespeichert (`twint`, `cash`, `paypal`, `bank_transfer`).
6. Lagerabgang und Chargenzuordnung laufen identisch zu Prozess A.
7. Outbound-Event wird wie bei Import in die Queue gestellt (`order.imported` mit `orderSource = direct`).
8. Audit-Log schreibt `action = direct_sale_captured`.
## Prozess B: `order.cancelled.partial`
### Trigger
Teil-Storno fuer einzelne Position oder Teilmenge.
### Schritte
1. Ziel-`sales_order_line` laden, `cancel_qty` validieren.
2. `qty_cancelled` erhoehen, `line_status = partially_cancelled` setzen.
3. Bereits `allocated` Menge in gleicher Hoehe rueckbuchen:
1. `stock_move` mit `move_type = adjustment` und positivem Eingang
2. gleiche `lot_id` wie Ursprungsallokation
4. Zugehoerige `sales_order_line_lot_allocation` auf `cancelled` setzen oder anteilig splitten.
5. Wenn Gesamtstorno der Position erreicht ist, `line_status = cancelled`.
6. Wenn alle Positionen storniert sind, `sales_order.order_status = cancelled`.
7. Outbound-Event in Queue schreiben: `order.cancelled.partial`.
8. Audit-Log schreiben.
## Prozess C: `order.cancelled.full`
### Trigger
Vollstorno der Bestellung.
### Schritte
1. Alle offenen Positionen iterieren.
2. Je Position Restmenge stornieren wie in Prozess B.
3. `sales_order.order_status = cancelled`, `cancelled_at`, `cancelled_reason` setzen.
4. Outbound-Event in Queue schreiben: `order.cancelled.full`.
5. Audit-Log schreiben.
## Prozess D: `lot.auto_switched`
### Trigger
Nach Abgang ist `current`-Charge leer (`qty_net <= 0`).
### Schritte
1. Alte `current` auf `closed` setzen.
2. Existierende `open` auf `current` setzen.
3. Neue `open` fuer dasselbe Produkt anlegen:
1. `lot_number = NULL`
2. `status = open`
4. Outbound-Event in Queue schreiben: `lot.auto_switched`.
5. Audit-Log schreiben.
## Outbound Webhook ERP -> n8n
### Ziel
n8n erhaelt statusrelevante Bestell- und Chargenupdates aus dem ERP.
### Delivery Model
1. Outbox/Queue in DB: `outbound_webhook_event`.
2. Worker liest `pending`/`failed` nach `next_attempt_at`.
3. HTTP POST an n8n Incoming Webhook.
4. Bei 2xx: `sent`.
5. Bei Fehler: Retry mit Backoff, danach `dead_letter`.
### Security
1. Header `X-ERP-Signature`: HMAC-SHA256 ueber Raw Body.
2. Header `X-ERP-Event`: Eventtyp.
3. Header `X-ERP-Event-Key`: Idempotenzschluessel.
### Event Payload (Basis)
```json
{
"eventType": "order.imported",
"eventKey": "order.imported:10466:2026-03-29T17:00:00Z",
"occurredAt": "2026-03-29T17:00:00Z",
"order": {
"externalRef": "10466",
"orderSource": "wix",
"orderStatus": "imported",
"paymentStatus": "paid",
"amounts": {
"net": 49.95,
"shipping": 4.95,
"tax": 0,
"discount": 0,
"total": 54.90,
"currency": "CHF"
}
},
"lines": [
{
"lineNo": 1,
"qty": 1,
"qtyCancelled": 0,
"status": "allocated",
"allocations": [
{
"productSku": "REISHI_FLASCHE",
"lotNumber": "2412.003",
"qty": 1
}
]
}
]
}
```
## Retry Policy (Standardannahme)
1. Maximal 10 Versuche.
2. Exponential Backoff: 1m, 2m, 4m, 8m, ... bis 12h.
3. Danach `dead_letter` + operativer Alert.
## Abverkauf-Warnung (Phase 1)
1. Die Prognose wird im ERP intern berechnet (`fn_refresh_sellout_forecast`).
2. Ausgabe erfolgt als Felder/Status fuer die UI (`sellout_date`, `warning_state`), nicht per E-Mail.
3. Warnlogik: `due_60d` bei Abverkaufdatum in <= 60 Tagen, `due_now` bei heute/ueberfaellig.
## Offene Details (werden spaeter mit realem n8n-Setup finalisiert)
1. Finale n8n Ziel-URL pro Umgebung (dev/staging/prod).
2. Secret-Rotation fuer Signatur.
3. Dead-letter-Verarbeitung (manuell oder Requeue-Button).
4. Exakte Liste weiterer Events fuer spaetere Module.
+25
View File
@@ -0,0 +1,25 @@
# Docs Index
This folder is the single home for project documentation.
## Source Of Truth
- `CONCEPT.md` is the normative source for product and system concepts.
## Document Types
- Normative: `CONCEPT.md`
- Operational:
- `SPEC_PHASE1_COMPONENTS.md` (Komponenten-Spezifikation fuer Schritt 1)
- `PROCESS_PHASE1.md` (Ablauflogik fuer Import, Lager, Storno, Outbound-Webhook)
- Derived:
- `SCHEMA_PHASE1.sql` (Draft schema for Schritt 1)
- `../db/migrations/*.sql` (Ausfuehrbare Datenbankmigrationen)
- Historical: none yet
- Legacy: none yet
## How We Use This Folder
- Keep the docs root small and scannable.
- Add new operational docs only when there is a running process to describe.
- If a file only repeats the concept, move it out of the active root or delete it.
+347
View File
@@ -0,0 +1,347 @@
-- ERP Naurua - Phase 1 Draft Schema
-- Status: Draft (Intentional minimal constraints; to be hardened during implementation)
-- Target DB: PostgreSQL-compatible SQL
-- Scope: Lagerverwaltung (Chargen/MHD), Bestellerfassung, Kontaktangaben
-- NOTE
-- - Use this as a starting point for migrations, not as final truth.
-- - Add stricter constraints once business rules are confirmed.
CREATE TABLE party (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'customer', -- customer | supplier | both
name TEXT,
email TEXT,
phone TEXT,
phone_alt TEXT,
tax_id TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_party_email ON party(email);
CREATE TABLE address (
id BIGSERIAL PRIMARY KEY,
party_id BIGINT NOT NULL REFERENCES party(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- billing | shipping
first_name TEXT,
last_name TEXT,
company_name TEXT,
street TEXT,
house_number TEXT,
zip TEXT,
city TEXT,
state_code TEXT,
country_name TEXT,
country_iso2 CHAR(2),
raw_payload JSONB, -- preserves source formatting variants
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_address_party ON address(party_id);
CREATE INDEX idx_address_country_iso2 ON address(country_iso2);
CREATE TABLE contact (
id BIGSERIAL PRIMARY KEY,
party_id BIGINT NOT NULL REFERENCES party(id) ON DELETE CASCADE,
first_name TEXT,
last_name TEXT,
email TEXT,
phone TEXT,
position_title TEXT,
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_contact_party ON contact(party_id);
-- Lagergefuehrtes Produkt (Bestandsfuehrung, Charge, MHD)
CREATE TABLE product (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
uom TEXT NOT NULL DEFAULT 'unit',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_product_sku ON product(sku);
-- Verkaufbarer Shop-Artikel (kann Bundle sein)
CREATE TABLE sellable_item (
id BIGSERIAL PRIMARY KEY,
item_code TEXT NOT NULL, -- interne stabile Kennung
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_sellable_item_code ON sellable_item(item_code);
-- Mapping externer Shop-Daten auf internen Artikel
CREATE TABLE external_item_alias (
id BIGSERIAL PRIMARY KEY,
source_system TEXT NOT NULL DEFAULT 'wix',
external_article_number TEXT,
external_title TEXT,
title_normalized TEXT,
sellable_item_id BIGINT NOT NULL REFERENCES sellable_item(id) ON DELETE CASCADE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ext_alias_article_number ON external_item_alias(external_article_number);
CREATE INDEX idx_ext_alias_title_norm ON external_item_alias(title_normalized);
-- Stueckliste: welcher Artikel enthaelt welche lagergefuehrten Produkte
CREATE TABLE sellable_item_component (
id BIGSERIAL PRIMARY KEY,
sellable_item_id BIGINT NOT NULL REFERENCES sellable_item(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES product(id) ON DELETE RESTRICT,
qty_per_item NUMERIC(14, 4) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_component_qty_positive CHECK (qty_per_item > 0)
);
CREATE UNIQUE INDEX uq_item_component ON sellable_item_component(sellable_item_id, product_id);
CREATE INDEX idx_item_component_product ON sellable_item_component(product_id);
CREATE TABLE warehouse (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_warehouse_code ON warehouse(code);
CREATE TABLE location (
id BIGSERIAL PRIMARY KEY,
warehouse_id BIGINT NOT NULL REFERENCES warehouse(id) ON DELETE RESTRICT,
code TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- storage | receiving | dispatch | adjustment
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_location_warehouse ON location(warehouse_id);
CREATE UNIQUE INDEX uq_location_code_per_warehouse ON location(warehouse_id, code);
CREATE TABLE stock_lot (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES product(id) ON DELETE RESTRICT,
lot_number TEXT,
mfg_date DATE,
expiry_date DATE,
status TEXT NOT NULL DEFAULT 'open', -- open | current | closed
sellout_date DATE,
warning_state TEXT NOT NULL DEFAULT 'none', -- none | due_60d | due_now
supplier_lot_number TEXT,
supplier_name TEXT,
purchase_date DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_stock_lot_status CHECK (status IN ('open', 'current', 'closed')),
CONSTRAINT chk_stock_lot_warning_state CHECK (warning_state IN ('none', 'due_60d', 'due_now')),
CONSTRAINT chk_stock_lot_number_required_for_non_open CHECK (
status = 'open' OR lot_number IS NOT NULL
)
);
CREATE INDEX idx_stock_lot_product ON stock_lot(product_id);
CREATE UNIQUE INDEX uq_stock_lot_product_number ON stock_lot(product_id, lot_number);
CREATE UNIQUE INDEX uq_stock_lot_one_current_per_product ON stock_lot(product_id) WHERE status = 'current';
CREATE UNIQUE INDEX uq_stock_lot_one_open_per_product ON stock_lot(product_id) WHERE status = 'open';
CREATE TABLE payment_method (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
label TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_payment_method_code ON payment_method(code);
CREATE TABLE shipping_method (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
label TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_shipping_method_code ON shipping_method(code);
CREATE TABLE sales_order (
id BIGSERIAL PRIMARY KEY,
external_ref TEXT NOT NULL, -- Wix BestellungNr oder ERP-Ref (DIR-...)
order_source TEXT NOT NULL DEFAULT 'wix', -- wix | direct
party_id BIGINT REFERENCES party(id) ON DELETE RESTRICT, -- bei direct optional
order_date TIMESTAMP NOT NULL DEFAULT NOW(),
order_status TEXT NOT NULL DEFAULT 'received', -- received | imported | fulfilled | cancelled
payment_status TEXT NOT NULL DEFAULT 'paid', -- phase 1 fuehrt nur bezahlte Auftraege
payment_method_id BIGINT REFERENCES payment_method(id) ON DELETE RESTRICT,
shipping_method_id BIGINT REFERENCES shipping_method(id) ON DELETE RESTRICT,
amount_net NUMERIC(14, 2),
amount_shipping NUMERIC(14, 2),
amount_tax NUMERIC(14, 2),
amount_discount NUMERIC(14, 2),
total_amount NUMERIC(14, 2),
currency TEXT NOT NULL DEFAULT 'CHF',
webhook_payload JSONB,
imported_at TIMESTAMP NOT NULL DEFAULT NOW(),
cancelled_at TIMESTAMP,
cancelled_reason TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_sales_order_status CHECK (order_status IN ('received', 'imported', 'fulfilled', 'cancelled')),
CONSTRAINT chk_sales_order_payment_status CHECK (payment_status IN ('paid')),
CONSTRAINT chk_sales_order_source CHECK (order_source IN ('wix', 'direct')),
CONSTRAINT chk_sales_order_direct_ref_prefix CHECK (
order_source <> 'direct' OR external_ref LIKE 'DIR-%'
)
);
CREATE UNIQUE INDEX uq_sales_order_external_ref ON sales_order(external_ref);
CREATE INDEX idx_sales_order_party ON sales_order(party_id);
CREATE INDEX idx_sales_order_source ON sales_order(order_source);
CREATE TABLE sales_order_line (
id BIGSERIAL PRIMARY KEY,
sales_order_id BIGINT NOT NULL REFERENCES sales_order(id) ON DELETE CASCADE,
line_no INTEGER NOT NULL,
sellable_item_id BIGINT REFERENCES sellable_item(id) ON DELETE RESTRICT,
raw_external_article_number TEXT,
raw_external_title TEXT,
qty NUMERIC(14, 4) NOT NULL,
qty_cancelled NUMERIC(14, 4) NOT NULL DEFAULT 0,
line_status TEXT NOT NULL DEFAULT 'allocated', -- allocated | partially_cancelled | cancelled
unit_price NUMERIC(14, 4),
line_total NUMERIC(14, 2),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_sales_order_line_qty_positive CHECK (qty > 0),
CONSTRAINT chk_sales_order_line_qty_cancelled_range CHECK (qty_cancelled >= 0 AND qty_cancelled <= qty),
CONSTRAINT chk_sales_order_line_status CHECK (line_status IN ('allocated', 'partially_cancelled', 'cancelled'))
);
CREATE UNIQUE INDEX uq_sales_order_line_no ON sales_order_line(sales_order_id, line_no);
CREATE INDEX idx_sales_order_line_order ON sales_order_line(sales_order_id);
CREATE INDEX idx_sales_order_line_sellable_item ON sales_order_line(sellable_item_id);
CREATE INDEX idx_sales_order_line_status ON sales_order_line(line_status);
CREATE TABLE stock_move (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES product(id) ON DELETE RESTRICT,
lot_id BIGINT NOT NULL REFERENCES stock_lot(id) ON DELETE RESTRICT,
from_location_id BIGINT REFERENCES location(id) ON DELETE RESTRICT,
to_location_id BIGINT REFERENCES location(id) ON DELETE RESTRICT,
qty NUMERIC(14, 4) NOT NULL,
move_type TEXT NOT NULL, -- in | out | transfer | adjustment
move_date TIMESTAMP NOT NULL DEFAULT NOW(),
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_stock_move_qty_positive CHECK (qty > 0),
CONSTRAINT chk_stock_move_type CHECK (move_type IN ('in', 'out', 'transfer', 'adjustment'))
);
CREATE INDEX idx_stock_move_product ON stock_move(product_id);
CREATE INDEX idx_stock_move_lot ON stock_move(lot_id);
CREATE INDEX idx_stock_move_from_location ON stock_move(from_location_id);
CREATE INDEX idx_stock_move_to_location ON stock_move(to_location_id);
-- Explizite Rueckverfolgung: welche Charge wurde fuer welche Bestellposition verwendet
CREATE TABLE sales_order_line_lot_allocation (
id BIGSERIAL PRIMARY KEY,
sales_order_line_id BIGINT NOT NULL REFERENCES sales_order_line(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES product(id) ON DELETE RESTRICT,
lot_id BIGINT NOT NULL REFERENCES stock_lot(id) ON DELETE RESTRICT,
qty NUMERIC(14, 4) NOT NULL,
allocation_status TEXT NOT NULL DEFAULT 'allocated', -- reserved | allocated | released | cancelled
released_at TIMESTAMP,
stock_move_id BIGINT REFERENCES stock_move(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_line_lot_qty_positive CHECK (qty > 0),
CONSTRAINT chk_line_lot_alloc_status CHECK (allocation_status IN ('reserved', 'allocated', 'released', 'cancelled'))
);
CREATE INDEX idx_line_lot_alloc_line ON sales_order_line_lot_allocation(sales_order_line_id);
CREATE INDEX idx_line_lot_alloc_product ON sales_order_line_lot_allocation(product_id);
CREATE INDEX idx_line_lot_alloc_lot ON sales_order_line_lot_allocation(lot_id);
CREATE INDEX idx_line_lot_alloc_stock_move ON sales_order_line_lot_allocation(stock_move_id);
CREATE INDEX idx_line_lot_alloc_status ON sales_order_line_lot_allocation(allocation_status);
-- Berechnete Chargensalden (Lagereingang/-ausgang/-netto)
CREATE VIEW v_stock_lot_balance AS
SELECT
sl.id AS stock_lot_id,
sl.product_id,
COALESCE(SUM(CASE WHEN sm.move_type = 'in' THEN sm.qty ELSE 0 END), 0) AS qty_in,
COALESCE(SUM(CASE WHEN sm.move_type = 'out' THEN sm.qty ELSE 0 END), 0) AS qty_out,
COALESCE(SUM(
CASE
WHEN sm.move_type = 'in' THEN sm.qty
WHEN sm.move_type = 'out' THEN -sm.qty
ELSE 0
END
), 0) AS qty_net
FROM stock_lot sl
LEFT JOIN stock_move sm ON sm.lot_id = sl.id
GROUP BY sl.id, sl.product_id;
-- Operative Guardrail-Trigger:
-- 1) keine negativen Chargensalden
-- 2) wenn aktuelle Charge <= 0, auf offene Charge umschalten und neue offene Charge vorbereiten
-- Hinweis: Die konkrete Trigger-Implementierung erfolgt in der Migrationsphase.
-- Standard Audit Trail (vorbereitet fuer spaetere Module)
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
entity_name TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL, -- insert | update | delete | import | allocate
changed_by TEXT,
changed_at TIMESTAMP NOT NULL DEFAULT NOW(),
before_data JSONB,
after_data JSONB,
context JSONB
);
CREATE INDEX idx_audit_log_entity ON audit_log(entity_name, entity_id);
CREATE INDEX idx_audit_log_changed_at ON audit_log(changed_at);
-- Outbound-Webhook Queue (ERP -> n8n)
CREATE TABLE outbound_webhook_event (
id BIGSERIAL PRIMARY KEY,
event_type TEXT NOT NULL, -- order.imported | order.cancelled.partial | order.cancelled.full | lot.auto_switched
event_key TEXT NOT NULL, -- idempotency key for consumers
aggregate_type TEXT NOT NULL, -- sales_order | stock_lot | sales_order_line
aggregate_id TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | processing | sent | failed | dead_letter
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_attempt_at TIMESTAMP,
last_error TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
sent_at TIMESTAMP,
CONSTRAINT chk_outbound_webhook_status CHECK (status IN ('pending', 'processing', 'sent', 'failed', 'dead_letter'))
);
CREATE UNIQUE INDEX uq_outbound_webhook_event_key ON outbound_webhook_event(event_key);
CREATE INDEX idx_outbound_webhook_status_next ON outbound_webhook_event(status, next_attempt_at);
+264
View File
@@ -0,0 +1,264 @@
# Phase 1 Component Specification
## Dokumentstatus
1. Typ: `operational`
2. Zweck: verbindliche Umsetzungs-Spezifikation fuer Schritt 1 Komponenten
3. Normative Referenz: `/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/CONCEPT.md`
4. Abgeleitete Referenz: `/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/SCHEMA_PHASE1.sql`
5. Prozessreferenz: `/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/docs/PROCESS_PHASE1.md`
6. Implementierungsreferenz: `/Users/mathias/Documents/Dokumente Chouchou/Codebases/erp_naurua/db/migrations/`
## Scope
Diese Spezifikation deckt alle Schritt-1-Komponenten ab:
1. Bestellerfassung
2. Kontakt- und Adressdaten
3. Artikel-Mapping und Stuecklisten
4. Lagerverwaltung mit Chargen, MHD, Zu-/Abgaengen
5. Chargenrueckverfolgung
6. Teil-/Vollstorno
7. Inbound/Outbound Webhooks
8. Audit und technische Guardrails
## Globale Invarianten
1. `sales_order.external_ref` ist eindeutig und dient als Upsert-Schluessel (Shop: `BestellungNr`, Direktverkauf: `DIR-...`).
2. Datensaetze werden nicht geloescht; Statusaenderungen und Audit-Trail werden verwendet.
3. Verkaufsbewegungen sind immer chargengebunden.
4. Negativbestand ist unzulaessig.
5. Pro Produkt existieren exakt eine `current`-Charge und eine `open`-Charge.
6. `open` darf nie fehlen.
7. Zahlungsstatus aus Shop wird intern auf `paid` normalisiert.
8. Direktverkaeufe haben `order_source = direct` und `external_ref` mit `DIR-`-Praefix.
## Komponente 1: Bestellerfassung
### Ziel
Persistente Erfassung von Bestellungen aus Shop und Direktverkauf inkl. Positionen, Summen und Import-/Erfassungsdaten.
### Eingangsquelle
1. n8n Inbound Webhook mit Shop-JSON (`order_source = wix`)
2. Manuelle ERP-Erfassung fuer Direktverkauf (`order_source = direct`)
### Datenregeln
1. Upsert auf `sales_order.external_ref` fuer Shop-Bestellungen.
2. Direktbestellungen erhalten ERP-interne Nummern mit `DIR-`-Praefix.
3. `order_status` initial `imported`.
4. Summenfelder werden direkt gespeichert (`amount_net`, `amount_shipping`, `amount_tax`, `amount_discount`, `total_amount`).
5. `webhook_payload` wird fuer Shop-Rohdaten gespeichert (Nachvollziehbarkeit).
6. Fuer Direktverkauf darf `party_id` leer sein (Laufkundschaft).
### Fehlerverhalten
1. Bei fehlender `BestellungNr`: Import ablehnen, Audit-Log `import_rejected`.
2. Bei unbekanntem Artikelfehler: Bestellung speichern, Position mit Rohdaten speichern, Mapping-Luecke markieren.
### Akzeptanzkriterien
1. Wiederholter Import derselben `BestellungNr` erzeugt kein Duplikat.
2. Direktbestellungen sind ueber den `DIR-`-Praefix eindeutig erkennbar.
3. Positionsdaten bleiben stabil und auditierbar.
## Komponente 2: Kontakt- und Adressdaten
### Ziel
Speicherung von Kundenkontakt, Lieferadresse und optionaler Rechnungsadresse.
### Datenregeln
1. Rechnungsadresse darf vollstaendig `NULL` sein.
2. Lieferadresse wird als eigener Datensatz gespeichert.
3. Land wird immer doppelt gespeichert: `country_name` + `country_iso2`.
4. Keine serverseitige Adressvalidierung in Phase 1.
5. Ausnahmefaelle bei Name/Firma werden unveraendert uebernommen.
6. Bei Direktverkauf ist keine Kontakt-/Adressanlage erforderlich.
### Akzeptanzkriterien
1. Bestellung kann ohne Rechnungsadresse gespeichert werden.
2. Liefer- und Rechnungsadresse sind getrennt auswertbar.
## Komponente 3: Artikel-Mapping und Stuecklisten
### Ziel
Stabile Entkopplung von Shop-Artikeln und lagergefuehrten Produkten.
### Datenmodell
1. `sellable_item`: verkaufbarer Artikel (auch Bundles).
2. `external_item_alias`: Mapping Shop-Artikelnummer/Titel -> `sellable_item`.
3. `sellable_item_component`: Stueckliste `sellable_item` -> `product` mit Menge.
### Aufloesungsreihenfolge
1. Match ueber `external_article_number`.
2. Fallback ueber normalisierten Titel.
3. Wenn beides fehlschlaegt: Position als unmapped speichern und auditieren.
### Akzeptanzkriterien
1. Bundle-Artikel koennen auf mehrere Lagerprodukte aufgeloest werden.
2. Ohne Mapping bleibt Bestellung dennoch erfassbar.
## Komponente 4: Lagerverwaltung und Chargen
### Ziel
Konsistente Bestandsfuehrung je Charge inklusive Auto-Wechsel.
### Statusmodell Charge
1. `open`: vorbereitete Charge
2. `current`: aktive Entnahmecharge
3. `closed`: abgeschlossene Charge
### Lebenszyklus
1. `open -> current -> closed`
2. Wenn `current` auf `qty_net <= 0` faellt:
1. alte `current` wird `closed`
2. vorhandene `open` wird `current`
3. neue `open` wird auto-angelegt
### Feldregeln
1. `lot_number` bei `open` darf leer sein.
2. `lot_number` fuer `current/closed` ist Pflicht.
3. Chargennummer ist pro Produkt eindeutig.
4. Chargensalden werden aus `stock_move` berechnet (`v_stock_lot_balance`).
5. Abverkaufprognose pro aktueller Charge wird im System gepflegt (`sellout_date`, `warning_state`).
### Korrekturregeln
1. Korrekturen erfolgen ueber `adjustment`-Bewegungen.
2. Keine direkten Netto-Setzungen als Betriebsprozess.
### Akzeptanzkriterien
1. Nach jeder Entnahme bleibt die Invariante `1x current + 1x open` erhalten.
2. Negativbestand wird technisch verhindert.
3. Warnstatus fuer UI ist ohne E-Mail nutzbar (`none`, `due_60d`, `due_now`).
## Komponente 5: Chargenrueckverfolgung
### Ziel
Lueckenlose Rueckverfolgung Bestellung -> Position -> Produkt -> Charge -> Lagerbewegung.
### Datenregeln
1. Jede Entnahme schreibt `stock_move` (`move_type = out`) mit `lot_id`.
2. Pro Entnahme wird `sales_order_line_lot_allocation` geschrieben.
3. `allocation_status` in Phase 1 standardmaessig `allocated`.
### Akzeptanzkriterien
1. Fuer jede ausgelieferte Position ist die verwendete Charge abfragbar.
2. Rueckverfolgung funktioniert auch nach Teilstorno.
## Komponente 6: Storno (Teil/Voll)
### Ziel
Revisionssicheres Storno ohne physisches Loeschen.
### Datenregeln
1. Teil- und Vollstorno sind erlaubt.
2. `sales_order_line.qty_cancelled` wird fortgeschrieben.
3. `line_status`: `allocated`, `partially_cancelled`, `cancelled`.
4. Bei Storno von bereits `allocated` Mengen erfolgt Gegenbuchung als `adjustment in` auf dieselbe Charge.
5. Bestellung bleibt erhalten; Statuswechsel statt Delete.
### Akzeptanzkriterien
1. Historische Ursprungs- und Korrekturbewegungen bleiben sichtbar.
2. Vollstorno setzt `sales_order.order_status = cancelled`.
## Komponente 7: Webhooks
### Inbound (n8n -> ERP)
1. Transport: JSON via HTTP.
2. Idempotenzschluessel: `BestellungNr`.
3. Verarbeitung: Upsert Bestellung, Kontakt, Positionen, Lagerabgang, Audit.
4. Quelle wird als `order_source = wix` gespeichert.
### Direkt (ERP intern)
1. Transport: manuelle Erfassung in ERP-Maske.
2. Nummernlogik: ERP erzeugt `external_ref` mit `DIR-`-Praefix.
3. Verarbeitung: Bestellung (optional ohne Kontakt), Positionen, Lagerabgang, Audit.
### Outbound (ERP -> n8n)
1. Transport: Queue-basierter POST ueber `outbound_webhook_event`.
2. Events Phase 1:
1. `order.imported`
2. `order.cancelled.partial`
3. `order.cancelled.full`
4. `lot.auto_switched`
3. Signatur: `X-ERP-Signature` (HMAC-SHA256).
4. Zustellung: Retry mit Backoff; final `dead_letter`.
### Akzeptanzkriterien
1. Event-Zustellung ist idempotent (`event_key` eindeutig).
2. Fehlgeschlagene Events sind operativ auffindbar.
3. Outbound-Payload enthaelt die Quelle (`order_source`), damit `wix` und `direct` getrennt auswertbar sind.
## Komponente 8: Audit und Betriebssicherheit
### Audit
1. Jede fachliche Aenderung schreibt `audit_log`.
2. Pflichtfelder: `entity_name`, `entity_id`, `action`, `changed_at`.
3. Vorher/Nachher-Daten werden als JSON gespeichert, wenn verfuegbar.
### Guardrails
1. Check-Constraints fuer Statuswerte.
2. Check-Constraints fuer Mengenbereiche.
3. Unique-Constraints fuer kritische Eindeutigkeiten.
4. Outbox-Statusmaschine fuer robuste externe Zustellung.
## Komponenten-Matrix
1. `Bestellerfassung`
Zustandsquelle: `sales_order`, `sales_order_line`
Hauptereignisse: `order.imported`, `order.cancelled.*`
2. `Kontakt/Adressen`
Zustandsquelle: `party`, `contact`, `address`
Hauptereignisse: `order.imported`
3. `Artikel-Mapping`
Zustandsquelle: `sellable_item`, `external_item_alias`, `sellable_item_component`
Hauptereignisse: `order.imported`
4. `Lagerverwaltung`
Zustandsquelle: `stock_lot`, `stock_move`, `v_stock_lot_balance`
Hauptereignisse: `order.imported`, `lot.auto_switched`, `order.cancelled.*`
5. `Rueckverfolgung`
Zustandsquelle: `sales_order_line_lot_allocation`
Hauptereignisse: alle Abgaenge und Stornos
6. `Outbound-Integration`
Zustandsquelle: `outbound_webhook_event`
Hauptereignisse: alle publizierten Domain-Events
## Nicht in Phase 1
1. Rechnungswesen, Buchungssaetze, OCR-Verarbeitung.
2. Automatisierte Preis-/Steuerneuberechnung.
3. Vollautomatische Chargennummerngenerierung nach Produktklasse.
## Change Governance
1. Konzeptaenderungen zuerst in `CONCEPT.md`.
2. Prozess- und Betriebsablauf in `PROCESS_PHASE1.md`.
3. Technische Ableitung in `SCHEMA_PHASE1.sql`.
4. Diese Spezifikation synchronisiert alle Komponenten auf Umsetzungsniveau.
@@ -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: <msprvs1=20545RoK9oTDl=bounces-17751-1668086@bounces.wixemails.com>",
"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: <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>",
"from": "From: \"Naurua GmbH\" <naurua.ch@stores-emails.com>",
"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": "<!doctype html><html lang=\"de\" xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\"><head><title></title><!--[if !mso]><!--><meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"><!--<![endif]--><meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><style type=\"text/css\">#a a {padding:0;}body {margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}table,td {border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}img {border:0;height:auto;line-height:100%;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}p {display:block;margin:13px 0;}</style><!--[if mso]>\n <noscript>\n <xml>\n <o:OfficeDocumentSettings>\n <o:AllowPNG/>\n <o:PixelsPerInch>96</o:PixelsPerInch>\n </o:OfficeDocumentSettings>\n </xml>\n </noscript>\n <![endif]--><!--[if lte mso 11]>\n <style type=\"text/css\">\n .mj-outlook-group-fix { width:100% !important; }\n </style>\n <![endif]--><style type=\"text/css\"></style><style type=\"text/css\"></style><!--[if !mso]><!--><link href=\"https://static.parastorage.com/unpkg/@wix/wix-emails-fonts@1.1.2/f/Ubuntu.css\" rel=\"stylesheet\" type=\"text/css\"><style type=\"text/css\">@import url(https://static.parastorage.com/unpkg/@wix/wix-emails-fonts@1.1.2/f/Ubuntu.css);</style><!--<![endif]--><style type=\"text/css\">@media only screen and (min-width:480px){ .a {width:100%!important;max-width:100%;}.b {width:50%!important;max-width:50%;}.c {width:48.161764705882355%!important;max-width:48.161764705882355%;}.d {width:25%!important;max-width:25%;}.e {width:26.83823529411765%!important;max-width:26.83823529411765%;}.f {width:58.897058823529406%!important;max-width:58.897058823529406%;}.g {width:41.102941176470594%!important;max-width:41.102941176470594%;} }</style><style media=\"screen and (min-width:480px)\">.h .a {width:100%!important;max-width:100%;}.h .b {width:50%!important;max-width:50%;}.h .c {width:48.161764705882355%!important;max-width:48.161764705882355%;}.h .d {width:25%!important;max-width:25%;}.h .e {width:26.83823529411765%!important;max-width:26.83823529411765%;}.h .f {width:58.897058823529406%!important;max-width:58.897058823529406%;}.h .g {width:41.102941176470594%!important;max-width:41.102941176470594%;}</style><style type=\"text/css\">[owa] .a {width:100%!important;max-width:100%;}[owa] .b {width:50%!important;max-width:50%;}[owa] .c {width:48.161764705882355%!important;max-width:48.161764705882355%;}[owa] .d {width:25%!important;max-width:25%;}[owa] .e {width:26.83823529411765%!important;max-width:26.83823529411765%;}[owa] .f {width:58.897058823529406%!important;max-width:58.897058823529406%;}[owa] .g {width:41.102941176470594%!important;max-width:41.102941176470594%;}</style><style type=\"text/css\">.i {text-decoration:none;} @media all and (max-width:480px){ .j {display:none!important;} } @media all and (min-width:481px){ .k {display:none!important;} }@media all and (max-width:480px){ .l td {width:100%!important;} }@media all and (max-width:480px){ .m{font-size:16px!important;}.n{font-size:16px!important;}.o{font-size:14px!important;}.p{font-size:28px!important;}.q{font-size:28px!important;}.r{font-size:20px!important;}.s{font-size:16px!important;}.t{font-size:16px!important;}.u{font-size:16px!important;}.v{font-size:14px!important;}.w{font-size:24px!important;}.x{font-size:16px!important;}.m{line-height:24px!important;}.n{line-height:24px!important;}.p{line-height:36.4px!important;}.q{line-height:36.4px!important;}.r{line-height:28px!important;}.s{line-height:24px!important;}.v{line-height:21px!important;}.w{line-height:31.200000000000003px!important;} .y>table {width:100%!important;} .z {display:none!important;} .ab .bb>table>tbody>tr>td {padding-bottom:24px!important;} .cb .bb>table>tbody>tr>td {padding-top:24px!important;} .ab.bb>table>tbody>tr>td {padding-bottom:24px!important;} .cb.bb>table>tbody>tr>td {padding-top:24px!important;} .db .bb>table>tbody>tr>td {padding-top:16px!important;} .eb .bb>table>tbody>tr>td {padding-right:16px!important;} .fb .bb>table>tbody>tr>td {padding-bottom:16px!important;} .gb .bb>table>tbody>tr>td {padding-left:16px!important;} .hb .bb>table>tbody>tr>td {padding-left:0px!important;} .ib .bb>table>tbody>tr>td {padding-right:0px!important;} .jb>table>tbody>tr>td {padding-right:16px!important;} .kb>table>tbody>tr>td {padding-left:16px!important;} }@media all and (min-width:481px){ .lb .mb {margin-left:16px!important;} .lb .mb {margin-right:16px!important;} }@media only screen and (max-width:480px){ .nb>table>tbody>tr>td {padding-bottom:0px!important;} .ob>table>tbody>tr>td {padding-top:24px!important;} .pb {width:36px!important;height:36px!important;} .qb {width:12px!important;} .rb {margin:12px!important;} .sb{font-size:28px!important;} .tb{font-size:16px!important;}Line truncated
"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": "<p>Neue Bestellung</p><p>Auf deiner Website wurde eine Bestellung aufgegeben.</p><p>Bestellung #10466 &nbsp;</p><p>Bestellinformationen</p><p>Bestelldetails</p><p>Klicke auf den Link unten, um die Nachricht in einem Browser zu &ouml;ffnen.<br/><a href=\"https://naurua.ch/so/tr/3078e2f5-3b52-4dd2-8e38-a47eb2dda7a1\">https://naurua.ch/so/tr/3078e2f5-3b52-4dd2-8e38-a47eb2dda7a1</a></p><p>Diese E-Mail wird an Abonnenten dieser Website gesendet.<br/><a href=\"https://naurua.ch/so/tr/3078e2f5-3b52-4dd2-8e38-a47eb2dda7a1/c?w=z1-nY5tvv5xMH793kk24PFyY_tHVXU7Gwpm6yyIShio.eyJ1IjoiaHR0cHM6Ly93d3cubmF1cnVhLmNoLyIsImMiOiI1NTBmZmMxOC0zNGFmLTM5NDItYWI2ZS1lNDQ0NzlmMTAyOWUiLCJtIjoibWFpbCIsInJpIjoiNTE2Y2ZhZmUtZjAzNC00ZmFhLTkxODAtOWJlNTBjOGFkNDA4IiwicnQiOiJVc2VyIn0\">https://naurua.ch/so/tr/3078e2f5-3b52-4dd2-8e38-a47eb2dda7a1/c?w=z1-nY5tvv5xMH793kk24PFyY_tHVXU7Gwpm6yyIShio.eyJ1IjoiaHR0cHM6Ly93d3cubmF1cnVhLmNoLyIsImMiOiI1NTBmZmMxOC0zNGFmLTM5NDItYWI2ZS1lNDQ0NzlmMTAyOWUiLCJtIjoibWFpbCIsInJpIjoiNTE2Y2ZhZmUtZjAzNC00ZmFhLTkxODAtOWJlNTBjOGFkNDA4IiwicnQiOiJVc2VyIn0</a></p><p>Zum Abbestellen dieser E-Mail bitte hier klicken:<br/><a href=\"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\">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</a></p>",
"subject": "Neue Bestellung (#10466)",
"date": "2026-03-26T09:34:59.000Z",
"to": {
"value": [
{
"address": "n8n_naurua_bestelleingang@imhochrain.ch",
"name": ""
}
],
"html": "<span class=\"mp_address_group\"><a href=\"mailto:n8n_naurua_bestelleingang@imhochrain.ch\" class=\"mp_address_email\">n8n_naurua_bestelleingang@imhochrain.ch</a></span>",
"text": "n8n_naurua_bestelleingang@imhochrain.ch"
},
"from": {
"value": [
{
"address": "naurua.ch@stores-emails.com",
"name": "Naurua GmbH"
}
],
"html": "<span class=\"mp_address_group\"><span class=\"mp_address_name\">Naurua GmbH</span> &lt;<a href=\"mailto:naurua.ch@stores-emails.com\" class=\"mp_address_email\">naurua.ch@stores-emails.com</a>&gt;</span>",
"text": "\"Naurua GmbH\" <naurua.ch@stores-emails.com>"
},
"messageId": "<27.8B.10073.3CDF4C96@i-043224e4cc422583f.mta1vrest.sd.prd.sparkpost>",
"replyTo": {
"value": [
{
"address": "naurua.ch@gmail.com",
"name": ""
}
],
"html": "<span class=\"mp_address_group\"><a href=\"mailto:naurua.ch@gmail.com\" class=\"mp_address_email\">naurua.ch@gmail.com</a></span>",
"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 <mathias.kurt.glaeser@gmail.com>",
"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"
}
]
}
}
+11
View File
@@ -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"
}
]
}
+442
View File
@@ -0,0 +1,442 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
function json_response(int $status, array $payload): void
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function parse_env_file(string $path): array
{
if (!is_file($path)) {
return [];
}
$result = [];
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
return [];
}
foreach ($lines as $line) {
$trimmed = trim($line);
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
continue;
}
$pos = strpos($trimmed, '=');
if ($pos === false) {
continue;
}
$key = trim(substr($trimmed, 0, $pos));
$value = trim(substr($trimmed, $pos + 1));
if ($key === '') {
continue;
}
if (strlen($value) >= 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(),
]);
}
+55
View File
@@ -0,0 +1,55 @@
<?php
$method = $_SERVER['REQUEST_METHOD'] ?? '';
if ($method !== 'POST') {
http_response_code(405);
echo 'Method Not Allowed';
exit;
}
$payload = file_get_contents('php://input');
if ($payload === false || $payload === '') {
http_response_code(400);
echo 'Empty payload';
exit;
}
$event = $_SERVER['HTTP_X_GITEA_EVENT'] ?? '';
$secret = getenv('GITEA_WEBHOOK_SECRET');
$signature = $_SERVER['HTTP_X_GITEA_SIGNATURE'] ?? '';
if ($secret !== false && $secret !== '') {
if ($signature === '') {
http_response_code(401);
echo 'Missing signature';
exit;
}
$hash = hash_hmac('sha256', $payload, $secret, false);
if (!hash_equals($hash, $signature)) {
http_response_code(401);
echo 'Invalid signature';
exit;
}
}
$decoded = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo 'Invalid JSON';
exit;
}
if ($event !== 'push') {
http_response_code(202);
echo 'Ignored';
exit;
}
$ref = $decoded['ref'] ?? '';
if ($ref !== 'refs/heads/main') {
http_response_code(202);
echo 'Ignored';
exit;
}
exec('/bin/sudo -u admin_hz2 /bin/bash /volume2/webssd/erpnaurua/dev/deploy-staging.sh > /dev/null 2>&1 &');
echo 'Deploy triggered';
+4
View File
@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/order-import.php';