1use 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#[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 pub two_way: bool,
56 #[serde(skip)]
57 pub project_id: Option<Uuid>,
58 #[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
77const 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
90fn 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
105pub 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
130pub 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 pub number: String,
160 pub note: String,
161 pub two_way: bool,
164}
165
166pub 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, ¬e).await?;
205 let reverse = if input.two_way {
206 insert_alternate(&mut tx, actor, other.id, item.id, ¬e).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
237async 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
259pub 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
324pub 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
354pub 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
376pub 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
401pub 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, ¬e).await?;
444 tx.commit().await?;
445 Ok(())
446}
447
448pub(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
486pub(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 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
524async 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, ¬ice).await?;
562 Ok(())
563}
564
565pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
608#[serde(rename_all = "snake_case")]
609pub enum ReplaceAction {
610 Replace,
612 CheckedOut,
614 NoPermission,
616 InReview,
618 NeedsRevision,
620 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#[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#[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 pub replaced: usize,
670 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 " 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
772pub 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
785pub 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}