Skip to main content

backbone_maintenance/infrastructure/persistence/
maintenance_visit_repository.rs

1//! Repository for MaintenanceVisit entities
2//!
3//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
4//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
5//! below hold the hand-written MaintenanceVisit SQL (4-layer rule: services orchestrate, repos hold
6//! SQL).
7//!
8//! Thin newtype over `backbone_orm::GenericCrudRepository<MaintenanceVisit, backbone_orm::SoftDelete>`.
9//! All standard CRUD methods are available via `Deref`.
10
11use anyhow::Result;
12use chrono::NaiveDate;
13use rust_decimal::Decimal;
14use sqlx::{PgPool, Row};
15use uuid::Uuid;
16
17use backbone_orm::{company_scope, org_scope};
18
19use crate::domain::entity::MaintenanceVisit;
20
21/// Table name for MaintenanceVisit entities
22pub const TABLE_NAME: &str = "maintenance.maintenance_visits";
23
24/// Repository for MaintenanceVisit entities.
25///
26/// All standard CRUD, soft-delete, pagination, and bulk methods are
27/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
28pub struct MaintenanceVisitRepository(
29    backbone_orm::GenericCrudRepository<MaintenanceVisit, backbone_orm::SoftDelete>,
30);
31
32impl std::ops::Deref for MaintenanceVisitRepository {
33    type Target = backbone_orm::GenericCrudRepository<MaintenanceVisit, backbone_orm::SoftDelete>;
34    fn deref(&self) -> &Self::Target { &self.0 }
35}
36
37impl MaintenanceVisitRepository {
38    /// Create a new repository instance.
39    pub fn new(pool: PgPool) -> Self {
40        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
41    }
42}
43
44/// The exact row a visit plan writes.
45///
46/// Mirrors the raw column shape rather than the `MaintenanceVisit` entity: `maintenance_type` binds as
47/// `&str` and is cast at the DB (`$5::maintenance_type`), so a bad value fails as a DB error rather
48/// than a deserialize panic. `labor_cost` is expected already money-rounded by the caller.
49pub struct NewVisitRow<'a> {
50    pub id: Uuid,
51    pub asset_id: Uuid,
52    pub schedule_id: Option<Uuid>,
53    pub maintenance_type: &'a str,
54    pub warehouse_id: Option<Uuid>,
55    pub warranty_claim_id: Option<Uuid>,
56    pub scheduled_date: NaiveDate,
57    pub labor_cost: Decimal,
58    pub maintenance_expense_account_id: Uuid,
59    pub parts_inventory_account_id: Uuid,
60    pub labor_payable_account_id: Uuid,
61}
62
63/// A visit's state as the completion path reads it — the accounts are `Option` because the columns are
64/// nullable, and the service turns a missing one into a domain error.
65pub struct VisitCompletionRow {
66    pub asset_id: Uuid,
67    pub status: String,
68    pub warehouse_id: Option<Uuid>,
69    pub labor_cost: Decimal,
70    pub maintenance_expense_account_id: Option<Uuid>,
71    pub parts_inventory_account_id: Option<Uuid>,
72    pub labor_payable_account_id: Option<Uuid>,
73    pub journal_id: Option<Uuid>,
74    pub total_cost: Decimal,
75    pub schedule_id: Option<Uuid>,
76    pub maintenance_type: String,
77}
78
79/// The values a completion CAS writes onto the visit.
80pub struct VisitCompletion {
81    pub performed_date: NaiveDate,
82    pub parts_cost: Decimal,
83    pub total_cost: Decimal,
84    pub journal_id: Option<Uuid>,
85    pub accounting_post_id: Option<Uuid>,
86}
87
88/// Hand-written MaintenanceVisit SQL. Lives here (not in the write service) per the module's 4-layer
89/// rule: services orchestrate and own the unit of work, repositories hold the SQL.
90impl MaintenanceVisitRepository {
91    /// Plan a visit (`planned`). Preventive visits reference a schedule; corrective ones don't.
92    ///
93    /// Runs outside a transaction on the pool via `org_scope::execute_scoped`, which binds the
94    /// ambient org scope (the composing service sets it per request) so the INSERT passes the
95    /// decorator's WITH CHECK fence; the decorator's fill trigger stamps the acting unit
96    /// (ADR-0029). Undecorated (module tests) the insert runs plain.
97    pub async fn insert_visit(&self, pool: &PgPool, v: &NewVisitRow<'_>) -> Result<(), sqlx::Error> {
98        org_scope::execute_scoped(
99            pool,
100            sqlx::query(
101                r#"INSERT INTO maintenance.maintenance_visits
102                     (id, asset_id, schedule_id, maintenance_type, status, warehouse_id,
103                      warranty_claim_id, scheduled_date, labor_cost, parts_cost, total_cost,
104                      maintenance_expense_account_id, parts_inventory_account_id, labor_payable_account_id)
105                   VALUES ($1,$2,$3,$4::maintenance_type,'planned'::visit_status,$5,$6,$7,$8,0,0,$9,$10,$11)"#,
106            )
107            .bind(v.id).bind(v.asset_id).bind(v.schedule_id).bind(v.maintenance_type)
108            .bind(v.warehouse_id).bind(v.warranty_claim_id).bind(v.scheduled_date).bind(v.labor_cost)
109            .bind(v.maintenance_expense_account_id).bind(v.parts_inventory_account_id)
110            .bind(v.labor_payable_account_id),
111        )
112        .await?;
113        Ok(())
114    }
115
116    /// A live visit's status, or `Ok(None)` when it is absent/soft-deleted.
117    ///
118    /// ID-only: no scope argument. `fetch_optional_scalar_scoped` means it rides the caller-scoped
119    /// connection, so a visit outside the caller's org scope simply is not found.
120    pub async fn fetch_status(
121        &self,
122        pool: &PgPool,
123        visit_id: Uuid,
124    ) -> Result<Option<String>, sqlx::Error> {
125        company_scope::fetch_optional_scalar_scoped(
126            pool,
127            sqlx::query_scalar(
128                "SELECT status::text FROM maintenance.maintenance_visits WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
129            )
130            .bind(visit_id),
131        )
132        .await
133    }
134
135    /// The full state the completion path needs. `Ok(None)` when the visit is absent/soft-deleted.
136    /// Same ID-only, caller-scoped contract as [`Self::fetch_status`].
137    pub async fn fetch_for_completion(
138        &self,
139        pool: &PgPool,
140        visit_id: Uuid,
141    ) -> Result<Option<VisitCompletionRow>, sqlx::Error> {
142        let row = company_scope::fetch_optional_row_scoped(
143            pool,
144            sqlx::query(
145                r#"SELECT asset_id, status::text AS status, warehouse_id, labor_cost,
146                          maintenance_expense_account_id, parts_inventory_account_id, labor_payable_account_id,
147                          journal_id, total_cost, schedule_id, maintenance_type::text AS maintenance_type
148                   FROM maintenance.maintenance_visits WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
149            )
150            .bind(visit_id),
151        )
152        .await?;
153        Ok(row.map(|r| VisitCompletionRow {
154            asset_id: r.get("asset_id"),
155            status: r.get("status"),
156            warehouse_id: r.get("warehouse_id"),
157            labor_cost: r.get("labor_cost"),
158            maintenance_expense_account_id: r.get("maintenance_expense_account_id"),
159            parts_inventory_account_id: r.get("parts_inventory_account_id"),
160            labor_payable_account_id: r.get("labor_payable_account_id"),
161            journal_id: r.get("journal_id"),
162            total_cost: r.get("total_cost"),
163            schedule_id: r.get("schedule_id"),
164            maintenance_type: r.get("maintenance_type"),
165        }))
166    }
167
168    /// Claim `planned → in_progress`. Returns the rows affected so the caller can distinguish "started
169    /// it" (1) from "it wasn't planned" (0).
170    ///
171    /// Two callers with two readings of that count: `start_visit` treats 0 as an invalid-state error;
172    /// the completion path calls it to FREEZE the part set before any external effect and ignores the
173    /// count (an already-in_progress visit is fine to complete).
174    pub async fn claim_in_progress(
175        &self,
176        pool: &PgPool,
177        visit_id: Uuid,
178    ) -> Result<u64, sqlx::Error> {
179        let r = org_scope::execute_scoped(
180            pool,
181            sqlx::query(
182                r#"UPDATE maintenance.maintenance_visits SET status='in_progress'::visit_status
183                   WHERE id=$1 AND status='planned'::visit_status"#,
184            )
185            .bind(visit_id),
186        )
187        .await?;
188        Ok(r.rows_affected())
189    }
190
191    /// The completion CAS: gate `planned|in_progress → completed` and write the rolled-up cost and the
192    /// posting references. Returns the rows affected — 0 means another completion raced and won, and
193    /// the caller must roll back and re-read the winner.
194    ///
195    /// Takes the CALLER'S connection so this, the schedule advance, and the outbox stage commit as one
196    /// unit. The caller has already relayed the ambient org scope onto it — don't re-bind here.
197    pub async fn complete(
198        &self,
199        conn: &mut sqlx::PgConnection,
200        visit_id: Uuid,
201        c: &VisitCompletion,
202    ) -> Result<u64, sqlx::Error> {
203        let r = sqlx::query(
204            r#"UPDATE maintenance.maintenance_visits
205               SET status='completed'::visit_status, performed_date=$2, parts_cost=$3, total_cost=$4,
206                   journal_id=$5, accounting_post_id=$6
207               WHERE id=$1 AND status IN ('planned'::visit_status,'in_progress'::visit_status)"#,
208        )
209        .bind(visit_id).bind(c.performed_date).bind(c.parts_cost).bind(c.total_cost)
210        .bind(c.journal_id).bind(c.accounting_post_id)
211        .execute(conn)
212        .await?;
213        Ok(r.rows_affected())
214    }
215
216    /// Re-read the journal a racing completion posted. Caller supplies the company scope.
217    pub async fn fetch_journal_id(
218        &self,
219        pool: &PgPool,
220        visit_id: Uuid,
221    ) -> Result<Option<Uuid>, sqlx::Error> {
222        company_scope::fetch_one_scalar_scoped(
223            pool,
224            sqlx::query_scalar("SELECT journal_id FROM maintenance.maintenance_visits WHERE id=$1")
225                .bind(visit_id),
226        )
227        .await
228    }
229
230    /// Cancel an open (planned/in_progress) visit. Returns the rows affected; 0 means it was not open.
231    /// Same ID-only, caller-scoped contract as [`Self::fetch_status`].
232    pub async fn cancel(&self, pool: &PgPool, visit_id: Uuid) -> Result<u64, sqlx::Error> {
233        let r = org_scope::execute_scoped(
234            pool,
235            sqlx::query(
236                r#"UPDATE maintenance.maintenance_visits SET status='cancelled'::visit_status
237                   WHERE id=$1 AND status IN ('planned'::visit_status,'in_progress'::visit_status)"#,
238            )
239            .bind(visit_id),
240        )
241        .await?;
242        Ok(r.rows_affected())
243    }
244}
245
246backbone_core::impl_crud_repository!(MaintenanceVisitRepository, MaintenanceVisit, soft_delete);