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:
44
crates/api/src/routes/accounts.rs
Normal file
44
crates/api/src/routes/accounts.rs
Normal file
@ -0,0 +1,44 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::get;
|
||||
use holzleitner_domain::Account;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/accounts/{personalnummer}", get(get_account))
|
||||
}
|
||||
|
||||
/// Liest den Account zu einer Personalnummer.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/accounts/{personalnummer}",
|
||||
tag = "accounts",
|
||||
params(
|
||||
("personalnummer" = i64, Path, description = "Personalnummer des Accounts")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Account gefunden", body = Account),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Kein Account zu dieser Personalnummer")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_account(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(personalnummer): Path<i64>,
|
||||
) -> Result<Json<Account>, ApiError> {
|
||||
tracing::debug!(
|
||||
caller = claims.personalnummer,
|
||||
target = personalnummer,
|
||||
"get_account",
|
||||
);
|
||||
let account = state.get_account.execute(personalnummer).await?;
|
||||
Ok(Json(account))
|
||||
}
|
||||
108
crates/api/src/routes/cars.rs
Normal file
108
crates/api/src/routes/cars.rs
Normal file
@ -0,0 +1,108 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::routing::{get, patch};
|
||||
use holzleitner_application::dto::{
|
||||
CarResponse, CarsList, CreateCarRequest, UpdateCarRequest,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/me/cars", get(list_my_cars).post(create_my_car))
|
||||
.route("/me/cars/{car_id}", patch(update_my_car))
|
||||
}
|
||||
|
||||
/// Query-Parameter für `GET /me/cars`.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListCarsQuery {
|
||||
/// Default `false` — Endpoint liefert standardmäßig auch
|
||||
/// deaktivierte Fahrzeuge **nicht** mit.
|
||||
#[serde(default)]
|
||||
pub include_inactive: bool,
|
||||
}
|
||||
|
||||
/// Listet die Fahrzeuge des angemeldeten Fahrers.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/me/cars",
|
||||
tag = "cars",
|
||||
params(
|
||||
("includeInactive" = Option<bool>, Query, description = "Wenn true, werden inaktive Fahrzeuge mitgeliefert (default: false)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Fahrzeuge des Fahrers", body = CarsList),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_my_cars(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Query(query): Query<ListCarsQuery>,
|
||||
) -> Result<Json<CarsList>, ApiError> {
|
||||
let cars = state
|
||||
.list_my_cars
|
||||
.execute(claims.personalnummer, query.include_inactive)
|
||||
.await?;
|
||||
Ok(Json(CarsList { cars }))
|
||||
}
|
||||
|
||||
/// Legt ein neues Fahrzeug für den angemeldeten Fahrer an.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/me/cars",
|
||||
tag = "cars",
|
||||
request_body = CreateCarRequest,
|
||||
responses(
|
||||
(status = 200, description = "Fahrzeug angelegt", body = CarResponse),
|
||||
(status = 400, description = "Validierungsfehler (z. B. doppeltes Kennzeichen)"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_my_car(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Json(req): Json<CreateCarRequest>,
|
||||
) -> Result<Json<CarResponse>, ApiError> {
|
||||
let car = state
|
||||
.create_my_car
|
||||
.execute(claims.personalnummer, req)
|
||||
.await?;
|
||||
Ok(Json(CarResponse { car }))
|
||||
}
|
||||
|
||||
/// Aktualisiert ein Fahrzeug (Kennzeichen ändern / deaktivieren).
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/me/cars/{car_id}",
|
||||
tag = "cars",
|
||||
params(("car_id" = Uuid, Path)),
|
||||
request_body = UpdateCarRequest,
|
||||
responses(
|
||||
(status = 200, description = "Fahrzeug aktualisiert", body = CarResponse),
|
||||
(status = 400, description = "Validierungsfehler"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Fahrzeug nicht gefunden oder gehört nicht zu diesem Account")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_my_car(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(car_id): Path<Uuid>,
|
||||
Json(req): Json<UpdateCarRequest>,
|
||||
) -> Result<Json<CarResponse>, ApiError> {
|
||||
let car = state
|
||||
.update_my_car
|
||||
.execute(car_id, claims.personalnummer, req)
|
||||
.await?;
|
||||
Ok(Json(CarResponse { car }))
|
||||
}
|
||||
207
crates/api/src/routes/deliveries.rs
Normal file
207
crates/api/src/routes/deliveries.rs
Normal file
@ -0,0 +1,207 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{post, put};
|
||||
use holzleitner_application::dto::{
|
||||
AssignCarRequest, CancelDeliveryRequest, CreateDeliveryNoteRequest, DeliveryNoteResponse,
|
||||
DeliveryResponse, HoldDeliveryRequest,
|
||||
};
|
||||
use holzleitner_application::ports::DeliveryAction;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/deliveries/{delivery_id}/hold", post(hold))
|
||||
.route("/deliveries/{delivery_id}/resume", post(resume))
|
||||
.route("/deliveries/{delivery_id}/cancel", post(cancel))
|
||||
.route("/deliveries/{delivery_id}/complete", post(complete))
|
||||
.route("/deliveries/{delivery_id}/notes", post(create_note))
|
||||
.route("/deliveries/{delivery_id}/assigned-car", put(assign_car))
|
||||
}
|
||||
|
||||
/// Setzt die Lieferung auf `held`. Nur aus `active` zulässig.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/hold",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
request_body = HoldDeliveryRequest,
|
||||
responses(
|
||||
(status = 200, description = "Lieferung geholdet", body = DeliveryResponse),
|
||||
(status = 400, description = "Invalider Statusübergang oder leerer Reason"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn hold(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
Json(req): Json<HoldDeliveryRequest>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.hold");
|
||||
let delivery = state
|
||||
.apply_delivery_action
|
||||
.execute(delivery_id, DeliveryAction::Hold { reason: req.reason })
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
|
||||
/// Setzt die Lieferung zurück auf `active`. Nur aus `held` zulässig.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/resume",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
responses(
|
||||
(status = 200, description = "Lieferung wieder aktiv", body = DeliveryResponse),
|
||||
(status = 400, description = "Invalider Statusübergang"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn resume(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.resume");
|
||||
let delivery = state
|
||||
.apply_delivery_action
|
||||
.execute(delivery_id, DeliveryAction::Resume)
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
|
||||
/// Setzt die Lieferung auf `canceled` — endgültig. Erlaubt aus
|
||||
/// `active` und `held`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/cancel",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
request_body = CancelDeliveryRequest,
|
||||
responses(
|
||||
(status = 200, description = "Lieferung storniert", body = DeliveryResponse),
|
||||
(status = 400, description = "Invalider Statusübergang oder leerer Reason"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn cancel(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
Json(req): Json<CancelDeliveryRequest>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.cancel");
|
||||
let delivery = state
|
||||
.apply_delivery_action
|
||||
.execute(
|
||||
delivery_id,
|
||||
DeliveryAction::Cancel { reason: req.reason },
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
|
||||
/// Schließt die Lieferung ab — `state = completed`. Nur aus `active`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/complete",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
responses(
|
||||
(status = 200, description = "Lieferung abgeschlossen", body = DeliveryResponse),
|
||||
(status = 400, description = "Invalider Statusübergang"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn complete(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.complete");
|
||||
let delivery = state
|
||||
.apply_delivery_action
|
||||
.execute(delivery_id, DeliveryAction::Complete)
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
|
||||
/// Legt eine neue Notiz an einer Lieferung an. Mindestens eines von
|
||||
/// `text` und `imageAttachment` muss inhaltlich gefüllt sein
|
||||
/// (Leerstrings werden serverseitig getrimmt und als leer behandelt).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/deliveries/{delivery_id}/notes",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
request_body = CreateDeliveryNoteRequest,
|
||||
responses(
|
||||
(status = 200, description = "Notiz angelegt", body = DeliveryNoteResponse),
|
||||
(status = 400, description = "Notiz ohne Inhalt"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_note(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
Json(req): Json<CreateDeliveryNoteRequest>,
|
||||
) -> Result<Json<DeliveryNoteResponse>, ApiError> {
|
||||
tracing::info!(actor = claims.personalnummer, %delivery_id, "delivery.create_note");
|
||||
let note = state
|
||||
.create_delivery_note
|
||||
.execute(delivery_id, claims.personalnummer, req)
|
||||
.await?;
|
||||
Ok(Json(DeliveryNoteResponse { note }))
|
||||
}
|
||||
|
||||
/// Setzt das `assigned_car_id` einer Lieferung. `carId: null` löst
|
||||
/// die Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug
|
||||
/// zum angemeldeten Account gehört.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/deliveries/{delivery_id}/assigned-car",
|
||||
tag = "deliveries",
|
||||
params(("delivery_id" = Uuid, Path)),
|
||||
request_body = AssignCarRequest,
|
||||
responses(
|
||||
(status = 200, description = "Fahrzeug zugewiesen / entfernt", body = DeliveryResponse),
|
||||
(status = 400, description = "Fahrzeug gehört nicht zum Account"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Lieferung nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn assign_car(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(delivery_id): Path<Uuid>,
|
||||
Json(req): Json<AssignCarRequest>,
|
||||
) -> Result<Json<DeliveryResponse>, ApiError> {
|
||||
tracing::info!(
|
||||
actor = claims.personalnummer,
|
||||
%delivery_id,
|
||||
car_id = ?req.car_id,
|
||||
"delivery.assign_car",
|
||||
);
|
||||
let delivery = state
|
||||
.assign_car_to_delivery
|
||||
.execute(delivery_id, claims.personalnummer, req.car_id)
|
||||
.await?;
|
||||
Ok(Json(DeliveryResponse { delivery }))
|
||||
}
|
||||
23
crates/api/src/routes/health.rs
Normal file
23
crates/api/src/routes/health.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use axum::Router;
|
||||
use axum::routing::get;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/health", get(health))
|
||||
}
|
||||
|
||||
/// Health-Endpoint für Load-Balancer und Container-Probes. Bewusst
|
||||
/// kein Auth — eine `200 ok`-Antwort darf nicht von der Auth abhängen.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
tag = "health",
|
||||
responses(
|
||||
(status = 200, description = "Service ist erreichbar", body = String)
|
||||
),
|
||||
security()
|
||||
)]
|
||||
pub async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
9
crates/api/src/routes/mod.rs
Normal file
9
crates/api/src/routes/mod.rs
Normal file
@ -0,0 +1,9 @@
|
||||
//! HTTP-Routen, gruppiert nach Domäne. Wird vom `main.rs`-Router
|
||||
//! zusammengesetzt.
|
||||
|
||||
pub mod accounts;
|
||||
pub mod cars;
|
||||
pub mod deliveries;
|
||||
pub mod health;
|
||||
pub mod scans;
|
||||
pub mod tours;
|
||||
49
crates/api/src/routes/scans.rs
Normal file
49
crates/api/src/routes/scans.rs
Normal file
@ -0,0 +1,49 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::routing::post;
|
||||
use holzleitner_application::dto::{ApplyScansRequest, ApplyScansResponse};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/scans", post(apply_scans))
|
||||
}
|
||||
|
||||
/// Wendet eine Liste von Scan-Events idempotent an.
|
||||
///
|
||||
/// Pro Event ein eigenes Resultat. Status `applied` schreibt einen
|
||||
/// frischen Audit-Eintrag, `duplicate` liefert den aktuellen Stand am
|
||||
/// Server, `rejected` enthält die Begründung. Reihenfolge der `results`
|
||||
/// entspricht der Reihenfolge der `scans` im Request.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/scans",
|
||||
tag = "scans",
|
||||
request_body = ApplyScansRequest,
|
||||
responses(
|
||||
(status = 200, description = "Bulk-Ergebnis pro Event", body = ApplyScansResponse),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn apply_scans(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Json(request): Json<ApplyScansRequest>,
|
||||
) -> Result<Json<ApplyScansResponse>, ApiError> {
|
||||
tracing::info!(
|
||||
actor = claims.personalnummer,
|
||||
count = request.scans.len(),
|
||||
"apply_scans",
|
||||
);
|
||||
let response = state
|
||||
.apply_scans
|
||||
.execute(request, claims.personalnummer)
|
||||
.await?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
158
crates/api/src/routes/tours.rs
Normal file
158
crates/api/src/routes/tours.rs
Normal file
@ -0,0 +1,158 @@
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{get, post, put};
|
||||
use holzleitner_application::dto::{
|
||||
SetDeliveryOrderRequest, SetDeliveryOrderResponse, SyncTourRequest, TourDetails, TourSummary,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/me/tours/today", get(list_my_tours_today))
|
||||
.route("/tours/{tour_id}", get(get_tour))
|
||||
.route("/tours/{tour_id}/delivery-order", put(set_delivery_order))
|
||||
.route("/sync/tour", post(sync_tour))
|
||||
}
|
||||
|
||||
/// Antwort-Hülle für `GET /me/tours/today`. Eigenes Struct, weil
|
||||
/// utoipa für `Vec<T>` als Top-Level-Response keinen sauberen
|
||||
/// Schemanamen vergibt — und ein Wrapper macht die Erweiterbarkeit
|
||||
/// (z. B. Paginierung in Zukunft) zur Nicht-Breaking-Change.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TourSummaryList {
|
||||
pub tours: Vec<TourSummary>,
|
||||
}
|
||||
|
||||
/// Antwort-Hülle für `POST /sync/tour`.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncTourResponse {
|
||||
pub tour_id: Uuid,
|
||||
}
|
||||
|
||||
/// Listet heutige Touren des angemeldeten Fahrers (Filter aus dem JWT).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/me/tours/today",
|
||||
tag = "tours",
|
||||
responses(
|
||||
(status = 200, description = "Liste der heutigen Touren", body = TourSummaryList),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn list_my_tours_today(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
) -> Result<Json<TourSummaryList>, ApiError> {
|
||||
tracing::debug!(personalnummer = claims.personalnummer, "list_my_tours_today");
|
||||
let tours = state
|
||||
.list_my_tours_today
|
||||
.execute(claims.personalnummer)
|
||||
.await?;
|
||||
Ok(Json(TourSummaryList { tours }))
|
||||
}
|
||||
|
||||
/// Lädt eine Tour mit allen Lieferungen, Positionen und referenzierten
|
||||
/// Stammdaten — die App nutzt das als einzigen großen Read.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/tours/{tour_id}",
|
||||
tag = "tours",
|
||||
params(
|
||||
("tour_id" = Uuid, Path, description = "Eindeutige Tour-Id (UUID)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Tour-Aggregat gefunden", body = TourDetails),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Keine Tour mit dieser Id")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_tour(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(tour_id): Path<Uuid>,
|
||||
) -> Result<Json<TourDetails>, ApiError> {
|
||||
tracing::debug!(personalnummer = claims.personalnummer, %tour_id, "get_tour");
|
||||
let details = state.get_tour.execute(tour_id).await?;
|
||||
Ok(Json(details))
|
||||
}
|
||||
|
||||
/// Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour neu.
|
||||
/// Der Client schickt die **vollständige** neue Reihenfolge; fehlende
|
||||
/// oder fremde Lieferungs-Ids werden mit `400 validation` abgelehnt.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/tours/{tour_id}/delivery-order",
|
||||
tag = "tours",
|
||||
params(("tour_id" = Uuid, Path)),
|
||||
request_body = SetDeliveryOrderRequest,
|
||||
responses(
|
||||
(status = 200, description = "Neue Reihenfolge gespeichert", body = SetDeliveryOrderResponse),
|
||||
(status = 400, description = "Mengen-Mismatch oder Duplikate"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen"),
|
||||
(status = 404, description = "Tour nicht gefunden")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn set_delivery_order(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Path(tour_id): Path<Uuid>,
|
||||
Json(request): Json<SetDeliveryOrderRequest>,
|
||||
) -> Result<Json<SetDeliveryOrderResponse>, ApiError> {
|
||||
tracing::info!(
|
||||
actor = claims.personalnummer,
|
||||
%tour_id,
|
||||
count = request.delivery_ids.len(),
|
||||
"set_delivery_order",
|
||||
);
|
||||
let response = state.set_delivery_order.execute(tour_id, request).await?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// Sync-Endpoint für das ERP: legt eine Tagestour samt Lieferungen und
|
||||
/// Positionen idempotent an. Identität pro Tour
|
||||
/// `(driver_personalnummer, tour_date)`, pro Lieferung
|
||||
/// `(belegart_id, belegnummer)`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/sync/tour",
|
||||
tag = "sync",
|
||||
request_body = SyncTourRequest,
|
||||
responses(
|
||||
(status = 200, description = "Tour gespeichert", body = SyncTourResponse),
|
||||
(status = 400, description = "Validierungsfehler im Sync-Payload"),
|
||||
(status = 401, description = "Authentifizierung fehlgeschlagen")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn sync_tour(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(claims): AuthenticatedUser,
|
||||
Json(request): Json<SyncTourRequest>,
|
||||
) -> Result<Json<SyncTourResponse>, ApiError> {
|
||||
tracing::info!(
|
||||
caller = claims.personalnummer,
|
||||
driver = request.driver_personalnummer,
|
||||
date = %request.tour_date,
|
||||
deliveries = request.deliveries.len(),
|
||||
"sync_tour",
|
||||
);
|
||||
let tour_id = state.sync_tour.execute(request).await?;
|
||||
Ok(Json(SyncTourResponse { tour_id }))
|
||||
}
|
||||
Reference in New Issue
Block a user