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