1use chrono::NaiveDate;
29use serde::Serialize;
30use serde_json::json;
31use sqlx::{PgConnection, PgPool};
32use uuid::Uuid;
33
34use crate::{
35 auth::Actor,
36 domain::{self, Classification, DeviationKind, Disposition, ItemType, LifecycleState},
37 error::{AppError, AppResult},
38 models::Revision,
39 services::{access, audit, items, revisions},
40};
41
42pub const MAX_DEVIATIONS: usize = 1000;
44
45pub const MAX_UNITS_LISTED: i64 = 500;
47
48#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
54pub struct BuildRecord {
55 pub revision_id: Uuid,
57 pub design_item_id: Uuid,
58 pub design_number: String,
59 pub design_type: String,
60 pub design_revision_id: Uuid,
61 pub design_code: String,
62 #[serde(skip)]
63 pub design_seq: i32,
64 pub design_state: String,
65 pub design_name: String,
66 pub serial_number: String,
67 pub built_on: Option<NaiveDate>,
68 pub note: String,
69 pub updated_by_name: String,
70 pub updated_at: chrono::DateTime<chrono::Utc>,
71 pub released_code: Option<String>,
73 #[serde(skip)]
74 pub released_seq: Option<i32>,
75 #[sqlx(default)]
78 pub newer_design: Option<String>,
79 #[serde(skip)]
81 pub design_project_id: Option<Uuid>,
82 #[sqlx(default)]
84 pub restricted: bool,
85}
86
87impl BuildRecord {
88 fn with_status(mut self) -> Self {
89 self.newer_design = newer_design(
90 &self.design_number,
91 &self.design_code,
92 self.design_seq,
93 self.released_code.as_deref(),
94 self.released_seq,
95 );
96 self
97 }
98
99 fn redact(mut self, actor: &Actor) -> Self {
100 if !actor.can_see_project(self.design_project_id) {
101 self.design_item_id = Uuid::nil();
102 self.design_number = access::RESTRICTED_LABEL.into();
103 self.design_type = String::new();
104 self.design_revision_id = Uuid::nil();
105 self.design_code = String::new();
106 self.design_state = String::new();
107 self.design_name = String::new();
108 self.released_code = None;
109 self.released_seq = None;
110 self.newer_design = None;
111 self.design_project_id = None;
112 self.restricted = true;
113 }
114 self
115 }
116}
117
118pub fn newer_design(
121 number: &str,
122 built_code: &str,
123 built_seq: i32,
124 released_code: Option<&str>,
125 released_seq: Option<i32>,
126) -> Option<String> {
127 match (released_code, released_seq) {
128 (Some(code), Some(seq)) if seq > built_seq => Some(format!(
129 "{number}/{code} is released (built to: {built_code})"
130 )),
131 _ => None,
132 }
133}
134
135const RECORD_SELECT: &str = "
136 SELECT rec.revision_id, di.id AS design_item_id, di.number AS design_number,
137 di.item_type AS design_type, dr.id AS design_revision_id, dr.code AS design_code,
138 dr.seq AS design_seq, dr.state AS design_state, dr.name AS design_name,
139 rec.serial_number, rec.built_on, rec.note, u.display_name AS updated_by_name,
140 rec.updated_at, lr.code AS released_code, lr.seq AS released_seq,
141 di.project_id AS design_project_id
142 FROM as_built_records rec
143 JOIN revisions dr ON dr.id = rec.design_revision_id
144 JOIN items di ON di.id = dr.item_id
145 JOIN users u ON u.id = rec.updated_by
146 LEFT JOIN LATERAL (
147 SELECT r.code, r.seq FROM revisions r
148 WHERE r.item_id = di.id AND r.released_at IS NOT NULL
149 ORDER BY r.seq DESC LIMIT 1
150 ) lr ON TRUE";
151
152pub async fn record(
155 pool: &PgPool,
156 actor: &Actor,
157 revision_id: Uuid,
158) -> AppResult<Option<BuildRecord>> {
159 revisions::get(pool, actor, revision_id).await?;
160 Ok(
161 sqlx::query_as::<_, BuildRecord>(&format!("{RECORD_SELECT} WHERE rec.revision_id = $1"))
162 .bind(revision_id)
163 .fetch_optional(pool)
164 .await?
165 .map(|r| r.with_status().redact(actor)),
166 )
167}
168
169pub(crate) fn ensure_as_built(rev: &Revision) -> AppResult<()> {
171 if rev.item_type == ItemType::AsBuilt.as_str() {
172 Ok(())
173 } else {
174 Err(AppError::BadRequest(format!(
175 "{} is not an as-built item; build records and deviations belong to items of type As-built.",
176 revisions::label(rev)
177 )))
178 }
179}
180
181async fn lock_editable(
183 conn: &mut PgConnection,
184 actor: &Actor,
185 revision_id: Uuid,
186) -> AppResult<Revision> {
187 let rev = revisions::lock(conn, revision_id).await?;
188 access::ensure_visible(actor, rev.project_id, access::not_found_revision())?;
189 ensure_as_built(&rev)?;
190 revisions::ensure_editable(&rev, actor)?;
191 Ok(rev)
192}
193
194pub fn parse_built_on(raw: Option<&str>) -> AppResult<Option<NaiveDate>> {
196 match raw.map(str::trim).filter(|s| !s.is_empty()) {
197 None => Ok(None),
198 Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")
199 .map(Some)
200 .map_err(|_| {
201 AppError::BadRequest(format!("Invalid build date '{s}': use YYYY-MM-DD."))
202 }),
203 }
204}
205
206#[derive(Clone, Debug, Default)]
208pub struct RecordInput {
209 pub design_number: String,
211 pub design_revision: String,
213 pub serial_number: String,
214 pub built_on: Option<NaiveDate>,
215 pub note: String,
216}
217
218struct Facts {
219 serial_number: String,
220 note: String,
221}
222
223fn validate_facts(serial_number: &str, note: &str) -> AppResult<Facts> {
224 let serial_number = domain::limit_text("Serial number", serial_number.trim(), 100)?;
225 if serial_number.chars().any(char::is_control) {
226 return Err(AppError::BadRequest(
227 "The serial number must be a single line.".into(),
228 ));
229 }
230 Ok(Facts {
231 serial_number,
232 note: domain::limit_text("Note", note, 2000)?,
233 })
234}
235
236async fn design_revision(
238 pool: &PgPool,
239 actor: &Actor,
240 number: &str,
241 code: &str,
242) -> AppResult<Revision> {
243 if number.trim().is_empty() {
244 return Err(AppError::BadRequest(
245 "Enter the item number of the design the unit was built to.".into(),
246 ));
247 }
248 let item = items::get_by_number(pool, actor, number).await?;
249 let code = code.trim();
250 if code.is_empty() || code.eq_ignore_ascii_case("latest") {
251 items::latest_revision(pool, actor, item.id).await
252 } else {
253 revisions::get_by_code(pool, actor, item.id, code).await
254 }
255}
256
257async fn write_record(
261 conn: &mut PgConnection,
262 actor: &Actor,
263 rev: &Revision,
264 design: &Revision,
265 facts: &Facts,
266 built_on: Option<NaiveDate>,
267) -> AppResult<()> {
268 let design_type = ItemType::parse(&design.item_type);
269 if !design_type.is_some_and(ItemType::can_be_built_to) {
270 return Err(AppError::BadRequest(format!(
271 "{} is {}; a unit is built to a part, an assembly or software.",
272 revisions::label(design),
273 design_type.map_or("of an unknown type", |t| match t {
274 ItemType::AsBuilt => "an as-built record itself",
275 ItemType::Simulation => "a simulation",
276 _ => "a document",
277 })
278 )));
279 }
280 let exists =
293 sqlx::query_scalar::<_, Uuid>("SELECT id FROM items WHERE id = $1 FOR NO KEY UPDATE")
294 .bind(design.item_id)
295 .fetch_optional(&mut *conn)
296 .await?
297 .is_some();
298 if !exists {
299 return Err(access::not_found_item());
300 }
301 let previous = sqlx::query_scalar::<_, Uuid>(
302 "SELECT design_revision_id FROM as_built_records WHERE revision_id = $1",
303 )
304 .bind(rev.id)
305 .fetch_optional(&mut *conn)
306 .await?;
307 if design.state == LifecycleState::Obsolete.as_str() && previous != Some(design.id) {
312 return Err(AppError::BadRequest(format!(
313 "{} is obsolete; record the unit against a current revision of the design.",
314 revisions::label(design)
315 )));
316 }
317 if !facts.serial_number.is_empty() {
318 let holder = sqlx::query_as::<_, (String, Option<Uuid>)>(
319 "SELECT ai.number, ai.project_id FROM as_built_records rec
320 JOIN revisions ar ON ar.id = rec.revision_id
321 JOIN items ai ON ai.id = ar.item_id
322 WHERE rec.design_item_id = $1 AND lower(rec.serial_number) = lower($2)
323 AND ar.item_id <> $3
324 LIMIT 1",
325 )
326 .bind(design.item_id)
327 .bind(&facts.serial_number)
328 .bind(rev.item_id)
329 .fetch_optional(&mut *conn)
330 .await?;
331 if let Some((number, project)) = holder {
332 let who = if actor.can_see_project(project) {
333 number
334 } else {
335 "another unit".into()
336 };
337 return Err(AppError::Conflict(format!(
338 "Serial number {} of {} is already recorded by {who}.",
339 facts.serial_number, design.item_number
340 )));
341 }
342 }
343 sqlx::query(
344 "INSERT INTO as_built_records (revision_id, design_revision_id, design_item_id,
345 serial_number, built_on, note, updated_by, updated_at)
346 VALUES ($1, $2, $3, $4, $5, $6, $7, now())
347 ON CONFLICT (revision_id) DO UPDATE SET
348 design_revision_id = EXCLUDED.design_revision_id,
349 design_item_id = EXCLUDED.design_item_id,
350 serial_number = EXCLUDED.serial_number, built_on = EXCLUDED.built_on,
351 note = EXCLUDED.note, updated_by = EXCLUDED.updated_by, updated_at = now()",
352 )
353 .bind(rev.id)
354 .bind(design.id)
355 .bind(design.item_id)
356 .bind(&facts.serial_number)
357 .bind(built_on)
358 .bind(&facts.note)
359 .bind(actor.id)
360 .execute(&mut *conn)
361 .await?;
362 let serial = if facts.serial_number.is_empty() {
363 String::new()
364 } else {
365 format!(", serial {}", facts.serial_number)
366 };
367 audit::record_related(
368 conn,
369 Some(actor),
370 if previous.is_some() {
371 "as_built.record.update"
372 } else {
373 "as_built.record.set"
374 },
375 "revision",
376 Some(rev.id),
377 Some(design.item_id),
378 format!(
379 "{} built to {}{serial}",
380 revisions::label(rev),
381 revisions::label(design)
382 ),
383 json!({ "item_id": rev.item_id, "design_revision_id": design.id,
384 "previous_design_revision_id": previous,
385 "serial_number": facts.serial_number, "built_on": built_on }),
386 )
387 .await?;
388 Ok(())
389}
390
391pub async fn set_record(
393 pool: &PgPool,
394 actor: &Actor,
395 revision_id: Uuid,
396 input: RecordInput,
397) -> AppResult<BuildRecord> {
398 let facts = validate_facts(&input.serial_number, &input.note)?;
399 let design = design_revision(pool, actor, &input.design_number, &input.design_revision).await?;
400 let mut tx = pool.begin().await?;
401 let rev = lock_editable(&mut tx, actor, revision_id).await?;
402 write_record(&mut tx, actor, &rev, &design, &facts, input.built_on).await?;
403 tx.commit().await?;
404 record(pool, actor, revision_id)
405 .await?
406 .ok_or_else(|| AppError::not_found("Build record not found."))
407}
408
409#[derive(Clone, Debug, Default)]
411pub struct NewUnit {
412 pub number: Option<String>,
414 pub name: String,
416 pub serial_number: String,
417 pub built_on: Option<NaiveDate>,
418 pub note: String,
419 pub project_id: Option<Uuid>,
421 pub with_bom: bool,
423}
424
425pub async fn record_unit(
429 pool: &PgPool,
430 actor: &Actor,
431 design_revision_id: Uuid,
432 input: NewUnit,
433) -> AppResult<Revision> {
434 let facts = validate_facts(&input.serial_number, &input.note)?;
435 let design = revisions::get(pool, actor, design_revision_id).await?;
436 let name = match input.name.trim() {
437 "" if facts.serial_number.is_empty() => format!("{} – as built", design.name),
438 "" => format!("{} – SN {}", design.name, facts.serial_number),
439 name => name.to_string(),
440 };
441 let target = input.project_id.or(design.project_id);
442 let mut tx = pool.begin().await?;
443 if let Some(source) = design.project_id {
446 if target != Some(source) && input.with_bom {
447 items::ensure_may_take_out(&mut tx, actor, source, "Copying").await?;
448 }
449 }
450 let created = items::create_tx(
452 &mut tx,
453 actor,
454 items::NewItem {
455 number: input.number.filter(|n| !n.trim().is_empty()),
456 item_type: ItemType::AsBuilt,
457 unit: design.unit.clone(),
458 project_id: target,
459 name,
460 description: String::new(),
461 material: design.material.clone(),
462 mass_kg: None,
463 unit_cost: None,
464 classification: Classification::default(),
465 attributes: Vec::new(),
466 },
467 )
468 .await?;
469 let rev = revisions::lock(&mut tx, created.revision_id).await?;
470 write_record(&mut tx, actor, &rev, &design, &facts, input.built_on).await?;
471 let mut lines = 0;
472 if input.with_bom {
473 lines = sqlx::query(
474 "INSERT INTO bom_lines (id, parent_revision_id, child_item_id, find_number, quantity, notes)
475 SELECT gen_random_uuid(), $2, child_item_id, find_number, quantity, notes
476 FROM bom_lines WHERE parent_revision_id = $1",
477 )
478 .bind(design.id)
479 .bind(rev.id)
480 .execute(&mut *tx)
481 .await?
482 .rows_affected();
483 }
484 audit::record_related(
485 &mut tx,
486 Some(actor),
487 "as_built.unit.create",
488 "item",
489 Some(created.item_id),
490 Some(design.item_id),
491 format!(
492 "Recorded {} as built to {}",
493 created.number,
494 revisions::label(&design)
495 ),
496 json!({ "revision_id": rev.id, "design_revision_id": design.id, "bom_lines": lines }),
497 )
498 .await?;
499 tx.commit().await?;
500 revisions::get(pool, actor, created.revision_id).await
501}
502
503#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
509pub struct Deviation {
510 pub id: Uuid,
511 pub revision_id: Uuid,
512 pub position: i32,
513 pub kind: String,
514 pub feature: String,
515 pub designed: String,
516 pub built: String,
517 pub disposition: String,
518 pub note: String,
519 pub created_by: Uuid,
520 pub created_by_name: String,
521 pub created_at: chrono::DateTime<chrono::Utc>,
522 pub updated_at: chrono::DateTime<chrono::Utc>,
523 pub concession_confirmed_by_name: Option<String>,
526 pub concession_confirmed_at: Option<chrono::DateTime<chrono::Utc>>,
527}
528
529const DEVIATION_SELECT: &str = "
530 SELECT d.id, d.revision_id, d.position, d.kind, d.feature, d.designed, d.built,
531 d.disposition, d.note, d.created_by, u.display_name AS created_by_name, d.created_at,
532 d.updated_at, cu.display_name AS concession_confirmed_by_name, d.concession_confirmed_at
533 FROM as_built_deviations d
534 JOIN users u ON u.id = d.created_by
535 LEFT JOIN users cu ON cu.id = d.concession_confirmed_by";
536
537pub async fn deviations(
539 pool: &PgPool,
540 actor: &Actor,
541 revision_id: Uuid,
542) -> AppResult<Vec<Deviation>> {
543 revisions::get(pool, actor, revision_id).await?;
544 Ok(sqlx::query_as::<_, Deviation>(&format!(
545 "{DEVIATION_SELECT} WHERE d.revision_id = $1 ORDER BY d.position, d.created_at"
546 ))
547 .bind(revision_id)
548 .fetch_all(pool)
549 .await?)
550}
551
552pub async fn get_deviation(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Deviation> {
553 let d = sqlx::query_as::<_, Deviation>(&format!("{DEVIATION_SELECT} WHERE d.id = $1"))
554 .bind(id)
555 .fetch_optional(pool)
556 .await?
557 .ok_or_else(|| AppError::not_found("Deviation not found."))?;
558 revisions::get(pool, actor, d.revision_id)
559 .await
560 .map_err(|_| AppError::not_found("Deviation not found."))?;
561 Ok(d)
562}
563
564#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
566pub struct DeviationSummary {
567 pub total: usize,
568 pub open: usize,
570 pub use_as_is: usize,
571 pub rework: usize,
572 pub repair: usize,
573 pub scrap: usize,
574}
575
576pub fn summarize(deviations: &[Deviation]) -> DeviationSummary {
577 let mut s = DeviationSummary {
578 total: deviations.len(),
579 ..DeviationSummary::default()
580 };
581 for d in deviations {
582 match Disposition::parse(&d.disposition) {
583 Some(Disposition::Open) | None => s.open += 1,
584 Some(Disposition::UseAsIs) => s.use_as_is += 1,
585 Some(Disposition::Rework) => s.rework += 1,
586 Some(Disposition::Repair) => s.repair += 1,
587 Some(Disposition::Scrap) => s.scrap += 1,
588 }
589 }
590 s
591}
592
593#[derive(Clone, Debug, serde::Deserialize)]
595pub struct DeviationInput {
596 pub kind: DeviationKind,
597 pub feature: String,
598 #[serde(default)]
599 pub designed: String,
600 #[serde(default)]
601 pub built: String,
602 #[serde(default = "open")]
603 pub disposition: Disposition,
604 #[serde(default)]
605 pub note: String,
606}
607
608fn open() -> Disposition {
609 Disposition::Open
610}
611
612impl DeviationInput {
613 pub fn validated(self) -> AppResult<Self> {
614 Ok(DeviationInput {
615 feature: domain::require_text("Feature", &self.feature, 200)?,
616 designed: domain::limit_text("As designed", self.designed.trim(), 200)?,
617 built: domain::limit_text("As built", self.built.trim(), 200)?,
618 note: domain::limit_text("Note", &self.note, 2000)?,
619 ..self
620 })
621 }
622}
623
624pub async fn add_deviation(
626 pool: &PgPool,
627 actor: &Actor,
628 revision_id: Uuid,
629 input: DeviationInput,
630) -> AppResult<Deviation> {
631 let input = input.validated()?;
632 let mut tx = pool.begin().await?;
633 let rev = lock_editable(&mut tx, actor, revision_id).await?;
634 let count = sqlx::query_scalar::<_, i64>(
635 "SELECT COUNT(*) FROM as_built_deviations WHERE revision_id = $1",
636 )
637 .bind(rev.id)
638 .fetch_one(&mut *tx)
639 .await?;
640 if usize::try_from(count).unwrap_or(usize::MAX) >= MAX_DEVIATIONS {
641 return Err(AppError::BadRequest(format!(
642 "An as-built revision can hold at most {MAX_DEVIATIONS} deviations."
643 )));
644 }
645 let id = Uuid::new_v4();
646 sqlx::query(
647 "INSERT INTO as_built_deviations (id, revision_id, position, kind, feature, designed, built,
648 disposition, note, created_by)
649 VALUES ($1, $2, (SELECT COALESCE(MAX(position), 0) + 1 FROM as_built_deviations WHERE revision_id = $2),
650 $3, $4, $5, $6, $7, $8, $9)",
651 )
652 .bind(id)
653 .bind(rev.id)
654 .bind(input.kind.as_str())
655 .bind(&input.feature)
656 .bind(&input.designed)
657 .bind(&input.built)
658 .bind(input.disposition.as_str())
659 .bind(&input.note)
660 .bind(actor.id)
661 .execute(&mut *tx)
662 .await?;
663 audit::record(
664 &mut tx,
665 Some(actor),
666 "as_built.deviation.add",
667 "revision",
668 Some(rev.id),
669 format!(
670 "{}: deviation {} ({}, {})",
671 revisions::label(&rev),
672 input.feature,
673 input.kind.label().to_lowercase(),
674 input.disposition.label().to_lowercase()
675 ),
676 json!({ "item_id": rev.item_id, "deviation_id": id,
677 "disposition": input.disposition.as_str() }),
678 )
679 .await?;
680 tx.commit().await?;
681 get_deviation(pool, actor, id).await
682}
683
684#[derive(Clone, Debug, Default)]
687pub struct DeviationPatch {
688 pub kind: Option<DeviationKind>,
689 pub feature: Option<String>,
690 pub designed: Option<String>,
691 pub built: Option<String>,
692 pub disposition: Option<Disposition>,
693 pub note: Option<String>,
694}
695
696impl From<DeviationInput> for DeviationPatch {
697 fn from(input: DeviationInput) -> Self {
698 DeviationPatch {
699 kind: Some(input.kind),
700 feature: Some(input.feature),
701 designed: Some(input.designed),
702 built: Some(input.built),
703 disposition: Some(input.disposition),
704 note: Some(input.note),
705 }
706 }
707}
708
709pub async fn update_deviation(
712 pool: &PgPool,
713 actor: &Actor,
714 id: Uuid,
715 input: DeviationInput,
716) -> AppResult<Deviation> {
717 patch_deviation(pool, actor, id, input.into()).await
718}
719
720pub async fn patch_deviation(
725 pool: &PgPool,
726 actor: &Actor,
727 id: Uuid,
728 patch: DeviationPatch,
729) -> AppResult<Deviation> {
730 let located = get_deviation(pool, actor, id).await?;
732 let mut tx = pool.begin().await?;
733 let rev = lock_editable(&mut tx, actor, located.revision_id).await?;
734 let existing = sqlx::query_as::<_, Deviation>(&format!(
735 "{DEVIATION_SELECT} WHERE d.id = $1 AND d.revision_id = $2"
736 ))
737 .bind(id)
738 .bind(rev.id)
739 .fetch_optional(&mut *tx)
740 .await?
741 .ok_or_else(|| AppError::not_found("Deviation not found."))?;
742 let input = DeviationInput {
743 kind: match patch.kind {
745 Some(kind) => kind,
746 None => DeviationKind::parse(&existing.kind).unwrap_or(DeviationKind::Other),
747 },
748 feature: patch.feature.unwrap_or_else(|| existing.feature.clone()),
749 designed: patch.designed.unwrap_or_else(|| existing.designed.clone()),
750 built: patch.built.unwrap_or_else(|| existing.built.clone()),
751 disposition: match patch.disposition {
752 Some(disposition) => disposition,
753 None => Disposition::parse(&existing.disposition).unwrap_or(Disposition::Open),
754 },
755 note: patch.note.unwrap_or_else(|| existing.note.clone()),
756 }
757 .validated()?;
758 let updated = sqlx::query(
759 "UPDATE as_built_deviations SET kind = $2, feature = $3, designed = $4, built = $5,
761 disposition = $6, note = $7, updated_at = now(),
762 concession_confirmed_by = NULL, concession_confirmed_at = NULL
763 WHERE id = $1",
764 )
765 .bind(id)
766 .bind(input.kind.as_str())
767 .bind(&input.feature)
768 .bind(&input.designed)
769 .bind(&input.built)
770 .bind(input.disposition.as_str())
771 .bind(&input.note)
772 .execute(&mut *tx)
773 .await?
774 .rows_affected();
775 if updated == 0 {
776 return Err(AppError::not_found("Deviation not found."));
777 }
778 let disposition = if existing.disposition == input.disposition.as_str() {
779 String::new()
780 } else {
781 format!(
782 ", disposition {} → {}",
783 Disposition::parse(&existing.disposition).map_or("?", Disposition::label),
784 input.disposition.label()
785 )
786 };
787 audit::record(
788 &mut tx,
789 Some(actor),
790 "as_built.deviation.update",
791 "revision",
792 Some(rev.id),
793 format!(
794 "{}: deviation {}{disposition}",
795 revisions::label(&rev),
796 input.feature
797 ),
798 json!({ "item_id": rev.item_id, "deviation_id": id,
799 "disposition": input.disposition.as_str(),
800 "previous_disposition": existing.disposition }),
801 )
802 .await?;
803 tx.commit().await?;
804 get_deviation(pool, actor, id).await
805}
806
807pub async fn remove_deviation(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Deviation> {
809 let existing = get_deviation(pool, actor, id).await?;
810 let mut tx = pool.begin().await?;
811 let rev = lock_editable(&mut tx, actor, existing.revision_id).await?;
812 let removed = sqlx::query("DELETE FROM as_built_deviations WHERE id = $1")
813 .bind(id)
814 .execute(&mut *tx)
815 .await?
816 .rows_affected();
817 if removed == 0 {
818 return Err(AppError::not_found("Deviation not found."));
819 }
820 audit::record(
821 &mut tx,
822 Some(actor),
823 "as_built.deviation.remove",
824 "revision",
825 Some(rev.id),
826 format!(
827 "{}: removed the deviation {}",
828 revisions::label(&rev),
829 existing.feature
830 ),
831 json!({ "item_id": rev.item_id, "deviation_id": id }),
832 )
833 .await?;
834 tx.commit().await?;
835 Ok(existing)
836}
837
838pub(crate) async fn copy_to_revision(
845 conn: &mut PgConnection,
846 from: Uuid,
847 to: Uuid,
848) -> AppResult<(u64, u64)> {
849 let records = sqlx::query(
850 "INSERT INTO as_built_records (revision_id, design_revision_id, design_item_id,
851 serial_number, built_on, note, updated_by, updated_at)
852 SELECT $2, design_revision_id, design_item_id, serial_number, built_on, note,
853 updated_by, updated_at
854 FROM as_built_records WHERE revision_id = $1",
855 )
856 .bind(from)
857 .bind(to)
858 .execute(&mut *conn)
859 .await?
860 .rows_affected();
861 let deviations = sqlx::query(
862 "INSERT INTO as_built_deviations (id, revision_id, position, kind, feature, designed, built,
863 disposition, note, created_by, created_at, updated_at)
864 SELECT gen_random_uuid(), $2, position, kind, feature, designed, built, disposition,
865 note, created_by, created_at, updated_at
866 FROM as_built_deviations WHERE revision_id = $1",
867 )
868 .bind(from)
869 .bind(to)
870 .execute(&mut *conn)
871 .await?
872 .rows_affected();
873 Ok((records, deviations))
874}
875
876pub(crate) async fn item_is_design(conn: &mut PgConnection, item_id: Uuid) -> AppResult<bool> {
879 Ok(sqlx::query_scalar::<_, bool>(
880 "SELECT EXISTS(SELECT 1 FROM as_built_records WHERE design_item_id = $1)",
881 )
882 .bind(item_id)
883 .fetch_one(&mut *conn)
884 .await?)
885}
886
887pub(crate) async fn ensure_ready(conn: &mut PgConnection, rev: &Revision) -> AppResult<()> {
891 if rev.item_type != ItemType::AsBuilt.as_str() {
892 return Ok(());
893 }
894 let (has_record, open) = sqlx::query_as::<_, (bool, i64)>(
895 "SELECT EXISTS(SELECT 1 FROM as_built_records WHERE revision_id = $1),
896 (SELECT COUNT(*) FROM as_built_deviations WHERE revision_id = $1 AND disposition = 'open')",
897 )
898 .bind(rev.id)
899 .fetch_one(&mut *conn)
900 .await?;
901 if !has_record {
902 return Err(AppError::Conflict(format!(
903 "{} has no build record; enter the design revision it was built to first.",
904 revisions::label(rev)
905 )));
906 }
907 if open > 0 {
908 return Err(AppError::Conflict(format!(
909 "{} has {open} open deviation{}; decide the disposition (use as is, rework, repair or scrap) first.",
910 revisions::label(rev),
911 if open == 1 { "" } else { "s" }
912 )));
913 }
914 Ok(())
915}
916
917pub(crate) async fn ensure_concessions_confirmed(
921 conn: &mut PgConnection,
922 change_id: Uuid,
923) -> AppResult<()> {
924 let waiting = sqlx::query_as::<_, (String, String)>(
925 "SELECT i.number || '/' || r.code, d.feature
926 FROM change_items ci
927 JOIN revisions r ON r.id = ci.revision_id
928 JOIN items i ON i.id = r.item_id
929 JOIN as_built_deviations d ON d.revision_id = r.id
930 JOIN project_policies pp ON pp.project_id = i.project_id AND pp.concessions_need_review
931 WHERE ci.change_id = $1 AND d.disposition = 'use_as_is'
932 AND d.concession_confirmed_by IS NULL
933 ORDER BY i.number, d.position, d.created_at
934 LIMIT 10",
935 )
936 .bind(change_id)
937 .fetch_all(&mut *conn)
938 .await?;
939 if waiting.is_empty() {
940 return Ok(());
941 }
942 let list: Vec<String> = waiting
943 .iter()
944 .map(|(rev, feature)| format!("{rev} {feature}"))
945 .collect();
946 Err(AppError::Conflict(format!(
947 "Confirm the \"use as is\" deviations first ({}): their project group wants a reviewer to \
948 accept each concession on its own.",
949 list.join(", ")
950 )))
951}
952
953#[derive(Clone, Copy, Debug, Default, Serialize)]
957pub struct ConcessionReview {
958 pub needed: bool,
959 pub can_confirm: bool,
960}
961
962pub async fn concession_review(
963 pool: &PgPool,
964 actor: &Actor,
965 rev: &Revision,
966) -> AppResult<ConcessionReview> {
967 let mut conn = pool.acquire().await?;
968 let needed =
969 crate::services::project_policy::concessions_need_review(&mut conn, rev.project_id).await?;
970 let can_confirm = actor.can_edit()
971 && sqlx::query_scalar::<_, bool>(
972 "SELECT EXISTS(SELECT 1 FROM changes c
973 JOIN change_items ci ON ci.change_id = c.id
974 JOIN change_reviews cr ON cr.change_id = c.id AND cr.reviewer_id = $2
975 WHERE ci.revision_id = $1 AND c.status = 'in_review')",
976 )
977 .bind(rev.id)
978 .bind(actor.id)
979 .fetch_one(&mut *conn)
980 .await?;
981 Ok(ConcessionReview {
982 needed,
983 can_confirm,
984 })
985}
986
987pub async fn confirm_concession(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Deviation> {
1001 actor.require(domain::Role::Editor)?;
1002 let located = get_deviation(pool, actor, id).await?;
1003 let mut tx = pool.begin().await?;
1004 let (revision_id, created_by, disposition, confirmed) =
1005 sqlx::query_as::<_, (Uuid, Uuid, String, bool)>(
1006 "SELECT revision_id, created_by, disposition, concession_confirmed_by IS NOT NULL
1007 FROM as_built_deviations WHERE id = $1 FOR UPDATE",
1008 )
1009 .bind(id)
1010 .fetch_optional(&mut *tx)
1011 .await?
1012 .ok_or_else(|| AppError::not_found("Deviation not found."))?;
1013 if disposition != Disposition::UseAsIs.as_str() {
1014 return Err(AppError::BadRequest(format!(
1015 "Only a \"use as is\" deviation is confirmed; {} is {}.",
1016 located.feature,
1017 Disposition::parse(&disposition).map_or("?", Disposition::label)
1018 )));
1019 }
1020 let rev = revisions::get(pool, actor, revision_id).await?;
1021 let change = sqlx::query_scalar::<_, String>(
1022 "SELECT c.number FROM changes c
1023 JOIN change_items ci ON ci.change_id = c.id
1024 JOIN change_reviews cr ON cr.change_id = c.id AND cr.reviewer_id = $2
1025 WHERE ci.revision_id = $1 AND c.status = 'in_review'
1026 LIMIT 1",
1027 )
1028 .bind(revision_id)
1029 .bind(actor.id)
1030 .fetch_optional(&mut *tx)
1031 .await?
1032 .ok_or_else(|| {
1033 AppError::Forbidden(format!(
1034 "Only a reviewer of the change order that releases {} confirms its \"use as is\" \
1035 deviations.",
1036 revisions::label(&rev)
1037 ))
1038 })?;
1039 if created_by == actor.id {
1040 return Err(AppError::Forbidden(format!(
1041 "You recorded the deviation {}; another reviewer confirms it (four-eyes principle).",
1042 located.feature
1043 )));
1044 }
1045 if !confirmed {
1046 sqlx::query(
1047 "UPDATE as_built_deviations SET concession_confirmed_by = $2,
1048 concession_confirmed_at = now()
1049 WHERE id = $1",
1050 )
1051 .bind(id)
1052 .bind(actor.id)
1053 .execute(&mut *tx)
1054 .await?;
1055 audit::record(
1056 &mut tx,
1057 Some(actor),
1058 "as_built.concession_confirm",
1059 "revision",
1060 Some(revision_id),
1061 format!(
1062 "{}: confirmed \"use as is\" for {} ({change})",
1063 revisions::label(&rev),
1064 located.feature
1065 ),
1066 json!({ "item_id": rev.item_id, "deviation_id": id, "change": change }),
1067 )
1068 .await?;
1069 }
1070 tx.commit().await?;
1071 get_deviation(pool, actor, id).await
1072}
1073
1074#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
1081pub struct BuiltUnit {
1082 pub unit_item_id: Uuid,
1083 pub unit_number: String,
1084 pub unit_revision_id: Uuid,
1085 pub unit_code: String,
1086 pub unit_state: String,
1087 pub unit_name: String,
1088 pub serial_number: String,
1089 pub built_on: Option<NaiveDate>,
1090 pub design_revision_id: Uuid,
1091 pub design_code: String,
1092 #[serde(skip)]
1093 pub design_seq: i32,
1094 #[serde(skip)]
1095 pub design_number: String,
1096 #[serde(skip)]
1097 pub released_code: Option<String>,
1098 #[serde(skip)]
1099 pub released_seq: Option<i32>,
1100 pub deviation_count: i64,
1101 pub open_count: i64,
1102 #[sqlx(default)]
1104 pub newer_design: Option<String>,
1105}
1106
1107pub async fn built_units(
1110 pool: &PgPool,
1111 actor: &Actor,
1112 design_item_id: Uuid,
1113) -> AppResult<(Vec<BuiltUnit>, i64)> {
1114 items::get(pool, actor, design_item_id).await?;
1115 let latest = "ar.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = ar.item_id)";
1116 let rows = sqlx::query_as::<_, BuiltUnit>(&format!(
1117 "SELECT ai.id AS unit_item_id, ai.number AS unit_number, ar.id AS unit_revision_id,
1118 ar.code AS unit_code, ar.state AS unit_state, ar.name AS unit_name,
1119 rec.serial_number, rec.built_on, dr.id AS design_revision_id,
1120 dr.code AS design_code, dr.seq AS design_seq, di.number AS design_number,
1121 lr.code AS released_code, lr.seq AS released_seq,
1122 (SELECT COUNT(*) FROM as_built_deviations d WHERE d.revision_id = ar.id) AS deviation_count,
1123 (SELECT COUNT(*) FROM as_built_deviations d WHERE d.revision_id = ar.id
1124 AND d.disposition = 'open') AS open_count
1125 FROM as_built_records rec
1126 JOIN revisions ar ON ar.id = rec.revision_id
1127 JOIN items ai ON ai.id = ar.item_id
1128 JOIN revisions dr ON dr.id = rec.design_revision_id
1129 JOIN items di ON di.id = dr.item_id
1130 LEFT JOIN LATERAL (
1131 SELECT r.code, r.seq FROM revisions r
1132 WHERE r.item_id = di.id AND r.released_at IS NOT NULL
1133 ORDER BY r.seq DESC LIMIT 1
1134 ) lr ON TRUE
1135 WHERE rec.design_item_id = $1 AND {latest} AND {}
1136 ORDER BY ai.number
1137 LIMIT $3",
1138 access::item_visible("ai", 2)
1139 ))
1140 .bind(design_item_id)
1141 .bind(&actor.hidden_projects)
1142 .bind(MAX_UNITS_LISTED)
1143 .fetch_all(pool)
1144 .await?
1145 .into_iter()
1146 .map(|mut u| {
1147 u.newer_design = newer_design(
1148 &u.design_number,
1149 &u.design_code,
1150 u.design_seq,
1151 u.released_code.as_deref(),
1152 u.released_seq,
1153 );
1154 u
1155 })
1156 .collect();
1157 let hidden = sqlx::query_scalar::<_, i64>(&format!(
1158 "SELECT COUNT(*) FROM as_built_records rec
1159 JOIN revisions ar ON ar.id = rec.revision_id
1160 JOIN items ai ON ai.id = ar.item_id
1161 WHERE rec.design_item_id = $1 AND {latest} AND NOT {}",
1162 access::item_visible("ai", 2)
1163 ))
1164 .bind(design_item_id)
1165 .bind(&actor.hidden_projects)
1166 .fetch_one(pool)
1167 .await?;
1168 Ok((rows, hidden))
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173 use super::*;
1174
1175 #[test]
1176 fn newer_design_only_when_a_later_revision_is_released() {
1177 assert_eq!(newer_design("PRT-1", "B", 2, None, None), None);
1178 assert_eq!(newer_design("PRT-1", "B", 2, Some("B"), Some(2)), None);
1179 assert_eq!(newer_design("PRT-1", "B", 2, Some("A"), Some(1)), None);
1180 assert_eq!(
1181 newer_design("PRT-1", "B", 2, Some("C"), Some(3)).as_deref(),
1182 Some("PRT-1/C is released (built to: B)")
1183 );
1184 }
1185
1186 #[test]
1187 fn build_dates_and_serials_are_validated() {
1188 assert_eq!(parse_built_on(None).unwrap(), None);
1189 assert_eq!(parse_built_on(Some(" ")).unwrap(), None);
1190 assert_eq!(
1191 parse_built_on(Some("2026-11-06")).unwrap(),
1192 NaiveDate::from_ymd_opt(2026, 11, 6)
1193 );
1194 assert!(parse_built_on(Some("06.11.2026")).is_err());
1195 assert_eq!(
1196 validate_facts(" SN-001 ", "").unwrap().serial_number,
1197 "SN-001"
1198 );
1199 assert!(validate_facts("SN\n2", "").is_err());
1200 assert!(validate_facts(&"9".repeat(101), "").is_err());
1201 }
1202
1203 fn deviation(disposition: &str) -> Deviation {
1204 Deviation {
1205 id: Uuid::nil(),
1206 revision_id: Uuid::nil(),
1207 position: 1,
1208 kind: "dimension".into(),
1209 feature: "Bore".into(),
1210 designed: String::new(),
1211 built: String::new(),
1212 disposition: disposition.into(),
1213 note: String::new(),
1214 created_by: Uuid::nil(),
1215 created_by_name: String::new(),
1216 created_at: chrono::Utc::now(),
1217 updated_at: chrono::Utc::now(),
1218 concession_confirmed_by_name: None,
1219 concession_confirmed_at: None,
1220 }
1221 }
1222
1223 #[test]
1224 fn summary_counts_dispositions() {
1225 let s = summarize(&[
1226 deviation("open"),
1227 deviation("use_as_is"),
1228 deviation("use_as_is"),
1229 deviation("scrap"),
1230 ]);
1231 assert_eq!(
1232 s,
1233 DeviationSummary {
1234 total: 4,
1235 open: 1,
1236 use_as_is: 2,
1237 rework: 0,
1238 repair: 0,
1239 scrap: 1,
1240 }
1241 );
1242 }
1243}