Skip to main content

backbone_maintenance/presentation/http/
maintenance_lifecycle_handler.rs

1//! The validated maintenance lifecycle write surface (hand-authored, user-owned).
2//!
3//! These five verbs are the ONLY way a maintenance visit's state may change — generic CRUD on the
4//! visit/part tables is read-only by default (see [`crate::MaintenanceModule::read_only_routes`]).
5//! Each handler delegates to [`MaintenanceWriteService`]; on completion the engine issues parts
6//! through the injected [`InventoryPort`], posts the balanced cost journal through the [`GlPostSink`],
7//! and publishes `MaintenanceCompleted` through the [`MaintenanceEventSink`] — so inventory, the
8//! books, and the GL can never diverge. Completion is idempotent per visit.
9//!
10//! Mounted via [`crate::MaintenanceModule::lifecycle_routes`]; the production surface composes as
11//! `module.read_only_routes().merge(module.lifecycle_routes())` (reads + validated writes). Do NOT
12//! merge with [`crate::MaintenanceModule::all_crud_routes`] — both mount `POST /maintenance_visits`.
13//!
14//! NOTE: tenancy rides the ambient org scope (ADR-0029) — the composing service binds the acting
15//! org unit per request and the write path scopes every verb through the caller-scoped helpers.
16//! No verb reads a tenant value from the request body.
17
18use std::sync::Arc;
19
20use axum::{
21    extract::{Path, State},
22    http::StatusCode,
23    response::IntoResponse,
24    routing::post,
25    Json, Router,
26};
27use chrono::NaiveDate;
28use rust_decimal::Decimal;
29use serde::Deserialize;
30use serde_json::{json, Value};
31use uuid::Uuid;
32
33use crate::application::service::{
34    GlPostSink, InventoryPort, MaintenanceError as WriteError, MaintenanceEventSink,
35    MaintenanceWriteService, NewVisit,
36};
37
38/// Shared state for the lifecycle routes: the write service plus the three ports the completion verb
39/// issues/posts/publishes through. `Clone` (everything is behind an `Arc`), so axum can hand a copy to
40/// each request. Only `complete_visit` touches the ports; the other four verbs self-scope from the
41/// visit's own company and need none — but the ports are required to mount the router so a missing
42/// adapter fails at startup, not on the first completion.
43#[derive(Clone)]
44pub struct MaintenanceLifecycleState {
45    pub write_svc: Arc<MaintenanceWriteService>,
46    pub inventory: Arc<dyn InventoryPort>,
47    pub gl: Arc<dyn GlPostSink>,
48    pub event_sink: Arc<dyn MaintenanceEventSink>,
49}
50
51/// Local wrapper around the engine `MaintenanceError` so we can impl axum's `IntoResponse` (foreign
52/// trait + foreign type → orphan rule). Maps each domain failure to a stable HTTP code + contract
53/// error string.
54pub struct MaintenanceApiError(pub WriteError);
55
56impl IntoResponse for MaintenanceApiError {
57    fn into_response(self) -> axum::response::Response {
58        let msg = self.0.to_string();
59        let (status, code) = match self.0 {
60            WriteError::NotFound(_) => (StatusCode::NOT_FOUND, "MAINTENANCE_NOT_FOUND"),
61            WriteError::InvalidState(_) => (StatusCode::CONFLICT, "MAINTENANCE_INVALID_STATE"),
62            WriteError::Invalid(_) => (StatusCode::BAD_REQUEST, "MAINTENANCE_INVALID_INPUT"),
63            // The engine's balanced-post guard failed, or the GL/inventory rejected the posting —
64            // the visit did not move (completion is atomic + idempotent).
65            WriteError::Unbalanced
66            | WriteError::GlRejected(_)
67            | WriteError::InventoryRejected(_) => {
68                (StatusCode::FAILED_DEPENDENCY, "MAINTENANCE_POST_REJECTED")
69            }
70            WriteError::Db(_) => (StatusCode::INTERNAL_SERVER_ERROR, "MAINTENANCE_DATABASE_ERROR"),
71        };
72        (status, Json(json!({ "success": false, "error": code, "message": msg }))).into_response()
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Request bodies
78// ---------------------------------------------------------------------------
79
80/// Plan a new visit.
81#[derive(Debug, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct PlanVisitRequest {
84    pub asset_id: Uuid,
85    #[serde(default)]
86    pub schedule_id: Option<Uuid>,
87    /// `preventive` (references a schedule) | `corrective` (ad-hoc).
88    pub maintenance_type: String,
89    pub scheduled_date: NaiveDate,
90    #[serde(default)]
91    pub warehouse_id: Option<Uuid>,
92    #[serde(default)]
93    pub warranty_claim_id: Option<Uuid>,
94    #[serde(default)]
95    pub labor_cost: Decimal,
96    pub maintenance_expense_account_id: Uuid,
97    pub parts_inventory_account_id: Uuid,
98    pub labor_payable_account_id: Uuid,
99}
100
101#[derive(Debug, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct AddPartRequest {
104    pub item_id: Uuid,
105    pub quantity: Decimal,
106}
107
108#[derive(Debug, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct CompleteVisitRequest {
111    pub performed_date: NaiveDate,
112}
113
114// ---------------------------------------------------------------------------
115// Handlers
116// ---------------------------------------------------------------------------
117
118/// Plan a maintenance visit (status = planned). Preventive visits reference a schedule; corrective don't.
119pub async fn plan_visit(
120    State(st): State<MaintenanceLifecycleState>,
121    Json(req): Json<PlanVisitRequest>,
122) -> Result<(StatusCode, Json<Value>), MaintenanceApiError> {
123    let id = st
124        .write_svc
125        .plan_visit(NewVisit {
126            asset_id: req.asset_id,
127            schedule_id: req.schedule_id,
128            maintenance_type: req.maintenance_type,
129            scheduled_date: req.scheduled_date,
130            warehouse_id: req.warehouse_id,
131            warranty_claim_id: req.warranty_claim_id,
132            labor_cost: req.labor_cost,
133            maintenance_expense_account_id: req.maintenance_expense_account_id,
134            parts_inventory_account_id: req.parts_inventory_account_id,
135            labor_payable_account_id: req.labor_payable_account_id,
136        })
137        .await
138        .map_err(MaintenanceApiError)?;
139    Ok((
140        StatusCode::CREATED,
141        Json(json!({ "success": true, "data": { "id": id } })),
142    ))
143}
144
145/// Start a planned visit (planned → in_progress).
146pub async fn start_visit(
147    State(st): State<MaintenanceLifecycleState>,
148    Path(id): Path<Uuid>,
149) -> Result<(StatusCode, Json<Value>), MaintenanceApiError> {
150    st.write_svc.start_visit(id).await.map_err(MaintenanceApiError)?;
151    Ok((StatusCode::OK, Json(json!({ "success": true }))))
152}
153
154/// Add a part line to a planned visit (the part set freezes once the visit is in_progress).
155pub async fn add_part(
156    State(st): State<MaintenanceLifecycleState>,
157    Path(id): Path<Uuid>,
158    Json(req): Json<AddPartRequest>,
159) -> Result<(StatusCode, Json<Value>), MaintenanceApiError> {
160    let part_id = st
161        .write_svc
162        .add_part(id, req.item_id, req.quantity)
163        .await
164        .map_err(MaintenanceApiError)?;
165    Ok((
166        StatusCode::CREATED,
167        Json(json!({ "success": true, "data": { "id": part_id } })),
168    ))
169}
170
171/// Complete a visit: issue its parts (valued by inventory), roll up the cost, post ONE balanced
172/// maintenance-cost journal, advance a preventive schedule, and emit `MaintenanceCompleted`.
173/// Idempotent per visit — re-completion returns the original outcome with `already: true`.
174pub async fn complete_visit(
175    State(st): State<MaintenanceLifecycleState>,
176    Path(id): Path<Uuid>,
177    Json(req): Json<CompleteVisitRequest>,
178) -> Result<(StatusCode, Json<Value>), MaintenanceApiError> {
179    let outcome = st
180        .write_svc
181        .complete_visit(
182            id,
183            req.performed_date,
184            st.inventory.as_ref(),
185            st.gl.as_ref(),
186            st.event_sink.as_ref(),
187        )
188        .await
189        .map_err(MaintenanceApiError)?;
190    Ok((
191        StatusCode::OK,
192        Json(json!({
193            "success": true,
194            "data": {
195                "visit_id": outcome.visit_id,
196                "journal_id": outcome.journal_id,
197                "total_cost": outcome.total_cost,
198                "already": outcome.already,
199            }
200        })),
201    ))
202}
203
204/// Cancel a planned/in_progress visit.
205pub async fn cancel_visit(
206    State(st): State<MaintenanceLifecycleState>,
207    Path(id): Path<Uuid>,
208) -> Result<(StatusCode, Json<Value>), MaintenanceApiError> {
209    st.write_svc.cancel_visit(id).await.map_err(MaintenanceApiError)?;
210    Ok((StatusCode::OK, Json(json!({ "success": true }))))
211}
212
213/// Mount the five lifecycle verbs. Returns a stateless `Router<()>` ready to merge with
214/// [`crate::MaintenanceModule::read_only_routes`].
215pub fn create_maintenance_lifecycle_routes(state: MaintenanceLifecycleState) -> Router {
216    Router::<MaintenanceLifecycleState>::new()
217        .route("/maintenance_visits", post(plan_visit))
218        .route("/maintenance_visits/:id/start", post(start_visit))
219        .route("/maintenance_visits/:id/parts", post(add_part))
220        .route("/maintenance_visits/:id/complete", post(complete_visit))
221        .route("/maintenance_visits/:id/cancel", post(cancel_visit))
222        .with_state(state)
223}