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

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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Environment files
.env
.env.*
!.env.example
# macOS
.DS_Store

55
db/README.md Normal file
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).

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;

View File

@@ -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;

View File

@@ -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;

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
deploy-staging.sh Executable file
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
deploy.php Normal file
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
docs/CONCEPT.md Normal file
View File

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

221
docs/PROCESS_PHASE1.md Normal file
View File

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

25
docs/README.md Normal file
View File

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

347
docs/SCHEMA_PHASE1.sql Normal file
View File

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

View File

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

File diff suppressed because one or more lines are too long

11
n8n/exports/index.json Normal file
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
order-import.php Normal file
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
public/deploy.php Normal file
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
public/order-import.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/order-import.php';