Skip to main content

teamcraft/services/
as_built.rs

1//! As-built records: hardware as it was really built.
2//!
3//! An **as-built** item (type `as_built`, numbers `AB-…`) is one physical
4//! unit or lot. It has the usual lifecycle, revisions, files (photos,
5//! measurement reports, a 3D scan, the traveller) and change orders. A new
6//! revision of the as-built item is a new state of the same unit (rework,
7//! repair, retrofit); the old revision stays for traceability. On top of
8//! that an as-built revision has
9//!
10//! * a **build record** – the design revision (part, assembly or software)
11//!   the unit was built to, serial or lot number, build date and note. The
12//!   serial number is unique per design item. The design revision may be in
13//!   work, in review or released (prototypes are built before the
14//!   release); an obsolete revision is refused for a new record;
15//! * **deviations** – how the unit departs from its design (kind, feature,
16//!   as designed, as built) and the **disposition** (open, use as is,
17//!   rework, repair, scrap);
18//! * its **BOM** – the regular BOM of the revision, holding what was really
19//!   installed. [`record_unit`] starts it as a copy of the design's BOM; the
20//!   BOM comparison between the design and the as-built revision shows the
21//!   differences.
22//!
23//! Record and deviations belong to the revision like BOM lines: changed only
24//! while it is checked out, copied by Revise, frozen at release. A change
25//! order with an as-built revision cannot be submitted while that revision
26//! has no build record or an open deviation (`ensure_ready`).
27
28use 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
42/// At most this many deviations per as-built revision.
43pub const MAX_DEVIATIONS: usize = 1000;
44
45/// At most this many built units are listed on a design item.
46pub const MAX_UNITS_LISTED: i64 = 500;
47
48// ---------------------------------------------------------------------------
49// Build record
50// ---------------------------------------------------------------------------
51
52/// What an as-built revision was built to.
53#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
54pub struct BuildRecord {
55    /// The as-built revision.
56    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    /// Newest released revision of the design item.
72    pub released_code: Option<String>,
73    #[serde(skip)]
74    pub released_seq: Option<i32>,
75    /// Set when a newer design revision was released after the one the unit
76    /// was built to (e.g. "PRT-000012/C is released (built to: B)").
77    #[sqlx(default)]
78    pub newer_design: Option<String>,
79    /// Project group of the design item (for the read check; never sent).
80    #[serde(skip)]
81    pub design_project_id: Option<Uuid>,
82    /// The reader may not see the design: its identity is removed.
83    #[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
118/// "PRT-1/C is released (built to: B)" when a design revision newer than
119/// the one the unit was built to is released (pure).
120pub 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
152/// The build record of an as-built revision the actor may read (`None`
153/// while none was entered).
154pub 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
169/// Fails unless the revision belongs to an as-built item.
170pub(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
181/// Locks an as-built revision the actor may edit.
182async 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
194/// Parses an optional build date (`YYYY-MM-DD`; empty = none).
195pub 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/// The build record as entered.
207#[derive(Clone, Debug, Default)]
208pub struct RecordInput {
209    /// Item number of the design.
210    pub design_number: String,
211    /// Revision code of the design; empty = its latest revision.
212    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
236/// Resolves the design revision a unit is built to (read-checked).
237async 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
257/// Writes the build record of the locked as-built revision `rev` inside the
258/// caller's transaction: type checks, the unique serial number per design
259/// item, upsert and audit entry.
260async 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    // Why the design item row is locked (the actor may only be able to read
281    // it, possibly in another group):
282    // * Serial numbers are unique per design item, but no unique index can
283    //   say so – the revisions of one unit share its serial number. Two
284    //   transactions recording the same serial would both pass the check
285    //   below; the row lock serialises them, so exactly one succeeds.
286    // * items::delete locks the row FOR UPDATE and then checks
287    //   `item_is_design`; holding the lock until commit keeps a design from
288    //   being deleted under a build record that is being written.
289    // FOR NO KEY UPDATE conflicts with both, but not with the FOR KEY SHARE
290    // locks that foreign-key checks (new BOM lines, references) take, so
291    // ordinary work on the design is not blocked.
292    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    // Units are recorded against in-work, in-review or released design
308    // revisions (prototypes are built before the release). An obsolete
309    // revision must not be used for new builds; a record that already
310    // names it (copied by Revise) may still be edited.
311    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
391/// Sets the build record of an as-built revision checked out by the actor.
392pub 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/// A new physical unit built to a design revision ([`record_unit`]).
410#[derive(Clone, Debug, Default)]
411pub struct NewUnit {
412    /// Item number of the as-built item; empty = generated (`AB-…`).
413    pub number: Option<String>,
414    /// Name of the as-built item; empty = "`<design name> – SN <serial>`".
415    pub name: String,
416    pub serial_number: String,
417    pub built_on: Option<NaiveDate>,
418    pub note: String,
419    /// Project group of the as-built item; `None` = the design's group.
420    pub project_id: Option<Uuid>,
421    /// Start the as-built BOM as a copy of the design's BOM.
422    pub with_bom: bool,
423}
424
425/// Records a physical unit built to the design revision `design_revision_id`:
426/// creates the as-built item (revision A, checked out by the actor) with its
427/// build record and, optionally, the design's BOM – in one transaction.
428pub 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    // The copied BOM takes the design's structure along: like Save As, out
444    // of a restricted group only for its managers.
445    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    // creation checks the role and the membership in the target group
451    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// ---------------------------------------------------------------------------
504// Deviations
505// ---------------------------------------------------------------------------
506
507/// How the built unit departs from its design.
508#[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    /// Who confirmed a "use as is" concession as a reviewer (cleared when the
524    /// deviation changes).
525    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
537/// The deviations of an as-built revision the actor may read.
538pub 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/// Deviation counts of an as-built revision.
565#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
566pub struct DeviationSummary {
567    pub total: usize,
568    /// Without a disposition yet (block the release).
569    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/// A deviation as entered.
594#[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
624/// Adds a deviation to an as-built revision checked out by the actor.
625pub 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/// Changed fields of a deviation; `None` keeps the stored value
685/// ([`patch_deviation`]).
686#[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
709/// Replaces every field of a deviation of an as-built revision checked out
710/// by the actor.
711pub 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
720/// Changes the given fields of a deviation of an as-built revision checked
721/// out by the actor. The stored deviation is read and merged while the
722/// revision row is locked – every writer of a deviation holds that lock –
723/// so two concurrent changes cannot overwrite each other's fields.
724pub async fn patch_deviation(
725    pool: &PgPool,
726    actor: &Actor,
727    id: Uuid,
728    patch: DeviationPatch,
729) -> AppResult<Deviation> {
730    // Read check first: a deviation of a hidden revision is "not found".
731    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        // the CHECK constraints keep the stored values parseable
744        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        // a changed deviation needs a new confirmation of its concession
760        "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
807/// Removes a deviation from an as-built revision checked out by the actor.
808pub 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
838// ---------------------------------------------------------------------------
839// Lifecycle hooks
840// ---------------------------------------------------------------------------
841
842/// Copies the build record and the deviations of `from` to the new revision
843/// `to` (Revise). Returns (records, deviations).
844pub(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
876/// Whether units were recorded as built to a revision of the item (such an
877/// item cannot be deleted).
878pub(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
887/// Fails when the as-built revision `rev` is not ready to be submitted for
888/// release: it has no build record or a deviation is still open. Other
889/// item types always pass.
890pub(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
917/// Fails when a change in review still carries a "use as is" deviation that
918/// its group wants confirmed (`concessions_need_review`) and nobody has
919/// confirmed yet – checked before a reviewer's approval counts.
920pub(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/// What the revision page shows about concessions: whether the group wants
954/// each "use as is" deviation confirmed, and whether the actor may confirm
955/// (a reviewer of a change order in review that contains the revision).
956#[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
987/// A reviewer confirms a "use as is" deviation of an as-built revision in
988/// review – the separate acceptance of a concession (#152 step 9).
989///
990/// # Invariants
991///
992/// * **Permission:** CAD editor who sees the revision (a hidden one is "not
993///   found"), is a reviewer of an open change order in review that contains
994///   it (403) and did not record the deviation (403, four-eyes).
995/// * **Data:** only "use as is" deviations (400); confirming again is a no-op.
996///   Any later change of the deviation clears the confirmation.
997/// * **Locks:** the deviation row (`FOR UPDATE`).
998/// * **Audit:** `as_built.concession_confirm` on the revision.
999/// * **Notifications / webhooks:** none.
1000pub 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// ---------------------------------------------------------------------------
1075// The design side
1076// ---------------------------------------------------------------------------
1077
1078/// A unit built to a revision of a design item (the latest revision of its
1079/// as-built item).
1080#[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    /// Set when a newer design revision is released.
1103    #[sqlx(default)]
1104    pub newer_design: Option<String>,
1105}
1106
1107/// Units built to any revision of the design item the actor may see (at
1108/// most [`MAX_UNITS_LISTED`]); the second value counts the hidden ones.
1109pub 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}