Files
Holzleitner-Lieferservice-App/openapi/holzleitner.json
Dennis Nemec 8cf4045e44 Phase A: generierter Dart-Client + DI-Foundation für Rust-Backend
OpenAPI-Generator-Setup:
- tool/generate_api_client.sh: Direkter Aufruf der openapi-generator-cli.jar
  (Java-CLI statt Dart-build_runner-Integration — vermeidet die
  analyzer-/source_gen-Version-Hölle mit json_serializable)
- tool/fetch_openapi_generator.sh: lädt die JAR (29 MB) nach (gitignored)
- openapi/holzleitner.json: Snapshot der Backend-Spec für reproduzierbare
  Generation
- packages/holzleitner_api/: generiertes Dart-Sub-Package (built_value +
  dio), per path-dep im Haupt-pubspec eingehängt

Netzwerk-Layer (lib/data/network/):
- BackendConfig: API- und Keycloak-Endpoints für Local-Dev (localhost
  wegen Keycloak-iss-Claim).
- AuthTokenProvider-Schnittstelle.
- DevPasswordGrantTokenProvider: Phase-A-Provider via Keycloak
  password-grant, Token-Caching mit Expiry-Check (Phase B ersetzt das
  durch flutter_appauth PKCE).
- HolzleitnerAuthInterceptor: dynamischer Bearer-Inject pro Request.
- HolzleitnerApiFactory: baut die generierte HolzleitnerApi-Klasse
  mit unserem Interceptor statt der vier Default-Auth-Interceptors.
- network_locator.registerNetworking(): get_it-Setup, in main() vor
  runApp() aufgerufen.

Clean-Arch-Scaffolding (lib/data/, lib/domain/):
- Verzeichnisstruktur für Phase C+D angelegt (mapper/, repository/,
  entity/, repository/) — befüllt sich in den Folge-Phasen.

Smoke-Test:
- tool/smoke_test_api.dart ruft /health (ungeschützt) und /me/cars
  (mit Bearer) via generiertem Client — grün gegen lokales Backend.
2026-05-14 22:44:51 +02:00

1828 lines
68 KiB
JSON

{
"openapi": "3.1.0",
"info": {
"title": "Holzleitner Backend API",
"description": "Backend f\u00fcr die Holzleitner-Lieferservice-App \u2014 Tour, Beladung, Ausf\u00fchrung.",
"contact": {
"name": "Holzleitner GmbH"
},
"license": {
"name": "Proprietary",
"identifier": "Proprietary"
},
"version": "0.1.0"
},
"paths": {
"/accounts/{personalnummer}": {
"get": {
"tags": [
"accounts"
],
"summary": "Liest den Account zu einer Personalnummer.",
"operationId": "get_account",
"parameters": [
{
"name": "personalnummer",
"in": "path",
"description": "Personalnummer des Accounts",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "Account gefunden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Account"
}
}
}
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
},
"404": {
"description": "Kein Account zu dieser Personalnummer"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/deliveries/{delivery_id}/assigned-car": {
"put": {
"tags": [
"deliveries"
],
"summary": "Setzt das `assigned_car_id` einer Lieferung. `carId: null` l\u00f6st\ndie Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug\nzum angemeldeten Account geh\u00f6rt.",
"operationId": "assign_car",
"parameters": [
{
"name": "delivery_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssignCarRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Fahrzeug zugewiesen / entfernt",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeliveryResponse"
}
}
}
},
"400": {
"description": "Fahrzeug geh\u00f6rt nicht zum Account"
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
},
"404": {
"description": "Lieferung nicht gefunden"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/deliveries/{delivery_id}/cancel": {
"post": {
"tags": [
"deliveries"
],
"summary": "Setzt die Lieferung auf `canceled` \u2014 endg\u00fcltig. Erlaubt aus\n`active` und `held`.",
"operationId": "cancel",
"parameters": [
{
"name": "delivery_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CancelDeliveryRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Lieferung storniert",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeliveryResponse"
}
}
}
},
"400": {
"description": "Invalider Status\u00fcbergang oder leerer Reason"
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
},
"404": {
"description": "Lieferung nicht gefunden"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/deliveries/{delivery_id}/complete": {
"post": {
"tags": [
"deliveries"
],
"summary": "Schlie\u00dft die Lieferung ab \u2014 `state = completed`. Nur aus `active`.",
"operationId": "complete",
"parameters": [
{
"name": "delivery_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Lieferung abgeschlossen",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeliveryResponse"
}
}
}
},
"400": {
"description": "Invalider Status\u00fcbergang"
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
},
"404": {
"description": "Lieferung nicht gefunden"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/deliveries/{delivery_id}/hold": {
"post": {
"tags": [
"deliveries"
],
"summary": "Setzt die Lieferung auf `held`. Nur aus `active` zul\u00e4ssig.",
"operationId": "hold",
"parameters": [
{
"name": "delivery_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HoldDeliveryRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Lieferung geholdet",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeliveryResponse"
}
}
}
},
"400": {
"description": "Invalider Status\u00fcbergang oder leerer Reason"
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
},
"404": {
"description": "Lieferung nicht gefunden"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/deliveries/{delivery_id}/notes": {
"post": {
"tags": [
"deliveries"
],
"summary": "Legt eine neue Notiz an einer Lieferung an. Mindestens eines von\n`text` und `imageAttachment` muss inhaltlich gef\u00fcllt sein\n(Leerstrings werden serverseitig getrimmt und als leer behandelt).",
"operationId": "create_note",
"parameters": [
{
"name": "delivery_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateDeliveryNoteRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Notiz angelegt",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeliveryNoteResponse"
}
}
}
},
"400": {
"description": "Notiz ohne Inhalt"
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
},
"404": {
"description": "Lieferung nicht gefunden"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/deliveries/{delivery_id}/resume": {
"post": {
"tags": [
"deliveries"
],
"summary": "Setzt die Lieferung zur\u00fcck auf `active`. Nur aus `held` zul\u00e4ssig.",
"operationId": "resume",
"parameters": [
{
"name": "delivery_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Lieferung wieder aktiv",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeliveryResponse"
}
}
}
},
"400": {
"description": "Invalider Status\u00fcbergang"
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
},
"404": {
"description": "Lieferung nicht gefunden"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/health": {
"get": {
"tags": [
"health"
],
"summary": "Health-Endpoint f\u00fcr Load-Balancer und Container-Probes. Bewusst\nkein Auth \u2014 eine `200 ok`-Antwort darf nicht von der Auth abh\u00e4ngen.",
"operationId": "health",
"responses": {
"200": {
"description": "Service ist erreichbar",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
},
"security": []
}
},
"/me/cars": {
"get": {
"tags": [
"cars"
],
"summary": "Listet die Fahrzeuge des angemeldeten Fahrers.",
"operationId": "list_my_cars",
"parameters": [
{
"name": "includeInactive",
"in": "query",
"description": "Wenn true, werden inaktive Fahrzeuge mitgeliefert (default: false)",
"required": false,
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "Fahrzeuge des Fahrers",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CarsList"
}
}
}
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
}
},
"security": [
{
"bearer_auth": []
}
]
},
"post": {
"tags": [
"cars"
],
"summary": "Legt ein neues Fahrzeug f\u00fcr den angemeldeten Fahrer an.",
"operationId": "create_my_car",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateCarRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Fahrzeug angelegt",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CarResponse"
}
}
}
},
"400": {
"description": "Validierungsfehler (z. B. doppeltes Kennzeichen)"
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/me/cars/{car_id}": {
"patch": {
"tags": [
"cars"
],
"summary": "Aktualisiert ein Fahrzeug (Kennzeichen \u00e4ndern / deaktivieren).",
"operationId": "update_my_car",
"parameters": [
{
"name": "car_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateCarRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Fahrzeug aktualisiert",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CarResponse"
}
}
}
},
"400": {
"description": "Validierungsfehler"
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
},
"404": {
"description": "Fahrzeug nicht gefunden oder geh\u00f6rt nicht zu diesem Account"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/me/tours/today": {
"get": {
"tags": [
"tours"
],
"summary": "Listet heutige Touren des angemeldeten Fahrers (Filter aus dem JWT).",
"operationId": "list_my_tours_today",
"responses": {
"200": {
"description": "Liste der heutigen Touren",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TourSummaryList"
}
}
}
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/scans": {
"post": {
"tags": [
"scans"
],
"summary": "Wendet eine Liste von Scan-Events idempotent an.",
"description": "Pro Event ein eigenes Resultat. Status `applied` schreibt einen\nfrischen Audit-Eintrag, `duplicate` liefert den aktuellen Stand am\nServer, `rejected` enth\u00e4lt die Begr\u00fcndung. Reihenfolge der `results`\nentspricht der Reihenfolge der `scans` im Request.",
"operationId": "apply_scans",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApplyScansRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Bulk-Ergebnis pro Event",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApplyScansResponse"
}
}
}
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/sync/tour": {
"post": {
"tags": [
"sync"
],
"summary": "Sync-Endpoint f\u00fcr das ERP: legt eine Tagestour samt Lieferungen und\nPositionen idempotent an. Identit\u00e4t pro Tour\n`(driver_personalnummer, tour_date)`, pro Lieferung\n`(belegart_id, belegnummer)`.",
"operationId": "sync_tour",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SyncTourRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Tour gespeichert",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SyncTourResponse"
}
}
}
},
"400": {
"description": "Validierungsfehler im Sync-Payload"
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/tours/{tour_id}": {
"get": {
"tags": [
"tours"
],
"summary": "L\u00e4dt eine Tour mit allen Lieferungen, Positionen und referenzierten\nStammdaten \u2014 die App nutzt das als einzigen gro\u00dfen Read.",
"operationId": "get_tour",
"parameters": [
{
"name": "tour_id",
"in": "path",
"description": "Eindeutige Tour-Id (UUID)",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Tour-Aggregat gefunden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TourDetails"
}
}
}
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
},
"404": {
"description": "Keine Tour mit dieser Id"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/tours/{tour_id}/delivery-order": {
"put": {
"tags": [
"tours"
],
"summary": "Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour neu.\nDer Client schickt die **vollst\u00e4ndige** neue Reihenfolge; fehlende\noder fremde Lieferungs-Ids werden mit `400 validation` abgelehnt.",
"operationId": "set_delivery_order",
"parameters": [
{
"name": "tour_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetDeliveryOrderRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Neue Reihenfolge gespeichert",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetDeliveryOrderResponse"
}
}
}
},
"400": {
"description": "Mengen-Mismatch oder Duplikate"
},
"401": {
"description": "Authentifizierung fehlgeschlagen"
},
"404": {
"description": "Tour nicht gefunden"
}
},
"security": [
{
"bearer_auth": []
}
]
}
}
},
"components": {
"schemas": {
"Account": {
"type": "object",
"description": "Account eines Liefer-Unternehmens oder Einzel-Lieferfahrers.\n\nDie Personalnummer ist sowohl Prim\u00e4rschl\u00fcssel als auch Login-ID. Sie\nstammt aus dem ERP-Stamm \u2014 entweder ein Unternehmen (juristische\nPerson, eigener Personalnummern-Kreis) oder eine nat\u00fcrliche Person.\n\nMehrere physische Fahrer k\u00f6nnen denselben Account benutzen; das Modell\nunterscheidet sie nicht, sondern loggt die Aktivit\u00e4t auf [`crate::domain::Car`]-\nEbene (siehe Audit-Log).",
"required": [
"personalnummer",
"name",
"active"
],
"properties": {
"active": {
"type": "boolean"
},
"name": {
"type": "string"
},
"personalnummer": {
"type": "integer",
"format": "int64"
}
}
},
"Address": {
"type": "object",
"description": "Postanschrift \u2014 wird sowohl als aktuelle Kundenanschrift in [`Customer`]\nals auch als unver\u00e4nderlicher Snapshot in [`crate::domain::Delivery`]\nverwendet (`delivery_address_snapshot`).\n\nBewusst als Value Object modelliert: gleiche Adresse = gleicher Wert.\nStrikte Equality erleichtert Sync-Diffs zwischen ERP und Backend.\n\n[`Customer`]: crate::domain::Customer",
"required": [
"street",
"houseNumber",
"postalCode",
"city",
"country"
],
"properties": {
"city": {
"type": "string"
},
"country": {
"type": "string"
},
"houseNumber": {
"type": "string"
},
"postalCode": {
"type": "string"
},
"street": {
"type": "string"
}
}
},
"ApplyScansRequest": {
"type": "object",
"required": [
"scans"
],
"properties": {
"scans": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ScanEvent"
}
}
}
},
"ApplyScansResponse": {
"type": "object",
"required": [
"results"
],
"properties": {
"results": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ScanResult"
}
}
}
},
"Article": {
"type": "object",
"description": "Artikel. ERP-Mirror; die `article_number` ist die business-stabile\nArtikelnummer aus dem ERP-Stamm und dient gleichzeitig als Br\u00fccke.\n\n`scannable = false` markiert nicht-physische Positionen wie\nDienstleistungen, Versandpauschalen o.\u00e4. \u2014 sie tauchen zwar als\n`DeliveryItem` auf, blockieren aber den Beladen-Fortschritt nicht.",
"required": [
"id",
"articleNumber",
"name",
"scannable"
],
"properties": {
"articleNumber": {
"type": "string"
},
"defaultWarehouseId": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"scannable": {
"type": "boolean"
}
}
},
"AssignCarRequest": {
"type": "object",
"description": "Setzt das `assigned_car_id` einer Lieferung. `None` (`carId: null`)\nentfernt die Zuordnung.",
"properties": {
"carId": {
"type": [
"string",
"null"
],
"format": "uuid"
}
}
},
"AuditAction": {
"type": "string",
"description": "Aktion-Typen im Scan-Audit-Log.\n\n* `Scan` / `Unscan` ver\u00e4ndern die `scanned_quantity` (+1 / -1).\n* `Hold` / `Unhold` \u00e4ndern nur den Status, keine Menge.\n* `Remove` markiert die Position als entfernt (Status `Removed`,\n z. B. weil der Kunde sie nicht annimmt).",
"enum": [
"scan",
"unscan",
"hold",
"unhold",
"remove"
]
},
"CancelDeliveryRequest": {
"type": "object",
"required": [
"reason"
],
"properties": {
"reason": {
"type": "string"
}
}
},
"Car": {
"type": "object",
"description": "Fahrzeug eines [`crate::domain::Account`]. Wird in der App selbst\ngepflegt \u2014 kein ERP-Spiegel. Eindeutig per UUID.\n\nIm Audit-Log ist der `Car` der \u201eAkteur\": die Personalnummer-Ebene\n(Account) ist gr\u00f6ber und unterscheidet nicht zwischen mehreren\ngleichzeitig aktiven Fahrern desselben Subunternehmens.",
"required": [
"id",
"accountId",
"plate",
"active"
],
"properties": {
"accountId": {
"type": "integer",
"format": "int64",
"description": "Verweis auf [`crate::domain::Account::personalnummer`]."
},
"active": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
},
"plate": {
"type": "string"
}
}
},
"CarResponse": {
"type": "object",
"required": [
"car"
],
"properties": {
"car": {
"$ref": "#/components/schemas/Car"
}
}
},
"CarsList": {
"type": "object",
"required": [
"cars"
],
"properties": {
"cars": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Car"
}
}
}
},
"CreateCarRequest": {
"type": "object",
"required": [
"plate"
],
"properties": {
"plate": {
"type": "string"
}
}
},
"CreateDeliveryNoteRequest": {
"type": "object",
"properties": {
"authorCarId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Fahrzeug, das die Notiz erzeugt hat. Muss zum angemeldeten\nAccount geh\u00f6ren. `None` ist erlaubt."
},
"imageAttachment": {
"type": [
"string",
"null"
],
"description": "Object-Storage-Key oder URL eines vorab hochgeladenen Bildes."
},
"text": {
"type": [
"string",
"null"
]
}
}
},
"Customer": {
"type": "object",
"description": "Kunde. ERP-Mirror: die Stammdaten geh\u00f6ren dem ERP, wir spiegeln sie\nf\u00fcr die App. Die `erp_customer_id` ist die Br\u00fccke zur\u00fcck (in der\nRegel die `Kunde.row_id` aus ERPframe).\n\nDie `Customer.address` ist die *aktuelle* Anschrift. F\u00fcr historische\nStabilit\u00e4t f\u00fchrt [`crate::domain::Delivery`] zus\u00e4tzlich einen\n`delivery_address_snapshot` \u2014 Adress-\u00c4nderungen wirken nicht\nr\u00fcckwirkend auf bereits zugestellte oder geplante Lieferungen.",
"required": [
"id",
"erpCustomerId",
"name",
"address"
],
"properties": {
"address": {
"$ref": "#/components/schemas/Address"
},
"erpCustomerId": {
"type": "integer",
"format": "int64"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
}
}
},
"CustomerContact": {
"type": "object",
"description": "Ansprechpartner eines Kunden. Ein Kunde kann mehrere Kontaktpersonen\nhaben (z. B. Empfang vor Ort + Gesch\u00e4ftsf\u00fchrung). Eine Lieferung w\u00e4hlt\n0..N davon als aktive Kontakte aus (siehe\n`Delivery::contact_person_ids`).",
"required": [
"id",
"customerId",
"name"
],
"properties": {
"customerId": {
"type": "string",
"format": "uuid"
},
"email": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"phone": {
"type": [
"string",
"null"
]
}
}
},
"Delivery": {
"type": "object",
"description": "Eine einzelne Lieferung an einen Kunden. Aggregat-Wurzel f\u00fcr die\nLiefer-Items, Notizen und das ggf. zugeordnete Fahrzeug.",
"required": [
"id",
"tourId",
"erpBelegartId",
"erpBelegnummer",
"customerId",
"deliveryAddressSnapshot",
"contactPersonIds",
"state"
],
"properties": {
"assignedCarId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Fahrzeug-Zuordnung, gesetzt in der Ausw\u00e4hlen-Phase.\nBei Ein-Auto-Teams beim Sync automatisch gef\u00fcllt."
},
"contactPersonIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"description": "Ausgew\u00e4hlte Ansprechpartner f\u00fcr genau diese Lieferung (Auswahl\naus `Customer.contacts`). Kann leer sein."
},
"customerId": {
"type": "string",
"format": "uuid"
},
"deliveryAddressSnapshot": {
"$ref": "#/components/schemas/Address",
"description": "Eingefrorene Liefer-Adresse zum Zeitpunkt des Tour-Syncs.\nSch\u00fctzt vor r\u00fcckwirkenden Kunden-Adress\u00e4nderungen."
},
"desiredTime": {
"type": [
"string",
"null"
],
"description": "Wunsch-Lieferzeit als Freitext (z. B. \"vormittags\", \"ab 14:00\")."
},
"erpBelegartId": {
"type": "integer",
"format": "int64",
"description": "ERP-Beleg-Bezug: business-stabiles Paar `(Belegart, Belegnummer)`.\n\u00dcberlebt den Belegkopf-Archiv\u00fcbergang."
},
"erpBelegnummer": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"specialAgreements": {
"type": [
"string",
"null"
],
"description": "Sondervereinbarungen (z. B. \u201eT\u00fcrklingel defekt, hintenrum klopfen\")."
},
"state": {
"$ref": "#/components/schemas/DeliveryState"
},
"stateReason": {
"type": [
"string",
"null"
],
"description": "Begr\u00fcndung bei `state == Held` oder `state == Canceled`. Beim\nResume / Complete wieder `None`."
},
"tourId": {
"type": "string",
"format": "uuid"
}
}
},
"DeliveryItem": {
"type": "object",
"description": "Einzelposition einer Lieferung. Vereint regul\u00e4re Belegzeilen und\nSt\u00fccklisten-Komponenten zu einer flachen Liste \u2014 die St\u00fccklisten-\nHierarchie ist ein ERP-Konstrukt und wird beim Sync aufgel\u00f6st.\n\n\u00dcber die Felder `belegzeilen_nr` und `komponenten_artikel_nr` bleibt\ndie ERP-Herkunft aufl\u00f6sbar.",
"required": [
"id",
"deliveryId",
"articleId",
"requiredQuantity",
"warehouseId",
"belegzeilenNr",
"scanState"
],
"properties": {
"articleId": {
"type": "string",
"format": "uuid"
},
"belegzeilenNr": {
"type": "integer",
"format": "int32",
"description": "ERP-Belegzeilen-Nr (Position innerhalb des Belegs)."
},
"deliveryId": {
"type": "string",
"format": "uuid"
},
"id": {
"type": "string",
"format": "uuid"
},
"komponentenArtikelNr": {
"type": [
"string",
"null"
],
"description": "Bei Items aus einer St\u00fcckliste: Artikelnummer der Komponente.\nBei regul\u00e4ren Belegzeilen: `None`."
},
"requiredQuantity": {
"type": "integer",
"format": "int32"
},
"scanState": {
"$ref": "#/components/schemas/ScanState"
},
"warehouseId": {
"type": "string",
"format": "uuid"
}
}
},
"DeliveryNote": {
"type": "object",
"description": "Notiz an einer Lieferung \u2014 frei eingegeben durch den Fahrer.\n\nMindestens eines von `text` oder `image_attachment` muss gesetzt\nsein. Die Constraint sitzt sowohl im DB-Schema (CHECK) als auch\nin der Application-Schicht.",
"required": [
"id",
"deliveryId",
"authorPersonalnummer",
"createdAt"
],
"properties": {
"authorCarId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Fahrzeug, falls bekannt \u2014 nullable bis das Backend Cars verwaltet."
},
"authorPersonalnummer": {
"type": "integer",
"format": "int64",
"description": "Personalnummer des Akteurs (aus dem JWT). Pflicht."
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"deliveryId": {
"type": "string",
"format": "uuid"
},
"id": {
"type": "string",
"format": "uuid"
},
"imageAttachment": {
"type": [
"string",
"null"
],
"description": "Referenz auf einen Bild-Anhang (z. B. Object-Storage-Key/URL)."
},
"text": {
"type": [
"string",
"null"
]
}
}
},
"DeliveryNoteResponse": {
"type": "object",
"required": [
"note"
],
"properties": {
"note": {
"$ref": "#/components/schemas/DeliveryNote"
}
}
},
"DeliveryOrderEntry": {
"type": "object",
"required": [
"deliveryId",
"sortOrder"
],
"properties": {
"deliveryId": {
"type": "string",
"format": "uuid"
},
"sortOrder": {
"type": "integer",
"format": "int32"
}
}
},
"DeliveryResponse": {
"type": "object",
"required": [
"delivery"
],
"properties": {
"delivery": {
"$ref": "#/components/schemas/Delivery"
}
}
},
"DeliveryState": {
"type": "string",
"description": "Lebenszyklus einer Lieferung.\n\n`Held` ist f\u00fcr \u201eheute nicht zustellbar, aber nicht endg\u00fcltig abgesagt\"\nreserviert; `Canceled` ist endg\u00fcltig. `Completed` setzt der\nAbschluss-Flow am Ende der Auslieferung.",
"enum": [
"active",
"held",
"canceled",
"completed"
]
},
"DeliveryWithItems": {
"allOf": [
{
"$ref": "#/components/schemas/Delivery"
},
{
"type": "object",
"required": [
"sortOrder",
"items"
],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeliveryItem"
}
},
"sortOrder": {
"type": "integer",
"format": "int32",
"description": "Sortier-Reihenfolge innerhalb der Tour (1-basiert)."
}
}
}
]
},
"HoldDeliveryRequest": {
"type": "object",
"required": [
"reason"
],
"properties": {
"reason": {
"type": "string"
}
}
},
"ScanEvent": {
"type": "object",
"required": [
"clientScanId",
"deliveryItemId",
"action",
"clientScannedAt"
],
"properties": {
"action": {
"$ref": "#/components/schemas/AuditAction"
},
"actorCarId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Fahrzeug, in dem der Scan gemacht wurde. Muss zum\nangemeldeten Account geh\u00f6ren. `None` ist erlaubt, schw\u00e4cht\naber den Audit-Trail."
},
"clientScanId": {
"type": "string",
"format": "uuid"
},
"clientScannedAt": {
"type": "string",
"format": "date-time"
},
"deliveryItemId": {
"type": "string",
"format": "uuid"
},
"reason": {
"type": [
"string",
"null"
],
"description": "Pflicht bei `Hold` und `Remove`. Sonst ignoriert."
}
}
},
"ScanResult": {
"type": "object",
"required": [
"clientScanId",
"status"
],
"properties": {
"clientScanId": {
"type": "string",
"format": "uuid"
},
"deliveryItemId": {
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Aktueller `scan_state` der Position nach der Verarbeitung \u2014\ngenau dann gesetzt, wenn der Server den Stand kennen konnte\n(`Applied` oder `Duplicate`). Erlaubt der App, die UI ohne\nRe-Fetch zu aktualisieren."
},
"newScanState": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ScanState"
}
]
},
"reason": {
"type": [
"string",
"null"
],
"description": "Bei `Rejected`: Begr\u00fcndung. Bei `Applied`/`Duplicate`: `None`."
},
"status": {
"$ref": "#/components/schemas/ScanResultStatus"
}
}
},
"ScanResultStatus": {
"type": "string",
"enum": [
"applied",
"duplicate",
"rejected"
]
},
"ScanState": {
"type": "object",
"description": "Eingebetteter Scan-Zustand pro [`DeliveryItem`]. Wird durch\n`ScanAuditEntry`-Events fortgeschrieben \u2014 das Audit-Log ist die\nWahrheit \u00fcber das WIE und WANN, dieses Embedded-VO ist die schnelle\nWahrheit \u00fcber das WIEVIEL.",
"required": [
"scannedQuantity",
"status",
"lastUpdatedAt"
],
"properties": {
"heldReason": {
"type": [
"string",
"null"
],
"description": "Grund bei `status == Held` oder `status == Removed`."
},
"lastUpdatedAt": {
"type": "string",
"format": "date-time"
},
"scannedQuantity": {
"type": "integer",
"format": "int32"
},
"status": {
"$ref": "#/components/schemas/ScanStatus"
}
}
},
"ScanStatus": {
"type": "string",
"description": "Status einer einzelnen Scan-Position innerhalb eines Items.",
"enum": [
"in_progress",
"done",
"held",
"removed"
]
},
"SetDeliveryOrderRequest": {
"type": "object",
"required": [
"deliveryIds"
],
"properties": {
"deliveryIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"description": "Reihenfolge: Position im Array (0-basiert) wird zu `sort_order`\n(1-basiert) gemappt."
}
}
},
"SetDeliveryOrderResponse": {
"type": "object",
"required": [
"tourId",
"order"
],
"properties": {
"order": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeliveryOrderEntry"
}
},
"tourId": {
"type": "string",
"format": "uuid"
}
}
},
"SyncDelivery": {
"type": "object",
"required": [
"belegartId",
"belegnummer",
"erpCustomerId",
"customerName",
"customerAddress",
"deliveryAddress",
"sortOrder",
"items"
],
"properties": {
"belegartId": {
"type": "integer",
"format": "int64"
},
"belegnummer": {
"type": "string"
},
"customerAddress": {
"$ref": "#/components/schemas/Address"
},
"customerName": {
"type": "string"
},
"deliveryAddress": {
"$ref": "#/components/schemas/Address",
"description": "Snapshot der Lieferadresse (kann von der Stammadresse abweichen)."
},
"desiredTime": {
"type": [
"string",
"null"
]
},
"erpCustomerId": {
"type": "integer",
"format": "int64"
},
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SyncDeliveryItem"
}
},
"sortOrder": {
"type": "integer",
"format": "int32",
"description": "1-basiert, definiert die initiale Reihenfolge in der App."
},
"specialAgreements": {
"type": [
"string",
"null"
]
}
}
},
"SyncDeliveryItem": {
"type": "object",
"required": [
"belegzeilenNr",
"articleNumber",
"articleName",
"articleScannable",
"warehouseCode",
"warehouseName",
"requiredQuantity"
],
"properties": {
"articleDefaultWarehouseCode": {
"type": [
"string",
"null"
],
"description": "Default-Lager-Code f\u00fcr den Artikel (Anlage neuer Artikel)."
},
"articleName": {
"type": "string"
},
"articleNumber": {
"type": "string"
},
"articleScannable": {
"type": "boolean"
},
"belegzeilenNr": {
"type": "integer",
"format": "int32"
},
"komponentenArtikelNr": {
"type": [
"string",
"null"
],
"description": "Komponenten-Artikelnummer bei aufgel\u00f6sten St\u00fccklisten, sonst leer."
},
"requiredQuantity": {
"type": "integer",
"format": "int32"
},
"warehouseCode": {
"type": "string"
},
"warehouseName": {
"type": "string"
}
}
},
"SyncTourRequest": {
"type": "object",
"required": [
"driverPersonalnummer",
"tourDate",
"deliveries"
],
"properties": {
"deliveries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SyncDelivery"
}
},
"driverPersonalnummer": {
"type": "integer",
"format": "int64"
},
"tourDate": {
"type": "string",
"format": "date"
}
}
},
"SyncTourResponse": {
"type": "object",
"description": "Antwort-H\u00fclle f\u00fcr `POST /sync/tour`.",
"required": [
"tourId"
],
"properties": {
"tourId": {
"type": "string",
"format": "uuid"
}
}
},
"Tour": {
"type": "object",
"description": "Tour eines Tages, pro [`crate::domain::Account`]. Aggregat-Wurzel\nf\u00fcr die Lieferungen dieses Tages \u2014 die einzelnen [`crate::domain::Delivery`]\nreferenzieren ihre Tour per FK.\n\nDer Sync vom ERP l\u00e4uft in der Regel einmal am Vortag und f\u00fcllt eine\nneue Tour-Zeile inklusive Delivery- und DeliveryItem-Strukturen.",
"required": [
"id",
"accountId",
"date",
"syncedAt"
],
"properties": {
"accountId": {
"type": "integer",
"format": "int64"
},
"date": {
"type": "string",
"format": "date"
},
"id": {
"type": "string",
"format": "uuid"
},
"syncedAt": {
"type": "string",
"format": "date-time",
"description": "Zeitpunkt des letzten ERP-Sync \u2014 f\u00fcr Drift-Erkennung."
}
}
},
"TourDetails": {
"type": "object",
"required": [
"tour",
"deliveries",
"customers",
"customerContacts",
"articles",
"warehouses",
"notes"
],
"properties": {
"articles": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Article"
}
},
"customerContacts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CustomerContact"
}
},
"customers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Customer"
}
},
"deliveries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeliveryWithItems"
}
},
"notes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeliveryNote"
},
"description": "Alle Notizen aller Lieferungen dieser Tour, in einer Liste.\nDie App joint clientseitig per `delivery_id`. Reihenfolge:\npro Lieferung aufsteigend nach `created_at`."
},
"tour": {
"$ref": "#/components/schemas/Tour"
},
"warehouses": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Warehouse"
}
}
}
},
"TourSummary": {
"type": "object",
"required": [
"tourId",
"tourDate",
"deliveryCount"
],
"properties": {
"deliveryCount": {
"type": "integer",
"format": "int64"
},
"tourDate": {
"type": "string",
"format": "date"
},
"tourId": {
"type": "string",
"format": "uuid"
}
}
},
"TourSummaryList": {
"type": "object",
"description": "Antwort-H\u00fclle f\u00fcr `GET /me/tours/today`. Eigenes Struct, weil\nutoipa f\u00fcr `Vec<T>` als Top-Level-Response keinen sauberen\nSchemanamen vergibt \u2014 und ein Wrapper macht die Erweiterbarkeit\n(z. B. Paginierung in Zukunft) zur Nicht-Breaking-Change.",
"required": [
"tours"
],
"properties": {
"tours": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TourSummary"
}
}
}
},
"UpdateCarRequest": {
"type": "object",
"properties": {
"active": {
"type": [
"boolean",
"null"
],
"description": "Wenn gesetzt: aktiv/inaktiv. Inaktive Fahrzeuge tauchen in\n`GET /me/cars?activeOnly=true` (default) nicht auf."
},
"plate": {
"type": [
"string",
"null"
],
"description": "Wenn gesetzt: neues Kennzeichen."
}
}
},
"Warehouse": {
"type": "object",
"description": "Lager. ERP-Mirror; `code` ist die ERP-Lager-Nr (z. B. `\"0\"` f\u00fcr das\nStandardlager). Das `is_standard`-Flag ist der schnelle Filter f\u00fcr\ndie Beladen-Logik (\u201enur Standardlager-Artikel z\u00e4hlen f\u00fcr Fertig\").",
"required": [
"id",
"code",
"name",
"isStandard"
],
"properties": {
"code": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"isStandard": {
"type": "boolean"
},
"name": {
"type": "string"
}
}
}
},
"securitySchemes": {
"bearer_auth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
},
"security": [
{
"bearer_auth": []
}
],
"tags": [
{
"name": "health",
"description": "Health- und Status-Endpoints"
},
{
"name": "accounts",
"description": "Account-Stammdaten"
},
{
"name": "tours",
"description": "Touren der Fahrer"
},
{
"name": "sync",
"description": "ERP-Sync-Endpunkte"
},
{
"name": "scans",
"description": "Scan-Events (Beladung & Auslieferung)"
},
{
"name": "deliveries",
"description": "Delivery-Lifecycle (hold / resume / cancel / complete)"
},
{
"name": "cars",
"description": "Fahrzeug-Stammdaten pro Fahrer"
}
]
}