Skip to main content

backbone_maintenance/application/service/
maintenance_write_service.rs

1//! The hand-authored maintenance write path (user-owned; survives regen).
2//!
3//! A maintenance visit: plan it against an asset (preventive from a schedule, or corrective ad-hoc), add
4//! parts, then complete it — issue the parts out of inventory (valued at inventory's moving-average),
5//! roll parts + labor into the visit cost, and post ONE balanced maintenance-cost journal — the **9th GL
6//! producer**: `Dr Maintenance Expense (total) · Cr Inventory Parts (parts) · Cr Labor Payable (labor)`.
7//! Because `total = parts + labor`, it balances. Idempotent per visit. Money is IDR, 2dp, half-away.
8//!
9//! Tenancy (ADR-0029): the module is tenant-agnostic. Writes bind the ambient org scope (set per
10//! request by the composing service) — never a module-side tenant value; pool reads ride the
11//! caller-scoped helpers. Cross-module payload fields that still carry a company id are legacy twins,
12//! filled from the ambient scope's company echo, never keyed on by a statement here.
13
14use backbone_orm::org_scope;
15use chrono::{NaiveDate, Utc};
16use rust_decimal::{Decimal, RoundingStrategy};
17use sqlx::PgPool;
18use uuid::Uuid;
19
20use crate::infrastructure::persistence::{
21    MaintenanceScheduleRepository, MaintenanceVisitPartRepository, MaintenanceVisitRepository,
22    NewScheduleRow, NewVisitRow, VisitCompletion,
23};
24
25use super::maintenance_events::*;
26use super::maintenance_gl::*;
27use super::maintenance_ports::*;
28
29/// The ambient org scope's legacy company echo — the acting org unit, which the spine mirrored
30/// from the historical company id verbatim. Cross-module wire fields that still carry a tenant
31/// (the GL envelope, the inventory issue, the outbox fence, the event payloads) are filled from
32/// it; nothing in this module keys a statement on it. `Uuid::nil()` when no scope is bound
33/// (undecorated module tests, jobs).
34pub(crate) fn legacy_company_echo() -> Uuid {
35    org_scope::current_org_scope()
36        .and_then(|s| s.legacy_company_id())
37        .unwrap_or(Uuid::nil())
38}
39
40/// Re-bind the caller's ambient org scope onto a transaction this service opened itself — the
41/// scope is task-local and a fresh pool transaction carries none of it. With no ambient scope
42/// (standalone deployment, jobs) the transaction stays plain: the module is tenant-agnostic and
43/// the composed decorator owns isolation.
44pub(crate) async fn relay_ambient_scope(conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
45    if let Some(scope) = org_scope::current_org_scope() {
46        org_scope::bind_org_scope_on(conn, &scope).await?;
47    }
48    Ok(())
49}
50
51fn money(v: Decimal) -> Decimal {
52    v.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
53}
54
55#[derive(Debug, thiserror::Error)]
56pub enum MaintenanceError {
57    #[error("db: {0}")]
58    Db(#[from] sqlx::Error),
59    #[error("not found: {0}")]
60    NotFound(&'static str),
61    #[error("invalid state: {0}")]
62    InvalidState(&'static str),
63    #[error("invalid input: {0}")]
64    Invalid(String),
65    #[error("unbalanced posting")]
66    Unbalanced,
67    #[error("gl rejected: {0}")]
68    GlRejected(String),
69    #[error("inventory rejected: {0}")]
70    InventoryRejected(String),
71}
72
73pub struct NewSchedule {
74    pub asset_id: Uuid,
75    pub name: String,
76    pub interval_days: i32,
77    pub next_due_date: NaiveDate,
78}
79
80pub struct NewVisit {
81    pub asset_id: Uuid,
82    pub schedule_id: Option<Uuid>,
83    pub maintenance_type: String, // preventive | corrective
84    pub scheduled_date: NaiveDate,
85    pub warehouse_id: Option<Uuid>,
86    pub warranty_claim_id: Option<Uuid>,
87    pub labor_cost: Decimal,
88    pub maintenance_expense_account_id: Uuid,
89    pub parts_inventory_account_id: Uuid,
90    pub labor_payable_account_id: Uuid,
91}
92
93#[derive(Debug, Clone, PartialEq)]
94pub struct CompleteOutcome {
95    pub visit_id: Uuid,
96    pub journal_id: Option<Uuid>,
97    pub total_cost: Decimal,
98    pub already: bool,
99}
100
101pub struct MaintenanceWriteService {
102    pool: PgPool,
103    schedules: MaintenanceScheduleRepository,
104    visits: MaintenanceVisitRepository,
105    parts: MaintenanceVisitPartRepository,
106}
107
108impl MaintenanceWriteService {
109    pub fn new(pool: PgPool) -> Self {
110        let schedules = MaintenanceScheduleRepository::new(pool.clone());
111        let visits = MaintenanceVisitRepository::new(pool.clone());
112        let parts = MaintenanceVisitPartRepository::new(pool.clone());
113        Self { pool, schedules, visits, parts }
114    }
115
116    /// Define a preventive maintenance schedule for an asset.
117    pub async fn create_schedule(&self, s: NewSchedule) -> Result<Uuid, MaintenanceError> {
118        if s.interval_days <= 0 {
119            return Err(MaintenanceError::Invalid("interval_days must be positive".into()));
120        }
121        let id = Uuid::new_v4();
122        self.schedules.insert_schedule(&self.pool, &NewScheduleRow {
123            id,
124            asset_id: s.asset_id,
125            name: &s.name,
126            interval_days: s.interval_days,
127            next_due_date: s.next_due_date,
128        }).await?;
129        Ok(id)
130    }
131
132    /// Plan a visit (planned). Preventive visits reference a schedule; corrective ones don't.
133    pub async fn plan_visit(&self, v: NewVisit) -> Result<Uuid, MaintenanceError> {
134        if v.labor_cost < Decimal::ZERO {
135            return Err(MaintenanceError::Invalid("labor_cost must be non-negative".into()));
136        }
137        let id = Uuid::new_v4();
138        self.visits.insert_visit(&self.pool, &NewVisitRow {
139            id,
140            asset_id: v.asset_id,
141            schedule_id: v.schedule_id,
142            maintenance_type: &v.maintenance_type,
143            warehouse_id: v.warehouse_id,
144            warranty_claim_id: v.warranty_claim_id,
145            scheduled_date: v.scheduled_date,
146            labor_cost: money(v.labor_cost),
147            maintenance_expense_account_id: v.maintenance_expense_account_id,
148            parts_inventory_account_id: v.parts_inventory_account_id,
149            labor_payable_account_id: v.labor_payable_account_id,
150        }).await?;
151        Ok(id)
152    }
153
154    /// Add a part line to a planned/in-progress visit (quantity only; valued on completion).
155    pub async fn add_part(&self, visit_id: Uuid, item_id: Uuid, quantity: Decimal) -> Result<Uuid, MaintenanceError> {
156        if quantity <= Decimal::ZERO {
157            return Err(MaintenanceError::Invalid("part quantity must be positive".into()));
158        }
159        let v = self.visits.fetch_for_completion(&self.pool, visit_id).await?
160            .ok_or(MaintenanceError::NotFound("visit"))?;
161        // Parts may only be added while PLANNED. Once a visit is in_progress the set is FROZEN — that is the
162        // state a crashed completion leaves behind, and a part added then would be replayed against the
163        // line-agnostic idempotent inventory ack: physically consumed but never issued/costed/journaled
164        // (maturity council 2026-07-10). So the line set can never widen after completion begins.
165        match v.status.as_str() {
166            "planned" => {}
167            _ => return Err(MaintenanceError::InvalidState("visit is not planned — the part set is frozen")),
168        }
169        // The line belongs to its visit; under the composed shape the decorator's fill stamps the
170        // acting unit on the INSERT (the fence intent ADR-0010 defined, carried by ADR-0029's
171        // decorator). The visit-status check above already fences the line to a visit the caller
172        // may see.
173        let id = Uuid::new_v4();
174        self.parts.insert_part(&self.pool, id, visit_id, item_id, quantity).await?;
175        Ok(id)
176    }
177
178    /// Start a planned visit (planned → in_progress).
179    pub async fn start_visit(&self, visit_id: Uuid) -> Result<(), MaintenanceError> {
180        let n = self.visits.claim_in_progress(&self.pool, visit_id).await?;
181        if n != 1 {
182            return Err(MaintenanceError::InvalidState("visit is not planned"));
183        }
184        Ok(())
185    }
186
187    /// Complete a visit: issue its parts (valued by inventory), roll up the cost, post ONE balanced
188    /// maintenance-cost journal, and gate `→ completed`. Posts at most once (idempotent on `source_id =
189    /// visit_id`). A zero-cost visit completes without a posting. Emits `MaintenanceCompleted`.
190    pub async fn complete_visit(
191        &self,
192        visit_id: Uuid,
193        performed_date: NaiveDate,
194        inventory: &dyn InventoryPort,
195        sink: &dyn GlPostSink,
196        events: &dyn MaintenanceEventSink,
197    ) -> Result<CompleteOutcome, MaintenanceError> {
198        let v = self.visits.fetch_for_completion(&self.pool, visit_id).await?
199            .ok_or(MaintenanceError::NotFound("visit"))?;
200        if v.status == "completed" {
201            return Ok(CompleteOutcome {
202                visit_id, journal_id: v.journal_id, total_cost: v.total_cost, already: true,
203            });
204        }
205        if v.status != "planned" && v.status != "in_progress" {
206            return Err(MaintenanceError::InvalidState("visit is not open"));
207        }
208        // The legacy company echo for the cross-module payloads (the inventory issue, the GL
209        // envelope, the outbox fence, the event payloads) — the ambient scope's own echo, never a
210        // module-side tenant value.
211        let legacy_company = legacy_company_echo();
212        // FREEZE the part set before any external effect: claim the visit to in_progress. `add_part` only
213        // accepts a planned visit, so once claimed no line can be added — a crash-and-retry re-issues the
214        // identical frozen set (maturity council 2026-07-10).
215        self.visits.claim_in_progress(&self.pool, visit_id).await?;
216
217        let asset_id = v.asset_id;
218        let labor_cost = v.labor_cost;
219        let schedule_id = v.schedule_id;
220        let maintenance_type = v.maintenance_type.clone();
221
222        // Issue the parts out of inventory (valued at moving-average). Idempotent per visit.
223        let part_rows = self.parts.list_for_visit(&self.pool, visit_id).await?;
224        let mut parts_cost = Decimal::ZERO;
225        if !part_rows.is_empty() {
226            let warehouse_id: Uuid = v.warehouse_id
227                .ok_or(MaintenanceError::Invalid("a visit with parts needs a warehouse".into()))?;
228            let lines: Vec<IssueLine> = part_rows.iter().map(|r| IssueLine {
229                item_id: r.item_id, quantity: r.quantity,
230            }).collect();
231            let ack = inventory.issue_parts(&PartsIssue {
232                company_id: legacy_company, visit_id, warehouse_id, idempotency_key: format!("maintenance:{visit_id}"), lines,
233            }).await.map_err(|e| MaintenanceError::InventoryRejected(e.code))?;
234            parts_cost = money(ack.total_value);
235            // Write back the valued unit_cost/amount per line.
236            for lv in &ack.lines {
237                self.parts.set_valuation(
238                    &self.pool, visit_id, lv.item_id, money(lv.rate), money(lv.value),
239                ).await?;
240            }
241        }
242        let total_cost = labor_cost + parts_cost;
243
244        // Post the balanced cost journal (skip a zero-cost visit).
245        let mut journal_id: Option<Uuid> = None;
246        let mut post_id: Option<Uuid> = None;
247        if total_cost > Decimal::ZERO {
248            let expense: Uuid = v.maintenance_expense_account_id
249                .ok_or(MaintenanceError::Invalid("no maintenance expense account".into()))?;
250            let parts_acct: Uuid = v.parts_inventory_account_id
251                .ok_or(MaintenanceError::Invalid("no parts inventory account".into()))?;
252            let labor_acct: Uuid = v.labor_payable_account_id
253                .ok_or(MaintenanceError::Invalid("no labor payable account".into()))?;
254            let mut lines = vec![GlPostLine::debit(expense, total_cost).with_description("Maintenance expense")];
255            if parts_cost > Decimal::ZERO {
256                lines.push(GlPostLine::credit(parts_acct, parts_cost).with_description("Parts issued"));
257            }
258            if labor_cost > Decimal::ZERO {
259                lines.push(GlPostLine::credit(labor_acct, labor_cost).with_description("Maintenance labor"));
260            }
261            let env = AccountingPostEnvelope {
262                idempotency_key: format!("maintenance:{visit_id}"),
263                company_id: legacy_company, branch_id: None, source_type: "maintenance".into(), source_id: visit_id,
264                source_reference: None, posting_date: performed_date, currency: "IDR".into(),
265                posting_type: "original".into(), description: Some("Maintenance cost".into()), lines,
266            };
267            if !env.is_balanced() {
268                return Err(MaintenanceError::Unbalanced);
269            }
270            let ack = sink.post(&env).await.map_err(|r| MaintenanceError::GlRejected(r.code))?;
271            journal_id = Some(ack.journal_id);
272            post_id = Some(ack.post_id);
273        }
274
275        let mut tx = self.pool.begin().await?;
276        // The ambient org scope — the completion tx writes visits/schedules/outbox behind the
277        // composed decorator's fence (ADR-0029). Undecorated, the tx stays plain.
278        relay_ambient_scope(&mut tx).await?;
279        let moved = self.visits.complete(&mut tx, visit_id, &VisitCompletion {
280            performed_date, parts_cost, total_cost, journal_id, accounting_post_id: post_id,
281        }).await?;
282        if moved != 1 {
283            tx.rollback().await?;
284            // Raced — re-read the winner.
285            let j = self.visits.fetch_journal_id(&self.pool, visit_id).await?;
286            return Ok(CompleteOutcome { visit_id, journal_id: j, total_cost, already: true });
287        }
288
289        // Advance the PREVENTIVE schedule so the next visit becomes due at performed + interval — the
290        // recurrence that makes a preventive plan preventive. Inline in the completion tx, so it runs at
291        // most once (gated by the visit CAS above) (completeness council 2026-07-10).
292        if maintenance_type == "preventive" {
293            if let Some(sid) = schedule_id {
294                self.schedules.advance_next_due(&mut tx, sid, performed_date).await?;
295            }
296        }
297
298        let event = MaintenanceEvent::MaintenanceCompleted(MaintenanceCompleted {
299            visit_id, company_id: legacy_company, asset_id, journal_id, labor_cost, parts_cost, total_cost,
300        });
301        let record = backbone_outbox::OutboxRecord::new(
302            "MaintenanceCompleted", "MaintenanceVisit", visit_id.to_string(), legacy_company,
303            serde_json::to_value(&event).map_err(|e| MaintenanceError::Invalid(e.to_string()))?,
304            Utc::now(),
305        );
306        backbone_outbox::outbox::stage(&mut *tx, "maintenance", &record)
307            .await.map_err(|e| MaintenanceError::Invalid(format!("outbox stage: {e}")))?;
308        tx.commit().await?;
309        events.publish(&event);
310
311        Ok(CompleteOutcome { visit_id, journal_id, total_cost, already: false })
312    }
313
314    /// Cancel a planned/in-progress visit.
315    pub async fn cancel_visit(&self, visit_id: Uuid) -> Result<(), MaintenanceError> {
316        let n = self.visits.cancel(&self.pool, visit_id).await?;
317        if n != 1 {
318            return Err(MaintenanceError::InvalidState("visit is not open"));
319        }
320        Ok(())
321    }
322}