backbone-maintenance 0.7.21

Minimal Backbone Framework module skeleton
Documentation
//! Repository for MaintenanceVisit entities
//!
//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
//! below hold the hand-written MaintenanceVisit SQL (4-layer rule: services orchestrate, repos hold
//! SQL).
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<MaintenanceVisit, backbone_orm::SoftDelete>`.
//! All standard CRUD methods are available via `Deref`.

use anyhow::Result;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use sqlx::{PgPool, Row};
use uuid::Uuid;

use backbone_orm::{company_scope, org_scope};

use crate::domain::entity::MaintenanceVisit;

/// Table name for MaintenanceVisit entities
pub const TABLE_NAME: &str = "maintenance.maintenance_visits";

/// Repository for MaintenanceVisit entities.
///
/// All standard CRUD, soft-delete, pagination, and bulk methods are
/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
pub struct MaintenanceVisitRepository(
    backbone_orm::GenericCrudRepository<MaintenanceVisit, backbone_orm::SoftDelete>,
);

impl std::ops::Deref for MaintenanceVisitRepository {
    type Target = backbone_orm::GenericCrudRepository<MaintenanceVisit, backbone_orm::SoftDelete>;
    fn deref(&self) -> &Self::Target { &self.0 }
}

impl MaintenanceVisitRepository {
    /// Create a new repository instance.
    pub fn new(pool: PgPool) -> Self {
        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
    }
}

/// The exact row a visit plan writes.
///
/// Mirrors the raw column shape rather than the `MaintenanceVisit` entity: `maintenance_type` binds as
/// `&str` and is cast at the DB (`$5::maintenance_type`), so a bad value fails as a DB error rather
/// than a deserialize panic. `labor_cost` is expected already money-rounded by the caller.
pub struct NewVisitRow<'a> {
    pub id: Uuid,
    pub asset_id: Uuid,
    pub schedule_id: Option<Uuid>,
    pub maintenance_type: &'a str,
    pub warehouse_id: Option<Uuid>,
    pub warranty_claim_id: Option<Uuid>,
    pub scheduled_date: NaiveDate,
    pub labor_cost: Decimal,
    pub maintenance_expense_account_id: Uuid,
    pub parts_inventory_account_id: Uuid,
    pub labor_payable_account_id: Uuid,
}

/// A visit's state as the completion path reads it — the accounts are `Option` because the columns are
/// nullable, and the service turns a missing one into a domain error.
pub struct VisitCompletionRow {
    pub asset_id: Uuid,
    pub status: String,
    pub warehouse_id: Option<Uuid>,
    pub labor_cost: Decimal,
    pub maintenance_expense_account_id: Option<Uuid>,
    pub parts_inventory_account_id: Option<Uuid>,
    pub labor_payable_account_id: Option<Uuid>,
    pub journal_id: Option<Uuid>,
    pub total_cost: Decimal,
    pub schedule_id: Option<Uuid>,
    pub maintenance_type: String,
}

/// The values a completion CAS writes onto the visit.
pub struct VisitCompletion {
    pub performed_date: NaiveDate,
    pub parts_cost: Decimal,
    pub total_cost: Decimal,
    pub journal_id: Option<Uuid>,
    pub accounting_post_id: Option<Uuid>,
}

/// Hand-written MaintenanceVisit SQL. Lives here (not in the write service) per the module's 4-layer
/// rule: services orchestrate and own the unit of work, repositories hold the SQL.
impl MaintenanceVisitRepository {
    /// Plan a visit (`planned`). Preventive visits reference a schedule; corrective ones don't.
    ///
    /// Runs outside a transaction on the pool via `org_scope::execute_scoped`, which binds the
    /// ambient org scope (the composing service sets it per request) so the INSERT passes the
    /// decorator's WITH CHECK fence; the decorator's fill trigger stamps the acting unit
    /// (ADR-0029). Undecorated (module tests) the insert runs plain.
    pub async fn insert_visit(&self, pool: &PgPool, v: &NewVisitRow<'_>) -> Result<(), sqlx::Error> {
        org_scope::execute_scoped(
            pool,
            sqlx::query(
                r#"INSERT INTO maintenance.maintenance_visits
                     (id, asset_id, schedule_id, maintenance_type, status, warehouse_id,
                      warranty_claim_id, scheduled_date, labor_cost, parts_cost, total_cost,
                      maintenance_expense_account_id, parts_inventory_account_id, labor_payable_account_id)
                   VALUES ($1,$2,$3,$4::maintenance_type,'planned'::visit_status,$5,$6,$7,$8,0,0,$9,$10,$11)"#,
            )
            .bind(v.id).bind(v.asset_id).bind(v.schedule_id).bind(v.maintenance_type)
            .bind(v.warehouse_id).bind(v.warranty_claim_id).bind(v.scheduled_date).bind(v.labor_cost)
            .bind(v.maintenance_expense_account_id).bind(v.parts_inventory_account_id)
            .bind(v.labor_payable_account_id),
        )
        .await?;
        Ok(())
    }

    /// A live visit's status, or `Ok(None)` when it is absent/soft-deleted.
    ///
    /// ID-only: no scope argument. `fetch_optional_scalar_scoped` means it rides the caller-scoped
    /// connection, so a visit outside the caller's org scope simply is not found.
    pub async fn fetch_status(
        &self,
        pool: &PgPool,
        visit_id: Uuid,
    ) -> Result<Option<String>, sqlx::Error> {
        company_scope::fetch_optional_scalar_scoped(
            pool,
            sqlx::query_scalar(
                "SELECT status::text FROM maintenance.maintenance_visits WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
            )
            .bind(visit_id),
        )
        .await
    }

    /// The full state the completion path needs. `Ok(None)` when the visit is absent/soft-deleted.
    /// Same ID-only, caller-scoped contract as [`Self::fetch_status`].
    pub async fn fetch_for_completion(
        &self,
        pool: &PgPool,
        visit_id: Uuid,
    ) -> Result<Option<VisitCompletionRow>, sqlx::Error> {
        let row = company_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT asset_id, status::text AS status, warehouse_id, labor_cost,
                          maintenance_expense_account_id, parts_inventory_account_id, labor_payable_account_id,
                          journal_id, total_cost, schedule_id, maintenance_type::text AS maintenance_type
                   FROM maintenance.maintenance_visits WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(visit_id),
        )
        .await?;
        Ok(row.map(|r| VisitCompletionRow {
            asset_id: r.get("asset_id"),
            status: r.get("status"),
            warehouse_id: r.get("warehouse_id"),
            labor_cost: r.get("labor_cost"),
            maintenance_expense_account_id: r.get("maintenance_expense_account_id"),
            parts_inventory_account_id: r.get("parts_inventory_account_id"),
            labor_payable_account_id: r.get("labor_payable_account_id"),
            journal_id: r.get("journal_id"),
            total_cost: r.get("total_cost"),
            schedule_id: r.get("schedule_id"),
            maintenance_type: r.get("maintenance_type"),
        }))
    }

    /// Claim `planned → in_progress`. Returns the rows affected so the caller can distinguish "started
    /// it" (1) from "it wasn't planned" (0).
    ///
    /// Two callers with two readings of that count: `start_visit` treats 0 as an invalid-state error;
    /// the completion path calls it to FREEZE the part set before any external effect and ignores the
    /// count (an already-in_progress visit is fine to complete).
    pub async fn claim_in_progress(
        &self,
        pool: &PgPool,
        visit_id: Uuid,
    ) -> Result<u64, sqlx::Error> {
        let r = org_scope::execute_scoped(
            pool,
            sqlx::query(
                r#"UPDATE maintenance.maintenance_visits SET status='in_progress'::visit_status
                   WHERE id=$1 AND status='planned'::visit_status"#,
            )
            .bind(visit_id),
        )
        .await?;
        Ok(r.rows_affected())
    }

    /// The completion CAS: gate `planned|in_progress → completed` and write the rolled-up cost and the
    /// posting references. Returns the rows affected — 0 means another completion raced and won, and
    /// the caller must roll back and re-read the winner.
    ///
    /// Takes the CALLER'S connection so this, the schedule advance, and the outbox stage commit as one
    /// unit. The caller has already relayed the ambient org scope onto it — don't re-bind here.
    pub async fn complete(
        &self,
        conn: &mut sqlx::PgConnection,
        visit_id: Uuid,
        c: &VisitCompletion,
    ) -> Result<u64, sqlx::Error> {
        let r = sqlx::query(
            r#"UPDATE maintenance.maintenance_visits
               SET status='completed'::visit_status, performed_date=$2, parts_cost=$3, total_cost=$4,
                   journal_id=$5, accounting_post_id=$6
               WHERE id=$1 AND status IN ('planned'::visit_status,'in_progress'::visit_status)"#,
        )
        .bind(visit_id).bind(c.performed_date).bind(c.parts_cost).bind(c.total_cost)
        .bind(c.journal_id).bind(c.accounting_post_id)
        .execute(conn)
        .await?;
        Ok(r.rows_affected())
    }

    /// Re-read the journal a racing completion posted. Caller supplies the company scope.
    pub async fn fetch_journal_id(
        &self,
        pool: &PgPool,
        visit_id: Uuid,
    ) -> Result<Option<Uuid>, sqlx::Error> {
        company_scope::fetch_one_scalar_scoped(
            pool,
            sqlx::query_scalar("SELECT journal_id FROM maintenance.maintenance_visits WHERE id=$1")
                .bind(visit_id),
        )
        .await
    }

    /// Cancel an open (planned/in_progress) visit. Returns the rows affected; 0 means it was not open.
    /// Same ID-only, caller-scoped contract as [`Self::fetch_status`].
    pub async fn cancel(&self, pool: &PgPool, visit_id: Uuid) -> Result<u64, sqlx::Error> {
        let r = org_scope::execute_scoped(
            pool,
            sqlx::query(
                r#"UPDATE maintenance.maintenance_visits SET status='cancelled'::visit_status
                   WHERE id=$1 AND status IN ('planned'::visit_status,'in_progress'::visit_status)"#,
            )
            .bind(visit_id),
        )
        .await?;
        Ok(r.rows_affected())
    }
}

backbone_core::impl_crud_repository!(MaintenanceVisitRepository, MaintenanceVisit, soft_delete);