-- Tour-Domäne: Stammdaten (customers, customer_contacts, articles, warehouses) -- plus die transaktionalen Tabellen tours, deliveries, delivery_items. -- -- Datenfluss: das ERP pusht eine Tour via POST /sync/tour. Dieser Sync -- legt fehlende Kunden/Artikel/Lager an oder aktualisiert sie und schreibt -- danach die transaktionalen Zeilen. -- ---------- Stamm: Lager ------------------------------------------------- CREATE TABLE warehouses ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, is_standard BOOLEAN NOT NULL DEFAULT FALSE ); -- Genau ein Lager darf als "Standardlager" markiert sein. Reduziert die -- Fertig-Logik auf der App auf einen Bool-Check. CREATE UNIQUE INDEX warehouses_one_standard ON warehouses ((is_standard)) WHERE is_standard; -- ---------- Stamm: Artikel ------------------------------------------------ CREATE TABLE articles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), article_number TEXT NOT NULL UNIQUE, name TEXT NOT NULL, scannable BOOLEAN NOT NULL DEFAULT TRUE, default_warehouse_id UUID REFERENCES warehouses(id) ); -- ---------- Stamm: Kunde -------------------------------------------------- CREATE TABLE customers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), erp_customer_id BIGINT NOT NULL UNIQUE, name TEXT NOT NULL, -- Aktuelle Anschrift (für Snapshot in deliveries gesondert geführt) street TEXT NOT NULL, house_number TEXT NOT NULL, postal_code TEXT NOT NULL, city TEXT NOT NULL, country TEXT NOT NULL ); CREATE TABLE customer_contacts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, name TEXT NOT NULL, phone TEXT, email TEXT ); CREATE INDEX customer_contacts_customer ON customer_contacts(customer_id); -- ---------- Transaktional: Tour ------------------------------------------ CREATE TABLE tours ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- account_id = Personalnummer des Subunternehmers account_id BIGINT NOT NULL REFERENCES accounts(personalnummer), tour_date DATE NOT NULL, synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- Eine Tour pro Account und Tag. Der Sync läuft als Upsert auf diese -- Constraint. UNIQUE (account_id, tour_date) ); CREATE INDEX tours_account_date ON tours(account_id, tour_date); -- ---------- Transaktional: Delivery -------------------------------------- CREATE TABLE deliveries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tour_id UUID NOT NULL REFERENCES tours(id) ON DELETE CASCADE, -- Business-stabiles Beleg-Paar aus dem ERP, überlebt Archivübergang erp_belegart_id BIGINT NOT NULL, erp_belegnummer TEXT NOT NULL, customer_id UUID NOT NULL REFERENCES customers(id), -- Snapshot der Adresse zum Zeitpunkt des Tour-Syncs snap_street TEXT NOT NULL, snap_house_number TEXT NOT NULL, snap_postal_code TEXT NOT NULL, snap_city TEXT NOT NULL, snap_country TEXT NOT NULL, assigned_car_id UUID, -- noch keine cars-Tabelle, FK später desired_time TEXT, special_agreements TEXT, state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'held', 'canceled', 'completed')), cancellation_reason TEXT, -- Sortier-Reihenfolge innerhalb der Tour. Beim Sync mit dichter -- Reihenfolge initialisiert, später durch PUT /tours/{id}/delivery-order -- überschrieben. sort_order INT NOT NULL DEFAULT 0, UNIQUE (erp_belegart_id, erp_belegnummer) ); CREATE INDEX deliveries_tour ON deliveries(tour_id); CREATE INDEX deliveries_customer ON deliveries(customer_id); -- N:M-Tabelle Delivery → ausgewählte Ansprechpartner CREATE TABLE delivery_contact_persons ( delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE, customer_contact_id UUID NOT NULL REFERENCES customer_contacts(id) ON DELETE CASCADE, PRIMARY KEY (delivery_id, customer_contact_id) ); -- ---------- Transaktional: DeliveryItem ---------------------------------- CREATE TABLE delivery_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE, article_id UUID NOT NULL REFERENCES articles(id), required_quantity INT NOT NULL CHECK (required_quantity > 0), warehouse_id UUID NOT NULL REFERENCES warehouses(id), -- ERP-Position innerhalb des Belegs belegzeilen_nr INT NOT NULL, -- Stücklistenkomponente: ArtNr der Komponente, sonst NULL komponenten_artikel_nr TEXT, -- Embedded ScanState (siehe Domain::ScanState) scanned_quantity INT NOT NULL DEFAULT 0 CHECK (scanned_quantity >= 0), scan_status TEXT NOT NULL DEFAULT 'in_progress' CHECK (scan_status IN ('in_progress','done','held','removed')), held_reason TEXT, scan_last_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- NULLS NOT DISTINCT: zwei Items mit (delivery, belegzeilenNr) und -- NULL-Komponente kollidieren — sonst würde der UPSERT eine zweite -- Zeile anlegen statt zu aktualisieren. (Postgres 15+) UNIQUE NULLS NOT DISTINCT (delivery_id, belegzeilen_nr, komponenten_artikel_nr) ); CREATE INDEX delivery_items_delivery ON delivery_items(delivery_id); -- ---------- Seed: Smoke-Test-Daten ---------------------------------------- INSERT INTO warehouses (id, code, name, is_standard) VALUES ('11111111-1111-1111-1111-111111111111', '0', 'Standardlager', TRUE), ('11111111-1111-1111-1111-111111111112', 'A', 'Außenlager A', FALSE); INSERT INTO articles (id, article_number, name, scannable, default_warehouse_id) VALUES ('22222222-2222-2222-2222-222222222221', 'BRETT-200', 'Holzbrett 200cm', TRUE, '11111111-1111-1111-1111-111111111111'), ('22222222-2222-2222-2222-222222222222', 'PALETTE-EUR', 'Europalette', TRUE, '11111111-1111-1111-1111-111111111111'), ('22222222-2222-2222-2222-222222222223', 'FRACHT-PAUSCH', 'Fracht', FALSE, NULL); INSERT INTO customers (id, erp_customer_id, name, street, house_number, postal_code, city, country) VALUES ('33333333-3333-3333-3333-333333333331', 4711, 'Bauernhof Huber', 'Dorfstraße', '12', '83410', 'Laufen', 'DE'), ('33333333-3333-3333-3333-333333333332', 4712, 'Sägewerk Müller', 'Industriering', '5', '83395', 'Freilassing', 'DE'); INSERT INTO customer_contacts (id, customer_id, name, phone, email) VALUES ('44444444-4444-4444-4444-444444444441', '33333333-3333-3333-3333-333333333331', 'Sepp Huber', '+49 8682 12345', NULL), ('44444444-4444-4444-4444-444444444442', '33333333-3333-3333-3333-333333333332', 'Anna Müller', NULL, 'anna@muellersaege.de'); -- Eine Beispiel-Tour für Personalnummer 1001 am heutigen Tag INSERT INTO tours (id, account_id, tour_date) VALUES ('55555555-5555-5555-5555-555555555555', 1001, CURRENT_DATE); INSERT INTO deliveries ( id, tour_id, erp_belegart_id, erp_belegnummer, customer_id, snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, sort_order ) VALUES ('66666666-6666-6666-6666-666666666661', '55555555-5555-5555-5555-555555555555', 1, 'AB-2026-0001', '33333333-3333-3333-3333-333333333331', 'Dorfstraße', '12', '83410', 'Laufen', 'DE', 1), ('66666666-6666-6666-6666-666666666662', '55555555-5555-5555-5555-555555555555', 1, 'AB-2026-0002', '33333333-3333-3333-3333-333333333332', 'Industriering', '5', '83395', 'Freilassing', 'DE', 2); INSERT INTO delivery_contact_persons (delivery_id, customer_contact_id) VALUES ('66666666-6666-6666-6666-666666666661', '44444444-4444-4444-4444-444444444441'), ('66666666-6666-6666-6666-666666666662', '44444444-4444-4444-4444-444444444442'); INSERT INTO delivery_items ( delivery_id, article_id, required_quantity, warehouse_id, belegzeilen_nr, komponenten_artikel_nr ) VALUES ('66666666-6666-6666-6666-666666666661', '22222222-2222-2222-2222-222222222221', 20, '11111111-1111-1111-1111-111111111111', 1, NULL), ('66666666-6666-6666-6666-666666666661', '22222222-2222-2222-2222-222222222222', 2, '11111111-1111-1111-1111-111111111111', 2, NULL), ('66666666-6666-6666-6666-666666666662', '22222222-2222-2222-2222-222222222221', 10, '11111111-1111-1111-1111-111111111112', 1, NULL), ('66666666-6666-6666-6666-666666666662', '22222222-2222-2222-2222-222222222223', 1, '11111111-1111-1111-1111-111111111111', 2, NULL);