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:
Dennis Nemec
2026-05-14 22:28:31 +02:00
commit 438040acce
83 changed files with 8922 additions and 0 deletions

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

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

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

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