Skip to main content

teamcraft/services/
interchange.rs

1//! Interchangeable parts (feature 6, see
2//! `docs/src/research/supersede-alternates.md`):
3//!
4//! * **Alternates** – item B may be built in instead of item A in every
5//!   assembly (`item_alternates`, one row per direction; "two-way" stores
6//!   both). Item-level, not revisioned.
7//! * **Supersession** – item A is superseded by B: do not use A for new
8//!   designs any more (`item_supersessions`, at most one successor, no chain
9//!   back to A). The lifecycle state of A does not change.
10//! * **Replace in assemblies** – a mass change on the where-used list of A:
11//!   every assembly whose latest revision is in work and not checked out by
12//!   somebody else gets B instead of A (quantity, find number and notes
13//!   kept); released assemblies and assemblies in review are listed as
14//!   needing a new revision. [`plan_replace`] is the preview, [`replace`]
15//!   applies exactly the same plan under row locks.
16//!
17//! Items in groups the actor may not read are left out (and counted where a
18//! list is about usages of the actor's item).
19
20use std::collections::HashMap;
21
22use chrono::{DateTime, Utc};
23use serde::Serialize;
24use serde_json::json;
25use sqlx::{PgConnection, PgPool};
26use uuid::Uuid;
27
28use crate::{
29    auth::Actor,
30    domain::{self, LifecycleState, NotificationKind, Role},
31    error::{AppError, AppResult},
32    models::Item,
33    services::{
34        access, audit, bom, items,
35        notifications::{self, Notice},
36        project_policy,
37    },
38};
39
40/// An item linked to another one as alternate, successor or predecessor,
41/// with the data of its latest revision.
42#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
43pub struct LinkedItem {
44    pub item_id: Uuid,
45    pub number: String,
46    pub item_type: String,
47    pub name: String,
48    pub latest_revision_id: Uuid,
49    pub latest_code: String,
50    pub latest_state: String,
51    pub note: String,
52    pub created_by_name: String,
53    pub created_at: DateTime<Utc>,
54    /// Alternates only: the reverse direction exists too.
55    pub two_way: bool,
56    #[serde(skip)]
57    pub project_id: Option<Uuid>,
58    /// The item is in a group the actor may not read; everything but the
59    /// note-free placeholder is blanked (see `LinkedItem::mask`).
60    #[sqlx(default)]
61    pub restricted: bool,
62}
63
64impl LinkedItem {
65    fn mask(&mut self) {
66        self.restricted = true;
67        self.item_id = Uuid::nil();
68        self.number = "restricted item".into();
69        self.item_type = String::new();
70        self.name = String::new();
71        self.latest_revision_id = Uuid::nil();
72        self.latest_code = String::new();
73        self.latest_state = String::new();
74    }
75}
76
77/// `$link` is the join alias of the linked item (`li`); the outer query
78/// adds the relation table and the WHERE clause.
79const LINKED_COLUMNS: &str = "
80    li.id AS item_id, li.number, li.item_type, lr.name, lr.id AS latest_revision_id,
81    lr.code AS latest_code, lr.state AS latest_state, li.project_id,
82    u.display_name AS created_by_name";
83
84const LATEST_OF_LI: &str = "
85    CROSS JOIN LATERAL (
86        SELECT r.id, r.code, r.state, r.name FROM revisions r
87        WHERE r.item_id = li.id ORDER BY r.seq DESC LIMIT 1
88    ) lr";
89
90/// Fails unless the actor may change the interchange data of `item`: CAD
91/// editor (or admin) and member of the item's group.
92fn ensure_item_editable(actor: &Actor, item: &Item) -> AppResult<()> {
93    actor.require(Role::Editor)?;
94    if actor.can_edit_project(item.project_id) {
95        Ok(())
96    } else {
97        Err(AppError::Forbidden(format!(
98            "Only members of the project group {} can change {}.",
99            item.project_code.as_deref().unwrap_or("?"),
100            item.number
101        )))
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Alternates
107// ---------------------------------------------------------------------------
108
109/// Items that may be used instead of `item_id` (only those the actor may
110/// read).
111pub async fn alternates(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<Vec<LinkedItem>> {
112    Ok(sqlx::query_as::<_, LinkedItem>(&format!(
113        "SELECT {LINKED_COLUMNS}, a.note, a.created_at,
114                EXISTS (SELECT 1 FROM item_alternates b
115                        WHERE b.item_id = a.alternate_item_id AND b.alternate_item_id = a.item_id) AS two_way
116         FROM item_alternates a
117         JOIN items li ON li.id = a.alternate_item_id
118         JOIN users u ON u.id = a.created_by
119         {LATEST_OF_LI}
120         WHERE a.item_id = $1 AND {}
121         ORDER BY li.number",
122        access::item_visible("li", 2)
123    ))
124    .bind(item_id)
125    .bind(&actor.hidden_projects)
126    .fetch_all(pool)
127    .await?)
128}
129
130/// Items for which `item_id` is a one-way alternate (the two-way ones are
131/// already in [`alternates`]).
132pub async fn alternate_for(
133    pool: &PgPool,
134    actor: &Actor,
135    item_id: Uuid,
136) -> AppResult<Vec<LinkedItem>> {
137    Ok(sqlx::query_as::<_, LinkedItem>(&format!(
138        "SELECT {LINKED_COLUMNS}, a.note, a.created_at, FALSE AS two_way
139         FROM item_alternates a
140         JOIN items li ON li.id = a.item_id
141         JOIN users u ON u.id = a.created_by
142         {LATEST_OF_LI}
143         WHERE a.alternate_item_id = $1
144           AND NOT EXISTS (SELECT 1 FROM item_alternates b
145                           WHERE b.item_id = a.alternate_item_id AND b.alternate_item_id = a.item_id)
146           AND {}
147         ORDER BY li.number",
148        access::item_visible("li", 2)
149    ))
150    .bind(item_id)
151    .bind(&actor.hidden_projects)
152    .fetch_all(pool)
153    .await?)
154}
155
156#[derive(Clone, Debug)]
157pub struct NewAlternate {
158    /// Item number of the alternate.
159    pub number: String,
160    pub note: String,
161    /// Also store the reverse direction (the alternate may be replaced by
162    /// the item as well). Needs edit rights in the alternate's group too.
163    pub two_way: bool,
164}
165
166/// Declares `input.number` an alternate of `item_id`.
167///
168/// # Invariants
169///
170/// * **Permission:** both items visible to the actor ("not found" otherwise);
171///   CAD editor and member of the group of `item_id` (403). A two-way link
172///   also needs membership in the alternate's group (403). An item cannot be
173///   its own alternate (400).
174/// * **Locks:** none beyond the unique key of `item_alternates`
175///   (`ON CONFLICT DO NOTHING`); a link that exists in every requested
176///   direction is a 409.
177/// * **Audit:** `item.alternate.add`.
178/// * **Notifications / webhooks:** none.
179/// * **Atomic:** one transaction (both directions of a two-way link or none).
180pub async fn add_alternate(
181    pool: &PgPool,
182    actor: &Actor,
183    item_id: Uuid,
184    input: NewAlternate,
185) -> AppResult<()> {
186    let note = domain::limit_text("Note", &input.note, 500)?;
187    let item = items::get(pool, actor, item_id).await?;
188    ensure_item_editable(actor, &item)?;
189    let other = items::get_by_number(pool, actor, &input.number).await?;
190    if other.id == item.id {
191        return Err(AppError::BadRequest(
192            "An item cannot be an alternate of itself.".into(),
193        ));
194    }
195    if input.two_way && !actor.can_edit_project(other.project_id) {
196        return Err(AppError::Forbidden(format!(
197            "A two-way alternate also changes {}, which belongs to the project group {}. \
198             Add it as a one-way alternate or ask a member of that group.",
199            other.number,
200            other.project_code.as_deref().unwrap_or("?")
201        )));
202    }
203    let mut tx = pool.begin().await?;
204    let inserted = insert_alternate(&mut tx, actor, item.id, other.id, &note).await?;
205    let reverse = if input.two_way {
206        insert_alternate(&mut tx, actor, other.id, item.id, &note).await?
207    } else {
208        false
209    };
210    if !inserted && !reverse {
211        return Err(AppError::Conflict(format!(
212            "{} is already an alternate of {}.",
213            other.number, item.number
214        )));
215    }
216    let both = input.two_way;
217    audit::record_related(
218        &mut tx,
219        Some(actor),
220        "item.alternate.add",
221        "item",
222        Some(item.id),
223        Some(other.id),
224        format!(
225            "{} is {} alternate of {}",
226            other.number,
227            if both { "a two-way" } else { "an" },
228            item.number
229        ),
230        json!({ "item_id": item.id, "alternate_item_id": other.id, "two_way": both }),
231    )
232    .await?;
233    tx.commit().await?;
234    Ok(())
235}
236
237/// Inserts one direction; `false` if it existed already.
238async fn insert_alternate(
239    conn: &mut PgConnection,
240    actor: &Actor,
241    item_id: Uuid,
242    alternate_item_id: Uuid,
243    note: &str,
244) -> AppResult<bool> {
245    Ok(sqlx::query(
246        "INSERT INTO item_alternates (item_id, alternate_item_id, note, created_by)
247         VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
248    )
249    .bind(item_id)
250    .bind(alternate_item_id)
251    .bind(note)
252    .bind(actor.id)
253    .execute(&mut *conn)
254    .await?
255    .rows_affected()
256        == 1)
257}
258
259/// Removes the alternate `alternate_item_id` of `item_id` – both directions
260/// when the actor may also edit the alternate's group, otherwise only this
261/// one. Returns the number of directions removed.
262///
263/// # Invariants
264///
265/// * **Permission:** both items visible to the actor ("not found" otherwise);
266///   CAD editor and member of the group of `item_id` (403). The reverse
267///   direction is removed only when the actor may also edit the alternate's
268///   group. No such link is "not found".
269/// * **Locks:** none (plain deletes of the link rows).
270/// * **Audit:** `item.alternate.remove`.
271/// * **Notifications / webhooks:** none.
272/// * **Atomic:** one transaction.
273pub async fn remove_alternate(
274    pool: &PgPool,
275    actor: &Actor,
276    item_id: Uuid,
277    alternate_item_id: Uuid,
278) -> AppResult<u64> {
279    let item = items::get(pool, actor, item_id).await?;
280    ensure_item_editable(actor, &item)?;
281    let other = items::get(pool, actor, alternate_item_id).await?;
282    let mut tx = pool.begin().await?;
283    let mut removed =
284        sqlx::query("DELETE FROM item_alternates WHERE item_id = $1 AND alternate_item_id = $2")
285            .bind(item.id)
286            .bind(other.id)
287            .execute(&mut *tx)
288            .await?
289            .rows_affected();
290    if removed == 0 {
291        return Err(AppError::not_found(format!(
292            "{} is not an alternate of {}.",
293            other.number, item.number
294        )));
295    }
296    if actor.can_edit_project(other.project_id) {
297        removed += sqlx::query(
298            "DELETE FROM item_alternates WHERE item_id = $1 AND alternate_item_id = $2",
299        )
300        .bind(other.id)
301        .bind(item.id)
302        .execute(&mut *tx)
303        .await?
304        .rows_affected();
305    }
306    audit::record_related(
307        &mut tx,
308        Some(actor),
309        "item.alternate.remove",
310        "item",
311        Some(item.id),
312        Some(other.id),
313        format!(
314            "{} is no longer an alternate of {}",
315            other.number, item.number
316        ),
317        json!({ "item_id": item.id, "alternate_item_id": other.id, "directions": removed }),
318    )
319    .await?;
320    tx.commit().await?;
321    Ok(removed)
322}
323
324// ---------------------------------------------------------------------------
325// Supersession
326// ---------------------------------------------------------------------------
327
328/// The item that supersedes `item_id`; a successor in a group the actor may
329/// not read is a placeholder (the item is still superseded).
330pub async fn successor(
331    pool: &PgPool,
332    actor: &Actor,
333    item_id: Uuid,
334) -> AppResult<Option<LinkedItem>> {
335    let row = sqlx::query_as::<_, LinkedItem>(&format!(
336        "SELECT {LINKED_COLUMNS}, s.note, s.created_at, FALSE AS two_way
337         FROM item_supersessions s
338         JOIN items li ON li.id = s.successor_item_id
339         JOIN users u ON u.id = s.created_by
340         {LATEST_OF_LI}
341         WHERE s.item_id = $1"
342    ))
343    .bind(item_id)
344    .fetch_optional(pool)
345    .await?;
346    Ok(row.map(|mut r| {
347        if !actor.can_see_project(r.project_id) {
348            r.mask();
349        }
350        r
351    }))
352}
353
354/// Items that `item_id` supersedes (only those the actor may read).
355pub async fn predecessors(
356    pool: &PgPool,
357    actor: &Actor,
358    item_id: Uuid,
359) -> AppResult<Vec<LinkedItem>> {
360    Ok(sqlx::query_as::<_, LinkedItem>(&format!(
361        "SELECT {LINKED_COLUMNS}, s.note, s.created_at, FALSE AS two_way
362         FROM item_supersessions s
363         JOIN items li ON li.id = s.item_id
364         JOIN users u ON u.id = s.created_by
365         {LATEST_OF_LI}
366         WHERE s.successor_item_id = $1 AND {}
367         ORDER BY li.number",
368        access::item_visible("li", 2)
369    ))
370    .bind(item_id)
371    .bind(&actor.hidden_projects)
372    .fetch_all(pool)
373    .await?)
374}
375
376/// Which of the given items are superseded: item id → number of the
377/// successor (`None` when the successor is in a group the actor may not
378/// read). For marking BOM lines.
379pub async fn superseded_among(
380    pool: &PgPool,
381    actor: &Actor,
382    item_ids: &[Uuid],
383) -> AppResult<HashMap<Uuid, Option<String>>> {
384    if item_ids.is_empty() {
385        return Ok(HashMap::new());
386    }
387    let rows = sqlx::query_as::<_, (Uuid, String, Option<Uuid>)>(
388        "SELECT s.item_id, si.number, si.project_id
389         FROM item_supersessions s JOIN items si ON si.id = s.successor_item_id
390         WHERE s.item_id = ANY($1)",
391    )
392    .bind(item_ids)
393    .fetch_all(pool)
394    .await?;
395    Ok(rows
396        .into_iter()
397        .map(|(id, number, project)| (id, actor.can_see_project(project).then_some(number)))
398        .collect())
399}
400
401/// Declares `item_id` superseded by `successor_number` (replacing an earlier
402/// successor).
403///
404/// # Invariants
405///
406/// * **Permission:** both items visible to the actor ("not found" otherwise);
407///   CAD editor and member of the group of `item_id` (403). An item cannot
408///   supersede itself (400). A group with `obsolete_requires_change` refuses
409///   the direct supersession (409): it goes through a change order.
410/// * **Locks:** the table `item_supersessions` (`SHARE ROW EXCLUSIVE`), so
411///   two concurrent supersessions cannot close a circle unseen; a circle is
412///   a 409.
413/// * **Audit:** `item.supersede`.
414/// * **Notifications:** the watchers of `item_id` (also when the group does
415///   not require a change order); people who may not see the successor get a
416///   text without its number. **Webhooks:** none.
417/// * **Data:** an earlier successor of the item is replaced.
418/// * **Atomic:** one transaction.
419pub async fn supersede(
420    pool: &PgPool,
421    actor: &Actor,
422    item_id: Uuid,
423    successor_number: &str,
424    note: &str,
425) -> AppResult<()> {
426    let note = domain::limit_text("Note", note, 500)?;
427    let item = items::get(pool, actor, item_id).await?;
428    ensure_item_editable(actor, &item)?;
429    let successor = items::get_by_number(pool, actor, successor_number).await?;
430    if successor.id == item.id {
431        return Err(AppError::BadRequest(
432            "An item cannot supersede itself.".into(),
433        ));
434    }
435    let mut tx = pool.begin().await?;
436    if project_policy::obsolete_requires_change(&mut tx, item.project_id).await? {
437        return Err(AppError::Conflict(format!(
438            "{} is in project group {}, which requires a change order to supersede an item: add a supersede line to a change order.",
439            item.number,
440            item.project_code.as_deref().unwrap_or("?")
441        )));
442    }
443    supersede_in(&mut tx, actor, &item, &successor, &note).await?;
444    tx.commit().await?;
445    Ok(())
446}
447
448/// The supersession itself in the caller's transaction (permissions and the
449/// group setting are the caller's business): used by [`supersede`] and when a
450/// change order with a supersede line is released.
451pub(crate) async fn supersede_in(
452    tx: &mut PgConnection,
453    actor: &Actor,
454    item: &Item,
455    successor: &Item,
456    note: &str,
457) -> AppResult<()> {
458    ensure_no_circle(&mut *tx, item, successor).await?;
459    sqlx::query(
460        "INSERT INTO item_supersessions (item_id, successor_item_id, note, created_by)
461         VALUES ($1, $2, $3, $4)
462         ON CONFLICT (item_id) DO UPDATE
463         SET successor_item_id = EXCLUDED.successor_item_id, note = EXCLUDED.note,
464             created_by = EXCLUDED.created_by, created_at = now()",
465    )
466    .bind(item.id)
467    .bind(successor.id)
468    .bind(note)
469    .bind(actor.id)
470    .execute(&mut *tx)
471    .await?;
472    audit::record_related(
473        &mut *tx,
474        Some(actor),
475        "item.supersede",
476        "item",
477        Some(item.id),
478        Some(successor.id),
479        format!("{} is superseded by {}", item.number, successor.number),
480        json!({ "item_id": item.id, "successor_item_id": successor.id }),
481    )
482    .await?;
483    tell_supersession_watchers(tx, actor, item, successor).await
484}
485
486/// Refuses an item as its own successor (400) and a supersession that would
487/// go in a circle (409). Takes the table lock that serialises supersessions
488/// until the transaction ends.
489pub(crate) async fn ensure_no_circle(
490    tx: &mut PgConnection,
491    item: &Item,
492    successor: &Item,
493) -> AppResult<()> {
494    if successor.id == item.id {
495        return Err(AppError::BadRequest(
496            "An item cannot supersede itself.".into(),
497        ));
498    }
499    // serialise concurrent supersessions so the chain check below sees them
500    sqlx::query("LOCK TABLE item_supersessions IN SHARE ROW EXCLUSIVE MODE")
501        .execute(&mut *tx)
502        .await?;
503    let loops = sqlx::query_scalar::<_, bool>(
504        "WITH RECURSIVE chain(item_id) AS (
505             SELECT $1::uuid
506             UNION
507             SELECT s.successor_item_id FROM item_supersessions s JOIN chain c ON c.item_id = s.item_id
508         )
509         SELECT EXISTS (SELECT 1 FROM chain WHERE item_id = $2)",
510    )
511    .bind(successor.id)
512    .bind(item.id)
513    .fetch_one(&mut *tx)
514    .await?;
515    if loops {
516        return Err(AppError::Conflict(format!(
517            "{} is (directly or through other items) superseded by {} – the replacement would go in a circle.",
518            successor.number, item.number
519        )));
520    }
521    Ok(())
522}
523
524/// Tells the watchers of the superseded item.
525async fn tell_supersession_watchers(
526    tx: &mut PgConnection,
527    actor: &Actor,
528    item: &Item,
529    successor: &Item,
530) -> AppResult<()> {
531    let latest = sqlx::query_as::<_, (Uuid, String)>(
532        "SELECT id, code FROM revisions WHERE item_id = $1 ORDER BY seq DESC LIMIT 1",
533    )
534    .bind(item.id)
535    .fetch_optional(&mut *tx)
536    .await?;
537    let Some((revision_id, code)) = latest else {
538        return Ok(());
539    };
540    let watchers = notifications::watchers(tx, "item", &[item.id]).await?;
541    let notice = Notice::revision_ref(
542        NotificationKind::ItemUpdate,
543        revision_id,
544        item.id,
545        &item.number,
546        &code,
547        format!("{} is superseded", item.number),
548        format!(
549            "{} declared {} superseded by {}: do not use it for new designs.",
550            actor.display_name, item.number, successor.number
551        ),
552    )
553    .naming(
554        audit::Mentions::items([successor.id]),
555        format!("{} is superseded", item.number),
556        format!(
557            "{} declared {} superseded: do not use it for new designs.",
558            actor.display_name, item.number
559        ),
560    );
561    notifications::notify(tx, Some(actor), &watchers, &notice).await?;
562    Ok(())
563}
564
565/// Withdraws the supersession of `item_id`.
566///
567/// # Invariants
568///
569/// * **Permission:** the item is visible to the actor ("not found"
570///   otherwise); CAD editor and member of its group (403). An item that is
571///   not superseded is "not found".
572/// * **Locks:** none (a single `DELETE ... RETURNING`).
573/// * **Audit:** `item.supersede.clear`.
574/// * **Notifications / webhooks:** none.
575/// * **Atomic:** one transaction.
576pub async fn clear_supersession(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<()> {
577    let item = items::get(pool, actor, item_id).await?;
578    ensure_item_editable(actor, &item)?;
579    let mut tx = pool.begin().await?;
580    let successor = sqlx::query_scalar::<_, Uuid>(
581        "DELETE FROM item_supersessions WHERE item_id = $1 RETURNING successor_item_id",
582    )
583    .bind(item.id)
584    .fetch_optional(&mut *tx)
585    .await?
586    .ok_or_else(|| AppError::not_found(format!("{} is not superseded.", item.number)))?;
587    audit::record_related(
588        &mut tx,
589        Some(actor),
590        "item.supersede.clear",
591        "item",
592        Some(item.id),
593        Some(successor),
594        format!("{} is no longer superseded", item.number),
595        json!({ "item_id": item.id, "successor_item_id": successor }),
596    )
597    .await?;
598    tx.commit().await?;
599    Ok(())
600}
601
602// ---------------------------------------------------------------------------
603// Replace in assemblies
604// ---------------------------------------------------------------------------
605
606/// What [`replace`] does with one BOM line.
607#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
608#[serde(rename_all = "snake_case")]
609pub enum ReplaceAction {
610    /// In work and free or checked out by the actor: the line is changed.
611    Replace,
612    /// In work, but checked out by somebody else.
613    CheckedOut,
614    /// In work, but the actor is not a member of the parent's group.
615    NoPermission,
616    /// In review: frozen until the review is finished or rejected.
617    InReview,
618    /// Released: needs a new revision (Revise, usually in a change order).
619    NeedsRevision,
620    /// The replacement contains the parent: replacing would create a cycle.
621    Cycle,
622}
623
624impl ReplaceAction {
625    pub fn label(self) -> &'static str {
626        match self {
627            ReplaceAction::Replace => "will be replaced",
628            ReplaceAction::CheckedOut => "checked out by somebody else",
629            ReplaceAction::NoPermission => "not your project group",
630            ReplaceAction::InReview => "in review – finish or reject the review first",
631            ReplaceAction::NeedsRevision => "released – needs a new revision",
632            ReplaceAction::Cycle => "would create a circular BOM",
633        }
634    }
635}
636
637/// One BOM line of the latest revision of an assembly that uses the item.
638#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
639pub struct ReplaceRow {
640    pub line_id: Uuid,
641    pub find_number: i32,
642    pub quantity: f64,
643    pub parent_item_id: Uuid,
644    pub parent_number: String,
645    pub parent_revision_id: Uuid,
646    pub parent_code: String,
647    pub parent_name: String,
648    pub parent_state: String,
649    #[serde(skip)]
650    pub project_id: Option<Uuid>,
651    #[serde(skip)]
652    pub checked_out_by: Option<Uuid>,
653    pub checked_out_by_name: Option<String>,
654    #[sqlx(skip)]
655    pub action: Option<ReplaceAction>,
656    #[sqlx(skip)]
657    pub action_label: &'static str,
658}
659
660/// The preview (or, after [`replace`], the outcome) of a replacement.
661#[derive(Clone, Debug, Serialize)]
662pub struct ReplacePlan {
663    pub old_item_id: Uuid,
664    pub old_number: String,
665    pub new_item_id: Uuid,
666    pub new_number: String,
667    pub rows: Vec<ReplaceRow>,
668    /// Lines that are (or were) replaced.
669    pub replaced: usize,
670    /// Assemblies using the old item that the actor may not read.
671    pub hidden: i64,
672}
673
674const REPLACE_ROWS: &str = "
675    SELECT bl.id AS line_id, bl.find_number, bl.quantity, i.id AS parent_item_id,
676           i.number AS parent_number, r.id AS parent_revision_id, r.code AS parent_code,
677           r.name AS parent_name, r.state AS parent_state, i.project_id, r.checked_out_by,
678           u.display_name AS checked_out_by_name
679    FROM bom_lines bl
680    JOIN revisions r ON r.id = bl.parent_revision_id
681    JOIN items i ON i.id = r.item_id
682    LEFT JOIN users u ON u.id = r.checked_out_by
683    WHERE bl.child_item_id = $1 AND r.state <> 'obsolete'
684      AND r.seq = (SELECT MAX(r2.seq) FROM revisions r2 WHERE r2.item_id = r.item_id)";
685
686async fn decide(
687    conn: &mut PgConnection,
688    actor: &Actor,
689    new_item_id: Uuid,
690    row: &mut ReplaceRow,
691) -> AppResult<()> {
692    let state = LifecycleState::parse(&row.parent_state).unwrap_or(LifecycleState::Obsolete);
693    let action = match state {
694        LifecycleState::InWork => {
695            if !actor.can_edit() || !actor.can_edit_project(row.project_id) {
696                ReplaceAction::NoPermission
697            } else if row.checked_out_by.is_some_and(|u| u != actor.id) {
698                ReplaceAction::CheckedOut
699            } else if bom::creates_cycle(conn, row.parent_item_id, new_item_id).await? {
700                ReplaceAction::Cycle
701            } else {
702                ReplaceAction::Replace
703            }
704        }
705        LifecycleState::InReview => ReplaceAction::InReview,
706        _ => ReplaceAction::NeedsRevision,
707    };
708    row.action = Some(action);
709    row.action_label = action.label();
710    Ok(())
711}
712
713async fn load_plan(
714    conn: &mut PgConnection,
715    actor: &Actor,
716    old: &Item,
717    new: &Item,
718    lock: bool,
719) -> AppResult<ReplacePlan> {
720    if old.id == new.id {
721        return Err(AppError::BadRequest(
722            "Choose a different item as the replacement.".into(),
723        ));
724    }
725    let lock_clause = if lock {
726        // lock the parent revisions in a fixed order (no deadlocks between
727        // two replacements)
728        " ORDER BY r.id, bl.id FOR UPDATE OF r, bl"
729    } else {
730        ""
731    };
732    let mut rows = sqlx::query_as::<_, ReplaceRow>(&format!(
733        "{REPLACE_ROWS} AND {}{lock_clause}",
734        access::item_visible("i", 2)
735    ))
736    .bind(old.id)
737    .bind(&actor.hidden_projects)
738    .fetch_all(&mut *conn)
739    .await?;
740    rows.sort_by(|a, b| (&a.parent_number, a.find_number).cmp(&(&b.parent_number, b.find_number)));
741    for row in &mut rows {
742        decide(conn, actor, new.id, row).await?;
743    }
744    let hidden = if actor.hidden_projects.is_empty() {
745        0
746    } else {
747        sqlx::query_scalar::<_, i64>(&format!(
748            "SELECT COUNT(DISTINCT i.id) FROM ({REPLACE_ROWS}) x JOIN items i ON i.id = x.parent_item_id
749             WHERE NOT {}",
750            access::item_visible("i", 2)
751        ))
752        .bind(old.id)
753        .bind(&actor.hidden_projects)
754        .fetch_one(&mut *conn)
755        .await?
756    };
757    let replaced = rows
758        .iter()
759        .filter(|r| r.action == Some(ReplaceAction::Replace))
760        .count();
761    Ok(ReplacePlan {
762        old_item_id: old.id,
763        old_number: old.number.clone(),
764        new_item_id: new.id,
765        new_number: new.number.clone(),
766        rows,
767        replaced,
768        hidden,
769    })
770}
771
772/// Preview: what [`replace`] would do with every assembly using `old_item_id`.
773pub async fn plan_replace(
774    pool: &PgPool,
775    actor: &Actor,
776    old_item_id: Uuid,
777    new_number: &str,
778) -> AppResult<ReplacePlan> {
779    let old = items::get(pool, actor, old_item_id).await?;
780    let new = items::get_by_number(pool, actor, new_number).await?;
781    let mut conn = pool.acquire().await?;
782    load_plan(&mut conn, actor, &old, &new, false).await
783}
784
785/// Replaces `old_item_id` by `new_number` in every assembly the plan marks
786/// [`ReplaceAction::Replace`], in one transaction. A revision that is not
787/// checked out is changed directly (like check out, edit, check in); one
788/// checked out by the actor stays checked out. Fails when nothing can be
789/// replaced.
790///
791/// # Invariants
792///
793/// * **Permission:** CAD editor; both items visible to the actor. Per line:
794///   the parent is in work, in a group the actor may edit, not checked out
795///   by somebody else and would not contain itself – otherwise the line is
796///   reported, not changed.
797/// * **Locks:** the parent revisions and their BOM lines (`FOR UPDATE`, in id
798///   order, so two replacements never deadlock).
799/// * **Audit:** `bom.replace` per replaced line.
800/// * **Notifications / webhooks:** none.
801/// * **Atomic:** one transaction (`tests/atomic_processes.rs`); a second run at
802///   once finds nothing left and gets a 409.
803pub async fn replace(
804    pool: &PgPool,
805    actor: &Actor,
806    old_item_id: Uuid,
807    new_number: &str,
808) -> AppResult<ReplacePlan> {
809    actor.require(Role::Editor)?;
810    let old = items::get(pool, actor, old_item_id).await?;
811    let new = items::get_by_number(pool, actor, new_number).await?;
812    let mut tx = pool.begin().await?;
813    let plan = load_plan(&mut tx, actor, &old, &new, true).await?;
814    if plan.replaced == 0 {
815        return Err(AppError::Conflict(format!(
816            "No assembly using {} can be changed right now – see the list for the reasons.",
817            old.number
818        )));
819    }
820    for row in plan
821        .rows
822        .iter()
823        .filter(|r| r.action == Some(ReplaceAction::Replace))
824    {
825        sqlx::query(
826            "UPDATE bom_lines SET child_item_id = $2, released_child_revision_id = NULL WHERE id = $1",
827        )
828        .bind(row.line_id)
829        .bind(new.id)
830        .execute(&mut *tx)
831        .await?;
832        sqlx::query("UPDATE revisions SET updated_at = now() WHERE id = $1")
833            .bind(row.parent_revision_id)
834            .execute(&mut *tx)
835            .await?;
836        audit::record_related(
837            &mut tx,
838            Some(actor),
839            "bom.replace",
840            "revision",
841            Some(row.parent_revision_id),
842            Some(new.id),
843            format!(
844                "Replaced {} by {} in {}/{} (find no. {})",
845                old.number, new.number, row.parent_number, row.parent_code, row.find_number
846            ),
847            json!({ "item_id": row.parent_item_id, "line_id": row.line_id,
848                    "old_item_id": old.id, "new_item_id": new.id }),
849        )
850        .await?;
851    }
852    tx.commit().await?;
853    Ok(plan)
854}