backbone_maintenance/application/service/
maintenance_write_service.rs1use 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
29pub(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
40pub(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, 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 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 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 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 match v.status.as_str() {
166 "planned" => {}
167 _ => return Err(MaintenanceError::InvalidState("visit is not planned — the part set is frozen")),
168 }
169 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 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 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 let legacy_company = legacy_company_echo();
212 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 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 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 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 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 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 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 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}