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

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;