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