Skip to main content

backbone_maintenance/application/service/
maintenance_query_service.rs

1//! Concrete [`MaintenanceQueryService`] over the repositories (hand-authored, user-owned).
2//!
3//! Delivers the published read contract (defined in `exports::services`) that the skeleton shipped as
4//! an unimplemented trait — the contract-seat finding of the maturity council (2026-08-02, rec #2).
5//! It is placed in the application layer — not inside `exports/` — so `exports/` stays a pure,
6//! decoupled contract surface while the realization lives next to the other services that depend on
7//! infrastructure. Direct mirror of `backbone-asset::AssetsQueryServiceImpl`.
8//!
9//! Reads use the repositories' generic `find_by_id` / `exists` (available via `Deref` on each
10//! hand-authored newtype). Under the composed decorator's RLS (ADR-0029), a read with no org scope
11//! bound simply sees no rows; a composing service binds the caller's scope onto its connection.
12
13use anyhow::Result;
14use async_trait::async_trait;
15use sqlx::PgPool;
16
17use crate::exports::{
18    MaintenanceQueryService, MaintenanceScheduleDto, MaintenanceScheduleId, MaintenanceScheduleSummary,
19    MaintenanceVisitDto, MaintenanceVisitId, MaintenanceVisitPartDto, MaintenanceVisitPartId,
20    MaintenanceVisitPartSummary, MaintenanceVisitSummary,
21};
22use crate::infrastructure::persistence::{
23    MaintenanceScheduleRepository, MaintenanceVisitPartRepository, MaintenanceVisitRepository,
24};
25
26/// Implemented [`MaintenanceQueryService`] — one `PgPool`, builds a repo per call (the pool is
27/// `Arc`-internal, so cloning is cheap).
28pub struct MaintenanceQueryServiceImpl {
29    pool: PgPool,
30}
31
32impl MaintenanceQueryServiceImpl {
33    pub fn new(pool: PgPool) -> Self {
34        Self { pool }
35    }
36}
37
38#[async_trait]
39impl MaintenanceQueryService for MaintenanceQueryServiceImpl {
40    async fn get_maintenance_schedule(
41        &self,
42        id: MaintenanceScheduleId,
43    ) -> Result<Option<MaintenanceScheduleDto>> {
44        let s = MaintenanceScheduleRepository::new(self.pool.clone())
45            .find_by_id(&id.into_inner().to_string())
46            .await?;
47        Ok(s.map(MaintenanceScheduleDto::from))
48    }
49
50    async fn get_maintenance_schedule_summary(
51        &self,
52        id: MaintenanceScheduleId,
53    ) -> Result<Option<MaintenanceScheduleSummary>> {
54        let s = MaintenanceScheduleRepository::new(self.pool.clone())
55            .find_by_id(&id.into_inner().to_string())
56            .await?;
57        Ok(s.map(|s| MaintenanceScheduleSummary {
58            id: MaintenanceScheduleId(s.id),
59            name: s.name,
60            status: s.status,
61        }))
62    }
63
64    async fn maintenance_schedule_exists(&self, id: MaintenanceScheduleId) -> Result<bool> {
65        MaintenanceScheduleRepository::new(self.pool.clone())
66            .exists(&id.into_inner().to_string())
67            .await
68    }
69
70    async fn get_maintenance_visit(&self, id: MaintenanceVisitId) -> Result<Option<MaintenanceVisitDto>> {
71        let v = MaintenanceVisitRepository::new(self.pool.clone())
72            .find_by_id(&id.into_inner().to_string())
73            .await?;
74        Ok(v.map(MaintenanceVisitDto::from))
75    }
76
77    async fn get_maintenance_visit_summary(
78        &self,
79        id: MaintenanceVisitId,
80    ) -> Result<Option<MaintenanceVisitSummary>> {
81        let v = MaintenanceVisitRepository::new(self.pool.clone())
82            .find_by_id(&id.into_inner().to_string())
83            .await?;
84        Ok(v.map(|v| MaintenanceVisitSummary {
85            id: MaintenanceVisitId(v.id),
86            status: v.status,
87        }))
88    }
89
90    async fn maintenance_visit_exists(&self, id: MaintenanceVisitId) -> Result<bool> {
91        MaintenanceVisitRepository::new(self.pool.clone())
92            .exists(&id.into_inner().to_string())
93            .await
94    }
95
96    async fn get_maintenance_visit_part(
97        &self,
98        id: MaintenanceVisitPartId,
99    ) -> Result<Option<MaintenanceVisitPartDto>> {
100        let p = MaintenanceVisitPartRepository::new(self.pool.clone())
101            .find_by_id(&id.into_inner().to_string())
102            .await?;
103        Ok(p.map(MaintenanceVisitPartDto::from))
104    }
105
106    async fn get_maintenance_visit_part_summary(
107        &self,
108        id: MaintenanceVisitPartId,
109    ) -> Result<Option<MaintenanceVisitPartSummary>> {
110        let p = MaintenanceVisitPartRepository::new(self.pool.clone())
111            .find_by_id(&id.into_inner().to_string())
112            .await?;
113        Ok(p.map(|p| MaintenanceVisitPartSummary {
114            id: MaintenanceVisitPartId(p.id),
115        }))
116    }
117
118    async fn maintenance_visit_part_exists(&self, id: MaintenanceVisitPartId) -> Result<bool> {
119        MaintenanceVisitPartRepository::new(self.pool.clone())
120            .exists(&id.into_inner().to_string())
121            .await
122    }
123}
124
125// Entity → DTO conversions. Regen-safe home: this file is hand-authored (never overwritten by
126// `metaphor make`), unlike `exports/types.rs` whose CUSTOM block the generator resets. The query
127// methods above rely on these `From` impls to hand siblings the published DTO shapes.
128impl From<crate::domain::entity::MaintenanceSchedule> for MaintenanceScheduleDto {
129    fn from(s: crate::domain::entity::MaintenanceSchedule) -> Self {
130        Self {
131            id: MaintenanceScheduleId(s.id),
132            asset_id: s.asset_id,
133            name: s.name,
134            interval_days: s.interval_days,
135            next_due_date: s.next_due_date,
136            status: s.status,
137            metadata: serde_json::to_value(&s.metadata).unwrap_or_default(),
138        }
139    }
140}
141
142impl From<crate::domain::entity::MaintenanceVisit> for MaintenanceVisitDto {
143    fn from(v: crate::domain::entity::MaintenanceVisit) -> Self {
144        Self {
145            id: MaintenanceVisitId(v.id),
146            asset_id: v.asset_id,
147            schedule_id: v.schedule_id,
148            maintenance_type: v.maintenance_type,
149            status: v.status,
150            warehouse_id: v.warehouse_id,
151            warranty_claim_id: v.warranty_claim_id,
152            scheduled_date: v.scheduled_date,
153            performed_date: v.performed_date,
154            labor_cost: v.labor_cost,
155            parts_cost: v.parts_cost,
156            total_cost: v.total_cost,
157            maintenance_expense_account_id: v.maintenance_expense_account_id,
158            parts_inventory_account_id: v.parts_inventory_account_id,
159            labor_payable_account_id: v.labor_payable_account_id,
160            journal_id: v.journal_id,
161            accounting_post_id: v.accounting_post_id,
162            notes: v.notes,
163            metadata: serde_json::to_value(&v.metadata).unwrap_or_default(),
164        }
165    }
166}
167
168impl From<crate::domain::entity::MaintenanceVisitPart> for MaintenanceVisitPartDto {
169    fn from(p: crate::domain::entity::MaintenanceVisitPart) -> Self {
170        Self {
171            id: MaintenanceVisitPartId(p.id),
172            visit_id: p.visit_id,
173            item_id: p.item_id,
174            quantity: p.quantity,
175            unit_cost: p.unit_cost,
176            amount: p.amount,
177            metadata: serde_json::to_value(&p.metadata).unwrap_or_default(),
178        }
179    }
180}