use backbone_orm::org_scope;
use chrono::{NaiveDate, Utc};
use rust_decimal::{Decimal, RoundingStrategy};
use sqlx::PgPool;
use uuid::Uuid;
use crate::infrastructure::persistence::{
MaintenanceScheduleRepository, MaintenanceVisitPartRepository, MaintenanceVisitRepository,
NewScheduleRow, NewVisitRow, VisitCompletion,
};
use super::maintenance_events::*;
use super::maintenance_gl::*;
use super::maintenance_ports::*;
pub(crate) fn legacy_company_echo() -> Uuid {
org_scope::current_org_scope()
.and_then(|s| s.legacy_company_id())
.unwrap_or(Uuid::nil())
}
pub(crate) async fn relay_ambient_scope(conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
if let Some(scope) = org_scope::current_org_scope() {
org_scope::bind_org_scope_on(conn, &scope).await?;
}
Ok(())
}
fn money(v: Decimal) -> Decimal {
v.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
}
#[derive(Debug, thiserror::Error)]
pub enum MaintenanceError {
#[error("db: {0}")]
Db(#[from] sqlx::Error),
#[error("not found: {0}")]
NotFound(&'static str),
#[error("invalid state: {0}")]
InvalidState(&'static str),
#[error("invalid input: {0}")]
Invalid(String),
#[error("unbalanced posting")]
Unbalanced,
#[error("gl rejected: {0}")]
GlRejected(String),
#[error("inventory rejected: {0}")]
InventoryRejected(String),
}
pub struct NewSchedule {
pub asset_id: Uuid,
pub name: String,
pub interval_days: i32,
pub next_due_date: NaiveDate,
}
pub struct NewVisit {
pub asset_id: Uuid,
pub schedule_id: Option<Uuid>,
pub maintenance_type: String, pub scheduled_date: NaiveDate,
pub warehouse_id: Option<Uuid>,
pub warranty_claim_id: Option<Uuid>,
pub labor_cost: Decimal,
pub maintenance_expense_account_id: Uuid,
pub parts_inventory_account_id: Uuid,
pub labor_payable_account_id: Uuid,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CompleteOutcome {
pub visit_id: Uuid,
pub journal_id: Option<Uuid>,
pub total_cost: Decimal,
pub already: bool,
}
pub struct MaintenanceWriteService {
pool: PgPool,
schedules: MaintenanceScheduleRepository,
visits: MaintenanceVisitRepository,
parts: MaintenanceVisitPartRepository,
}
impl MaintenanceWriteService {
pub fn new(pool: PgPool) -> Self {
let schedules = MaintenanceScheduleRepository::new(pool.clone());
let visits = MaintenanceVisitRepository::new(pool.clone());
let parts = MaintenanceVisitPartRepository::new(pool.clone());
Self { pool, schedules, visits, parts }
}
pub async fn create_schedule(&self, s: NewSchedule) -> Result<Uuid, MaintenanceError> {
if s.interval_days <= 0 {
return Err(MaintenanceError::Invalid("interval_days must be positive".into()));
}
let id = Uuid::new_v4();
self.schedules.insert_schedule(&self.pool, &NewScheduleRow {
id,
asset_id: s.asset_id,
name: &s.name,
interval_days: s.interval_days,
next_due_date: s.next_due_date,
}).await?;
Ok(id)
}
pub async fn plan_visit(&self, v: NewVisit) -> Result<Uuid, MaintenanceError> {
if v.labor_cost < Decimal::ZERO {
return Err(MaintenanceError::Invalid("labor_cost must be non-negative".into()));
}
let id = Uuid::new_v4();
self.visits.insert_visit(&self.pool, &NewVisitRow {
id,
asset_id: v.asset_id,
schedule_id: v.schedule_id,
maintenance_type: &v.maintenance_type,
warehouse_id: v.warehouse_id,
warranty_claim_id: v.warranty_claim_id,
scheduled_date: v.scheduled_date,
labor_cost: money(v.labor_cost),
maintenance_expense_account_id: v.maintenance_expense_account_id,
parts_inventory_account_id: v.parts_inventory_account_id,
labor_payable_account_id: v.labor_payable_account_id,
}).await?;
Ok(id)
}
pub async fn add_part(&self, visit_id: Uuid, item_id: Uuid, quantity: Decimal) -> Result<Uuid, MaintenanceError> {
if quantity <= Decimal::ZERO {
return Err(MaintenanceError::Invalid("part quantity must be positive".into()));
}
let v = self.visits.fetch_for_completion(&self.pool, visit_id).await?
.ok_or(MaintenanceError::NotFound("visit"))?;
match v.status.as_str() {
"planned" => {}
_ => return Err(MaintenanceError::InvalidState("visit is not planned — the part set is frozen")),
}
let id = Uuid::new_v4();
self.parts.insert_part(&self.pool, id, visit_id, item_id, quantity).await?;
Ok(id)
}
pub async fn start_visit(&self, visit_id: Uuid) -> Result<(), MaintenanceError> {
let n = self.visits.claim_in_progress(&self.pool, visit_id).await?;
if n != 1 {
return Err(MaintenanceError::InvalidState("visit is not planned"));
}
Ok(())
}
pub async fn complete_visit(
&self,
visit_id: Uuid,
performed_date: NaiveDate,
inventory: &dyn InventoryPort,
sink: &dyn GlPostSink,
events: &dyn MaintenanceEventSink,
) -> Result<CompleteOutcome, MaintenanceError> {
let v = self.visits.fetch_for_completion(&self.pool, visit_id).await?
.ok_or(MaintenanceError::NotFound("visit"))?;
if v.status == "completed" {
return Ok(CompleteOutcome {
visit_id, journal_id: v.journal_id, total_cost: v.total_cost, already: true,
});
}
if v.status != "planned" && v.status != "in_progress" {
return Err(MaintenanceError::InvalidState("visit is not open"));
}
let legacy_company = legacy_company_echo();
self.visits.claim_in_progress(&self.pool, visit_id).await?;
let asset_id = v.asset_id;
let labor_cost = v.labor_cost;
let schedule_id = v.schedule_id;
let maintenance_type = v.maintenance_type.clone();
let part_rows = self.parts.list_for_visit(&self.pool, visit_id).await?;
let mut parts_cost = Decimal::ZERO;
if !part_rows.is_empty() {
let warehouse_id: Uuid = v.warehouse_id
.ok_or(MaintenanceError::Invalid("a visit with parts needs a warehouse".into()))?;
let lines: Vec<IssueLine> = part_rows.iter().map(|r| IssueLine {
item_id: r.item_id, quantity: r.quantity,
}).collect();
let ack = inventory.issue_parts(&PartsIssue {
company_id: legacy_company, visit_id, warehouse_id, idempotency_key: format!("maintenance:{visit_id}"), lines,
}).await.map_err(|e| MaintenanceError::InventoryRejected(e.code))?;
parts_cost = money(ack.total_value);
for lv in &ack.lines {
self.parts.set_valuation(
&self.pool, visit_id, lv.item_id, money(lv.rate), money(lv.value),
).await?;
}
}
let total_cost = labor_cost + parts_cost;
let mut journal_id: Option<Uuid> = None;
let mut post_id: Option<Uuid> = None;
if total_cost > Decimal::ZERO {
let expense: Uuid = v.maintenance_expense_account_id
.ok_or(MaintenanceError::Invalid("no maintenance expense account".into()))?;
let parts_acct: Uuid = v.parts_inventory_account_id
.ok_or(MaintenanceError::Invalid("no parts inventory account".into()))?;
let labor_acct: Uuid = v.labor_payable_account_id
.ok_or(MaintenanceError::Invalid("no labor payable account".into()))?;
let mut lines = vec![GlPostLine::debit(expense, total_cost).with_description("Maintenance expense")];
if parts_cost > Decimal::ZERO {
lines.push(GlPostLine::credit(parts_acct, parts_cost).with_description("Parts issued"));
}
if labor_cost > Decimal::ZERO {
lines.push(GlPostLine::credit(labor_acct, labor_cost).with_description("Maintenance labor"));
}
let env = AccountingPostEnvelope {
idempotency_key: format!("maintenance:{visit_id}"),
company_id: legacy_company, branch_id: None, source_type: "maintenance".into(), source_id: visit_id,
source_reference: None, posting_date: performed_date, currency: "IDR".into(),
posting_type: "original".into(), description: Some("Maintenance cost".into()), lines,
};
if !env.is_balanced() {
return Err(MaintenanceError::Unbalanced);
}
let ack = sink.post(&env).await.map_err(|r| MaintenanceError::GlRejected(r.code))?;
journal_id = Some(ack.journal_id);
post_id = Some(ack.post_id);
}
let mut tx = self.pool.begin().await?;
relay_ambient_scope(&mut tx).await?;
let moved = self.visits.complete(&mut tx, visit_id, &VisitCompletion {
performed_date, parts_cost, total_cost, journal_id, accounting_post_id: post_id,
}).await?;
if moved != 1 {
tx.rollback().await?;
let j = self.visits.fetch_journal_id(&self.pool, visit_id).await?;
return Ok(CompleteOutcome { visit_id, journal_id: j, total_cost, already: true });
}
if maintenance_type == "preventive" {
if let Some(sid) = schedule_id {
self.schedules.advance_next_due(&mut tx, sid, performed_date).await?;
}
}
let event = MaintenanceEvent::MaintenanceCompleted(MaintenanceCompleted {
visit_id, company_id: legacy_company, asset_id, journal_id, labor_cost, parts_cost, total_cost,
});
let record = backbone_outbox::OutboxRecord::new(
"MaintenanceCompleted", "MaintenanceVisit", visit_id.to_string(), legacy_company,
serde_json::to_value(&event).map_err(|e| MaintenanceError::Invalid(e.to_string()))?,
Utc::now(),
);
backbone_outbox::outbox::stage(&mut *tx, "maintenance", &record)
.await.map_err(|e| MaintenanceError::Invalid(format!("outbox stage: {e}")))?;
tx.commit().await?;
events.publish(&event);
Ok(CompleteOutcome { visit_id, journal_id, total_cost, already: false })
}
pub async fn cancel_visit(&self, visit_id: Uuid) -> Result<(), MaintenanceError> {
let n = self.visits.cancel(&self.pool, visit_id).await?;
if n != 1 {
return Err(MaintenanceError::InvalidState("visit is not open"));
}
Ok(())
}
}