1use chrono::{DateTime, Duration, NaiveDate, Utc};
21use rust_decimal::{Decimal, prelude::ToPrimitive};
22use sqlx::PgPool;
23use uuid::Uuid;
24
25use crate::domain::entity::{MaintenanceType, RequestKanbanState, RequestPriority, RepeatType, RepeatUnit};
26use crate::infrastructure::persistence::{
27 MaintenanceRequestRepository, NewRequestRow, RequestFieldUpdates, RequestTransitionRow,
28};
29
30use super::maintenance_events::*;
31use super::maintenance_write_service::{legacy_company_echo, relay_ambient_scope};
32
33#[derive(Debug, thiserror::Error)]
34pub enum RequestWriteError {
35 #[error("db: {0}")]
36 Db(#[from] sqlx::Error),
37 #[error("not found: {0}")]
38 NotFound(&'static str),
39 #[error("schedule_end must not precede schedule_date")]
41 ScheduleEndBeforeStart,
42 #[error("repeat_interval must be at least 1")]
44 RepeatIntervalBelowOne,
45 #[error("repeat_type 'until' requires a repeat_until date")]
47 RepeatUntilMissing,
48 #[error("only preventive maintenance may recur")]
50 CorrectiveCannotRecur,
51 #[error("invalid state: {0}")]
52 InvalidState(&'static str),
53 #[error("invalid input: {0}")]
54 Invalid(String),
55}
56
57#[derive(Debug, Clone)]
59pub struct NewMaintenanceRequest {
60 pub name: String,
61 pub description: Option<String>,
62 pub schedule_date: Option<DateTime<Utc>>,
63 pub schedule_end: Option<DateTime<Utc>>,
64 pub duration: Decimal,
65 pub owner_user_id: Option<Uuid>,
66 pub user_id: Option<Uuid>,
67 pub asset_id: Option<Uuid>,
68 pub stage_id: Option<Uuid>,
69 pub kanban_state: RequestKanbanState,
70 pub priority: RequestPriority,
71 pub maintenance_type: MaintenanceType,
72 pub recurring: bool,
73 pub repeat_interval: i32,
74 pub repeat_unit: RepeatUnit,
75 pub repeat_type: RepeatType,
76 pub repeat_until: Option<NaiveDate>,
77}
78
79impl Default for NewMaintenanceRequest {
80 fn default() -> Self {
81 Self {
82 name: String::new(),
83 description: None,
84 schedule_date: None,
85 schedule_end: None,
86 duration: Decimal::ZERO,
87 owner_user_id: None,
88 user_id: None,
89 asset_id: None,
90 stage_id: None,
91 kanban_state: RequestKanbanState::default(),
92 priority: RequestPriority::default(),
93 maintenance_type: MaintenanceType::Corrective,
94 recurring: false,
95 repeat_interval: 1,
96 repeat_unit: RepeatUnit::default(),
97 repeat_type: RepeatType::default(),
98 repeat_until: None,
99 }
100 }
101}
102
103#[derive(Debug, Clone, Default)]
107pub struct MaintenanceRequestUpdate {
108 pub name: Option<String>,
109 pub description: Option<String>,
110 pub schedule_date: Option<DateTime<Utc>>,
111 pub schedule_end: Option<DateTime<Utc>>,
112 pub duration: Option<Decimal>,
113 pub owner_user_id: Option<Uuid>,
114 pub user_id: Option<Uuid>,
115 pub asset_id: Option<Uuid>,
116 pub priority: Option<RequestPriority>,
117 pub maintenance_type: Option<MaintenanceType>,
118 pub recurring: Option<bool>,
119 pub repeat_interval: Option<i32>,
120 pub repeat_unit: Option<RepeatUnit>,
121 pub repeat_type: Option<RepeatType>,
122 pub repeat_until: Option<NaiveDate>,
123}
124
125#[derive(Debug, Clone, PartialEq)]
128pub struct TransitionOutcome {
129 pub request_id: Uuid,
130 pub from_stage_id: Option<Uuid>,
131 pub to_stage_id: Uuid,
132 pub close_date: Option<NaiveDate>,
134 pub spawned_successor_id: Option<Uuid>,
135 pub already: bool,
136}
137
138pub struct MaintenanceRequestWriteService {
139 pool: PgPool,
140 requests: MaintenanceRequestRepository,
141}
142
143impl MaintenanceRequestWriteService {
144 pub fn new(pool: PgPool) -> Self {
145 let requests = MaintenanceRequestRepository::new(pool.clone());
146 Self { pool, requests }
147 }
148
149 pub async fn create_request(&self, r: NewMaintenanceRequest) -> Result<Uuid, RequestWriteError> {
152 Self::guard(
153 r.schedule_date,
154 r.schedule_end,
155 r.repeat_interval,
156 r.maintenance_type,
157 r.recurring,
158 r.repeat_type,
159 r.repeat_until,
160 )?;
161 let stage_id = match r.stage_id {
162 Some(s) => {
163 let stage = self.requests.fetch_stage(&self.pool, s).await?;
164 stage.ok_or(RequestWriteError::NotFound("stage"))?.id
165 }
166 None => self.requests.fetch_first_stage_id(&self.pool).await?
167 .ok_or(RequestWriteError::NotFound("stage"))?,
168 };
169 let id = Uuid::new_v4();
170 self.requests.insert_request(&self.pool, &NewRequestRow {
171 id,
172 name: &r.name,
173 description: r.description.as_deref(),
174 schedule_date: r.schedule_date,
175 schedule_end: r.schedule_end,
176 duration: r.duration,
177 owner_user_id: r.owner_user_id,
178 user_id: r.user_id,
179 asset_id: r.asset_id,
180 stage_id,
181 kanban_state: &r.kanban_state.to_string(),
182 priority: &r.priority.to_string(),
183 maintenance_type: &r.maintenance_type.to_string(),
184 recurring: r.recurring,
185 repeat_interval: r.repeat_interval,
186 repeat_unit: &r.repeat_unit.to_string(),
187 repeat_type: &r.repeat_type.to_string(),
188 repeat_until: r.repeat_until,
189 successor_of_request_id: None,
190 })
191 .await?;
192 Ok(id)
193 }
194
195 pub async fn update_request(
199 &self,
200 request_id: Uuid,
201 u: MaintenanceRequestUpdate,
202 ) -> Result<(), RequestWriteError> {
203 let old = self
204 .fetch(&self.pool, request_id)
205 .await?
206 .ok_or(RequestWriteError::NotFound("request"))?;
207
208 let schedule_date = u.schedule_date.or(old.schedule_date);
209 let schedule_end = u.schedule_end.or(old.schedule_end);
210 let repeat_interval = u.repeat_interval.unwrap_or(old.repeat_interval);
211 let maintenance_type = u
212 .maintenance_type
213 .map(|t| t.to_string())
214 .unwrap_or_else(|| old.maintenance_type.clone());
215 let recurring = u.recurring.unwrap_or(old.recurring);
216 let repeat_type = u
217 .repeat_type
218 .map(|t| t.to_string())
219 .unwrap_or_else(|| old.repeat_type.clone());
220 let repeat_until = u.repeat_until.or(old.repeat_until);
221
222 Self::guard_schedule(schedule_date, schedule_end)?;
223 Self::guard_repeat_interval(repeat_interval)?;
224 Self::guard_repeat_until(&repeat_type, repeat_until)?;
225 Self::guard_recurring(&maintenance_type, recurring)?;
226
227 if u.name.is_none()
228 && u.description.is_none()
229 && u.schedule_date.is_none()
230 && u.schedule_end.is_none()
231 && u.duration.is_none()
232 && u.owner_user_id.is_none()
233 && u.user_id.is_none()
234 && u.asset_id.is_none()
235 && u.priority.is_none()
236 && u.maintenance_type.is_none()
237 && u.recurring.is_none()
238 && u.repeat_interval.is_none()
239 && u.repeat_unit.is_none()
240 && u.repeat_type.is_none()
241 && u.repeat_until.is_none()
242 {
243 return Ok(()); }
245
246 let priority = u.priority.as_ref().map(|p| p.to_string());
247 let maintenance_type_str = u.maintenance_type.as_ref().map(|t| t.to_string());
248 let repeat_unit_str = u.repeat_unit.as_ref().map(|x| x.to_string());
249 let repeat_type_str = u.repeat_type.as_ref().map(|x| x.to_string());
250
251 let moved = self.requests.update_request_fields(&self.pool, request_id, &RequestFieldUpdates {
252 name: u.name.as_deref(),
253 description: u.description.as_deref(),
254 schedule_date: u.schedule_date,
255 schedule_end: u.schedule_end,
256 duration: u.duration,
257 owner_user_id: u.owner_user_id,
258 user_id: u.user_id,
259 asset_id: u.asset_id,
260 priority: priority.as_deref(),
261 maintenance_type: maintenance_type_str.as_deref(),
262 recurring: u.recurring,
263 repeat_interval: u.repeat_interval,
264 repeat_unit: repeat_unit_str.as_deref(),
265 repeat_type: repeat_type_str.as_deref(),
266 repeat_until: u.repeat_until,
267 })
268 .await?;
269 if moved != 1 {
270 return Err(RequestWriteError::NotFound("request"));
271 }
272 Ok(())
273 }
274
275 pub async fn transition_request(
283 &self,
284 request_id: Uuid,
285 target_stage_id: Uuid,
286 kanban_state_override: Option<RequestKanbanState>,
287 events: &dyn MaintenanceEventSink,
288 ) -> Result<TransitionOutcome, RequestWriteError> {
289 let old = self
290 .fetch(&self.pool, request_id)
291 .await?
292 .ok_or(RequestWriteError::NotFound("request"))?;
293
294 let target = self.requests.fetch_stage(&self.pool, target_stage_id).await?
295 .ok_or(RequestWriteError::NotFound("stage"))?;
296
297 if old.stage_id == target.id {
299 return Ok(TransitionOutcome {
300 request_id,
301 from_stage_id: Some(old.stage_id),
302 to_stage_id: target.id,
303 close_date: old.close_date,
304 spawned_successor_id: None,
305 already: true,
306 });
307 }
308
309 let mut tx = self.pool.begin().await?;
310 relay_ambient_scope(&mut tx).await?;
313 sqlx::query("SELECT set_config('app.maintenance_managed_transition', '1', true)")
317 .execute(&mut *tx)
318 .await?;
319
320 let from_stage_id = old.stage_id;
321 let moved = self
322 .requests
323 .transition_cas(
324 &mut tx,
325 request_id,
326 target.id,
327 kanban_state_override.as_ref().map(|k| k.to_string()).as_deref(),
328 )
329 .await?;
330 if moved != 1 {
331 tx.rollback().await?;
332 let winner = self.fetch(&self.pool, request_id).await?.ok_or(RequestWriteError::NotFound("request"))?;
334 return Ok(TransitionOutcome {
335 request_id,
336 from_stage_id: Some(from_stage_id),
337 to_stage_id: winner.stage_id,
338 close_date: winner.close_date,
339 spawned_successor_id: winner.successor_request_id,
340 already: true,
341 });
342 }
343
344 let mut spawned_successor_id: Option<Uuid> = None;
349 let mut spawn_window: Option<(DateTime<Utc>, DateTime<Utc>)> = None;
350 if !old.stage_done && target.done && old.maintenance_type == "preventive" && old.recurring {
351 let base = old.schedule_date.unwrap_or_else(Utc::now);
352 let next = advance(base, old.repeat_interval, &old.repeat_unit)
353 .ok_or_else(|| RequestWriteError::Invalid("recurrence step overflows the calendar".into()))?;
354 let continues = old.repeat_type != "until"
356 || old.repeat_until.map(|until| next.date_naive() <= until).unwrap_or(false);
357 if continues {
358 let hours = if old.duration.is_zero() { Decimal::ONE } else { old.duration };
359 let minutes = (hours * Decimal::from(60))
360 .to_i64()
361 .ok_or_else(|| RequestWriteError::Invalid("duration out of range".into()))?;
362 let next_end = next + Duration::minutes(minutes);
363
364 let successor_id = Uuid::new_v4();
365 let claimed = self
369 .requests
370 .claim_successor_slot(&mut tx, request_id, successor_id)
371 .await?;
372 if claimed == 1 {
373 let first_stage = self
374 .requests
375 .fetch_first_stage_id_on(&mut tx)
376 .await?
377 .ok_or(RequestWriteError::Invalid("no visible stage to spawn the successor into".into()))?;
378 self.requests
379 .insert_request_on(&mut tx, &NewRequestRow {
380 id: successor_id,
381 name: &old.name,
382 description: old.description.as_deref(),
383 schedule_date: Some(next),
384 schedule_end: Some(next_end),
385 duration: old.duration,
386 owner_user_id: old.owner_user_id,
387 user_id: old.user_id,
388 asset_id: old.asset_id,
389 stage_id: first_stage,
390 kanban_state: "normal",
391 priority: &old.priority,
392 maintenance_type: &old.maintenance_type,
393 recurring: old.recurring,
394 repeat_interval: old.repeat_interval,
395 repeat_unit: &old.repeat_unit,
396 repeat_type: &old.repeat_type,
397 repeat_until: old.repeat_until,
398 successor_of_request_id: Some(request_id),
399 })
400 .await?;
401 spawned_successor_id = Some(successor_id);
402 spawn_window = Some((next, next_end));
403 }
404 }
405 }
406
407 let legacy_company = legacy_company_echo();
410 let close_date = if target.done { Some(Utc::now().date_naive()) } else { None };
411 let stage_event = MaintenanceEvent::MaintenanceRequestStageChanged(MaintenanceRequestStageChanged {
412 request_id,
413 company_id: legacy_company,
414 from_stage_id: Some(from_stage_id),
415 to_stage_id: target.id,
416 close_date,
417 spawned_successor_id,
418 });
419 let stage_record = backbone_outbox::OutboxRecord::new(
420 "MaintenanceRequestStageChanged", "MaintenanceRequest", request_id.to_string(), legacy_company,
421 serde_json::to_value(&stage_event).map_err(|e| RequestWriteError::Invalid(e.to_string()))?,
422 Utc::now(),
423 );
424 backbone_outbox::outbox::stage(&mut *tx, "maintenance", &stage_record)
425 .await
426 .map_err(|e| RequestWriteError::Invalid(format!("outbox stage: {e}")))?;
427
428 let mut spawn_event_value = None;
429 if let Some(successor_id) = spawned_successor_id {
430 let (next, next_end) = spawn_window.expect("spawn window set alongside the successor id");
431 let spawn_event = MaintenanceEvent::SuccessorSpawned(SuccessorSpawned {
432 source_request_id: request_id,
433 successor_request_id: successor_id,
434 company_id: legacy_company,
435 next_schedule_date: Some(next),
436 next_schedule_end: Some(next_end),
437 });
438 let spawn_record = backbone_outbox::OutboxRecord::new(
439 "SuccessorSpawned", "MaintenanceRequest", successor_id.to_string(), legacy_company,
440 serde_json::to_value(&spawn_event).map_err(|e| RequestWriteError::Invalid(e.to_string()))?,
441 Utc::now(),
442 );
443 backbone_outbox::outbox::stage(&mut *tx, "maintenance", &spawn_record)
444 .await
445 .map_err(|e| RequestWriteError::Invalid(format!("outbox stage: {e}")))?;
446 spawn_event_value = Some(spawn_event);
447 }
448
449 tx.commit().await?;
450 events.publish(&stage_event);
451 if let Some(ev) = spawn_event_value {
452 events.publish(&ev);
453 }
454
455 Ok(TransitionOutcome {
456 request_id,
457 from_stage_id: Some(from_stage_id),
458 to_stage_id: target.id,
459 close_date,
460 spawned_successor_id,
461 already: false,
462 })
463 }
464
465 async fn fetch(
467 &self,
468 pool: &PgPool,
469 request_id: Uuid,
470 ) -> Result<Option<RequestTransitionRow>, RequestWriteError> {
471 Ok(self.requests.fetch_for_transition(pool, request_id).await?)
472 }
473
474 fn guard_schedule(
475 schedule_date: Option<DateTime<Utc>>,
476 schedule_end: Option<DateTime<Utc>>,
477 ) -> Result<(), RequestWriteError> {
478 if let (Some(start), Some(end)) = (schedule_date, schedule_end) {
479 if end < start {
480 return Err(RequestWriteError::ScheduleEndBeforeStart);
481 }
482 }
483 Ok(())
484 }
485
486 fn guard_repeat_interval(repeat_interval: i32) -> Result<(), RequestWriteError> {
487 if repeat_interval < 1 {
488 return Err(RequestWriteError::RepeatIntervalBelowOne);
489 }
490 Ok(())
491 }
492
493 fn guard_repeat_until(repeat_type: &str, repeat_until: Option<NaiveDate>) -> Result<(), RequestWriteError> {
494 if repeat_type == "until" && repeat_until.is_none() {
495 return Err(RequestWriteError::RepeatUntilMissing);
496 }
497 Ok(())
498 }
499
500 fn guard_recurring(maintenance_type: &str, recurring: bool) -> Result<(), RequestWriteError> {
501 if maintenance_type == "corrective" && recurring {
502 return Err(RequestWriteError::CorrectiveCannotRecur);
503 }
504 Ok(())
505 }
506
507 fn guard(
508 schedule_date: Option<DateTime<Utc>>,
509 schedule_end: Option<DateTime<Utc>>,
510 repeat_interval: i32,
511 maintenance_type: MaintenanceType,
512 recurring: bool,
513 repeat_type: RepeatType,
514 repeat_until: Option<NaiveDate>,
515 ) -> Result<(), RequestWriteError> {
516 Self::guard_schedule(schedule_date, schedule_end)?;
517 Self::guard_repeat_interval(repeat_interval)?;
518 Self::guard_repeat_until(&repeat_type.to_string(), repeat_until)?;
519 Self::guard_recurring(&maintenance_type.to_string(), recurring)?;
520 Ok(())
521 }
522}
523
524fn advance(base: DateTime<Utc>, interval: i32, unit: &str) -> Option<DateTime<Utc>> {
527 match unit {
528 "day" => base.checked_add_signed(Duration::days(interval as i64)),
529 "week" => base.checked_add_signed(Duration::weeks(interval as i64)),
530 "month" => base
531 .checked_add_months(chrono::Months::new(u32::try_from(interval).ok()?)),
532 "year" => u32::try_from(interval)
534 .ok()
535 .and_then(|i| i.checked_mul(12))
536 .and_then(|m| base.checked_add_months(chrono::Months::new(m))),
537 _ => None,
538 }
539}