Initial: Rust-Backend mit Clean Architecture (domain/application/infrastructure/api)
Vier-Crate-Workspace mit:
- Domain: Account, Car, Tour, Delivery, DeliveryItem, DeliveryNote, Customer,
Article, Warehouse, ScanState, AuditAction — alle mit serde + feature-gated
utoipa::ToSchema.
- Application: Ports (TourRepository, DeliveryRepository, ScanRepository,
DeliveryNoteRepository, CarRepository, AuthService) und Use Cases.
- Infrastructure: Postgres-Adapter via sqlx (PgTourRepository etc.) +
Keycloak-AuthService mit JWKS-Cache + OIDC-Discovery.
- API: Axum 0.8, utoipa-OpenAPI + Swagger-UI, JWT-Bearer-Middleware,
AuthenticatedUser-Extractor.
Endpoints:
- GET /me/tours/today, /tours/{id}, /accounts/{pn}, /me/cars, /health
- POST /sync/tour, /scans (bulk + idempotent via clientScanId),
/deliveries/{id}/{hold,resume,cancel,complete,notes}, /me/cars
- PUT /tours/{id}/delivery-order, /deliveries/{id}/assigned-car, /me/cars/{id}
- PATCH /me/cars/{id}
Datenmodell:
- 6 Migrationen (accounts, tours/deliveries/items + Stammdaten,
scan_audit mit clientScanId-UNIQUE, state_reason refactor,
delivery_notes, cars + FKs nachziehen).
- Business-stabile Beleg-Keys (belegart_id, belegnummer) für ERP-Sync.
- Append-only scan_audit + embedded scan_state als doppelte Wahrheit.
Dev-Setup:
- docker-compose mit Postgres 17 + Keycloak 26
- Keycloak-Realm 'holzleitner' mit Public-Client (PKCE), Testfahrer
(PN 1001) + Audience-/Personalnummer-Mapper
This commit is contained in:
13
migrations/0001_accounts.sql
Normal file
13
migrations/0001_accounts.sql
Normal file
@ -0,0 +1,13 @@
|
||||
-- Erste Tabelle: Account. Wird zunächst nur als Smoke-Test-Ziel für die
|
||||
-- End-to-End-Achse benutzt; weitere Tabellen folgen pro Migration.
|
||||
|
||||
CREATE TABLE accounts (
|
||||
personalnummer BIGINT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Seed-Daten für Smoke-Tests.
|
||||
INSERT INTO accounts (personalnummer, name, active) VALUES
|
||||
(1001, 'Müller Logistik GmbH', TRUE),
|
||||
(1002, 'Schmidt Transporte', TRUE);
|
||||
185
migrations/0002_tours.sql
Normal file
185
migrations/0002_tours.sql
Normal file
@ -0,0 +1,185 @@
|
||||
-- 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);
|
||||
52
migrations/0003_scan_audit.sql
Normal file
52
migrations/0003_scan_audit.sql
Normal file
@ -0,0 +1,52 @@
|
||||
-- Append-only Audit-Log für jede Scan-Aktion an einer Lieferposition.
|
||||
--
|
||||
-- Doppelter Wahrheits-Ansatz:
|
||||
-- * delivery_items.scan_* = "schnelle Wahrheit" über WIEVIEL (Embedded)
|
||||
-- * scan_audit = "lange Wahrheit" über WIE und WANN
|
||||
--
|
||||
-- Idempotenz: client_scan_id ist UNIQUE. Schickt die App einen Scan
|
||||
-- nach Netzfehler erneut, kollidiert sie auf diesem Index — der Server
|
||||
-- liefert "duplicate" zurück, ohne den embedded scan_state zu verändern.
|
||||
--
|
||||
-- Beleg-Bezug wird denormalisiert mitgespeichert: bleibt nachvollziehbar,
|
||||
-- auch wenn delivery_items irgendwann archiviert oder bereinigt wird.
|
||||
|
||||
CREATE TABLE scan_audit (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Idempotenz-Schlüssel vom Client.
|
||||
client_scan_id UUID NOT NULL UNIQUE,
|
||||
|
||||
delivery_item_id UUID NOT NULL REFERENCES delivery_items(id) ON DELETE CASCADE,
|
||||
|
||||
action TEXT NOT NULL
|
||||
CHECK (action IN ('scan','unscan','hold','unhold','remove')),
|
||||
|
||||
-- Signed Delta auf scanned_quantity. +1 / -1 / 0 abhängig vom action.
|
||||
delta INT NOT NULL,
|
||||
-- Snapshot scanned_quantity nach dieser Aktion (Reports brauchen keinen Replay).
|
||||
resulting_quantity INT NOT NULL CHECK (resulting_quantity >= 0),
|
||||
resulting_status TEXT NOT NULL
|
||||
CHECK (resulting_status IN ('in_progress','done','held','removed')),
|
||||
|
||||
reason TEXT,
|
||||
|
||||
-- Akteur: Personalnummer aus dem JWT (Pflicht).
|
||||
-- car_id NULL bis Cars im Backend gemanagt werden.
|
||||
actor_personalnummer BIGINT NOT NULL,
|
||||
actor_car_id UUID,
|
||||
|
||||
-- Zeitpunkt am Client (offline-fähig) + Eingang am Server.
|
||||
client_scanned_at TIMESTAMPTZ NOT NULL,
|
||||
server_recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
-- Denormalisierter ERP-Beleg-Bezug.
|
||||
erp_belegart_id BIGINT NOT NULL,
|
||||
erp_belegnummer TEXT NOT NULL,
|
||||
erp_belegzeilen_nr INT NOT NULL,
|
||||
erp_komponenten_artikel_nr TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX scan_audit_item ON scan_audit(delivery_item_id);
|
||||
CREATE INDEX scan_audit_recorded ON scan_audit(server_recorded_at);
|
||||
CREATE INDEX scan_audit_actor ON scan_audit(actor_personalnummer, server_recorded_at);
|
||||
10
migrations/0004_delivery_state_reason.sql
Normal file
10
migrations/0004_delivery_state_reason.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- Ein Reason-Feld für beide Nicht-Active-Status (Hold + Cancel).
|
||||
--
|
||||
-- Bisher hieß die Spalte `cancellation_reason` und passte nur zum
|
||||
-- Cancel-Pfad. Mit dem Hold-Flow brauchen wir ebenfalls einen
|
||||
-- begründenden Text — semantisch ist es immer "warum ist die
|
||||
-- Lieferung gerade nicht 'active'". `state_reason` ist der bessere
|
||||
-- Name; beim Resume / Complete wird die Spalte auf NULL gesetzt.
|
||||
|
||||
ALTER TABLE deliveries
|
||||
RENAME COLUMN cancellation_reason TO state_reason;
|
||||
21
migrations/0005_delivery_notes.sql
Normal file
21
migrations/0005_delivery_notes.sql
Normal file
@ -0,0 +1,21 @@
|
||||
-- Notizen pro Lieferung. Eine Notiz ist entweder Text, ein Bild-Anhang
|
||||
-- (Object-Storage-Key/URL) oder beides — aber nicht NULL/NULL.
|
||||
--
|
||||
-- Akteur: actor_personalnummer ist Pflicht (aus JWT). Das fachlich
|
||||
-- gewünschte author_car_id bleibt optional, bis das Backend Fahrzeuge
|
||||
-- selbst verwaltet.
|
||||
|
||||
CREATE TABLE delivery_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE,
|
||||
text TEXT,
|
||||
image_attachment TEXT,
|
||||
author_personalnummer BIGINT NOT NULL,
|
||||
author_car_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CHECK (text IS NOT NULL OR image_attachment IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX delivery_notes_delivery
|
||||
ON delivery_notes (delivery_id, created_at);
|
||||
39
migrations/0006_cars.sql
Normal file
39
migrations/0006_cars.sql
Normal file
@ -0,0 +1,39 @@
|
||||
-- Fahrzeuge eines Subunternehmer-Accounts. Kein ERP-Spiegel — die App
|
||||
-- pflegt die Fahrzeuge selbst.
|
||||
--
|
||||
-- Im Audit-Log und an Lieferungen war `car_id` bislang ohne FK
|
||||
-- geführt (Spalten existieren in 0002/0003/0005). Diese Migration
|
||||
-- zieht die FKs nach. Bestehende NULL-Werte bleiben gültig.
|
||||
--
|
||||
-- `(account_id, plate)` ist fachlich eindeutig — pro Account keine
|
||||
-- doppelten Kennzeichen.
|
||||
|
||||
CREATE TABLE cars (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id BIGINT NOT NULL REFERENCES accounts(personalnummer),
|
||||
plate TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX cars_account_plate ON cars(account_id, plate);
|
||||
CREATE INDEX cars_account ON cars(account_id);
|
||||
|
||||
-- FKs nachziehen — alle Spalten bleiben NULLABLE, weil
|
||||
-- Audit-Einträge der Vor-Cars-Phase keinen car_id haben.
|
||||
ALTER TABLE deliveries
|
||||
ADD CONSTRAINT deliveries_assigned_car_fk
|
||||
FOREIGN KEY (assigned_car_id) REFERENCES cars(id);
|
||||
|
||||
ALTER TABLE delivery_notes
|
||||
ADD CONSTRAINT delivery_notes_author_car_fk
|
||||
FOREIGN KEY (author_car_id) REFERENCES cars(id);
|
||||
|
||||
ALTER TABLE scan_audit
|
||||
ADD CONSTRAINT scan_audit_actor_car_fk
|
||||
FOREIGN KEY (actor_car_id) REFERENCES cars(id);
|
||||
|
||||
-- Seed: zwei Fahrzeuge für Testfahrer (PN 1001) — bewusst zwei,
|
||||
-- damit der Flow "Auswählen ab 2 Autos" testbar ist.
|
||||
INSERT INTO cars (id, account_id, plate, active) VALUES
|
||||
('77777777-7777-7777-7777-777777777771', 1001, 'BGL-HZ 100', TRUE),
|
||||
('77777777-7777-7777-7777-777777777772', 1001, 'BGL-HZ 200', TRUE);
|
||||
Reference in New Issue
Block a user