Files
erp_naurua/docs/SCHEMA_PHASE1.sql

348 lines
14 KiB
SQL

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