Add ERP order import endpoint and n8n order-ingest flow export
This commit is contained in:
55
db/README.md
Normal file
55
db/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Database Migrations
|
||||
|
||||
## Reihenfolge
|
||||
|
||||
1. `db/migrations/0001_phase1_core.sql`
|
||||
2. `db/migrations/0002_phase1_seed_methods.sql`
|
||||
3. `db/migrations/0003_phase1_inventory_forecast.sql`
|
||||
4. `db/migrations/0004_phase1_direct_sales.sql`
|
||||
|
||||
## Beispielausfuehrung
|
||||
|
||||
```bash
|
||||
psql "$DATABASE_URL" -f db/migrations/0001_phase1_core.sql
|
||||
psql "$DATABASE_URL" -f db/migrations/0002_phase1_seed_methods.sql
|
||||
psql "$DATABASE_URL" -f db/migrations/0003_phase1_inventory_forecast.sql
|
||||
psql "$DATABASE_URL" -f db/migrations/0004_phase1_direct_sales.sql
|
||||
```
|
||||
|
||||
## Enthaltene Kernlogik in `0001`
|
||||
|
||||
1. Phase-1 Tabellen, Indizes und Constraints
|
||||
2. Bestands-View `v_stock_lot_balance`
|
||||
3. Trigger fuer:
|
||||
1. Negativbestand-Schutz
|
||||
2. Auto-Anlage von `current` + `open` bei neuem Produkt
|
||||
3. Auto-Status fuer Bestellpositionen (`allocated`/`partially_cancelled`/`cancelled`)
|
||||
4. Auto-Chargenwechsel bei leerer `current`-Charge
|
||||
5. Outbox-Events fuer Outbound-Webhook
|
||||
|
||||
## Hinweis
|
||||
|
||||
ENV-Werte fuer Outbound-Zustellung (n8n URL/Secret) werden spaeter von der Applikation/Worker genutzt. Die DB-Migration legt dafuer die Outbox-Struktur bereits an.
|
||||
|
||||
## Abverkaufdatum und Warnung
|
||||
|
||||
Die Migration `0003` fuehrt die interne Prognosefunktion ein:
|
||||
|
||||
```sql
|
||||
SELECT fn_refresh_sellout_forecast(60, 60);
|
||||
```
|
||||
|
||||
UI-Read-Modell:
|
||||
|
||||
```sql
|
||||
SELECT * FROM v_stock_lot_ui_alerts ORDER BY product_name, status DESC, lot_number;
|
||||
```
|
||||
|
||||
## Direktverkauf (Migration `0004`)
|
||||
|
||||
`0004` erweitert Bestellungen um `order_source` (`wix` | `direct`) und erlaubt Direktverkaeufe ohne Kontaktzuordnung (`party_id = NULL`). Zusaetzlich:
|
||||
|
||||
1. Guardrail: Direktbestellungen muessen `external_ref` mit `DIR-`-Praefix haben.
|
||||
2. Generator-Funktion: `fn_next_direct_sales_order_ref(...)`.
|
||||
3. Trigger: automatische Vergabe einer `DIR-...` Nummer, falls leer.
|
||||
4. Zahlungsarten fuer Direktverkauf: `cash`, `paypal` (zus. zu bestehenden Methoden).
|
||||
699
db/migrations/0001_phase1_core.sql
Normal file
699
db/migrations/0001_phase1_core.sql
Normal 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;
|
||||
16
db/migrations/0002_phase1_seed_methods.sql
Normal file
16
db/migrations/0002_phase1_seed_methods.sql
Normal 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;
|
||||
307
db/migrations/0003_phase1_inventory_forecast.sql
Normal file
307
db/migrations/0003_phase1_inventory_forecast.sql
Normal 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;
|
||||
185
db/migrations/0004_phase1_direct_sales.sql
Normal file
185
db/migrations/0004_phase1_direct_sales.sql
Normal 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;
|
||||
Reference in New Issue
Block a user