1use std::collections::{HashMap, HashSet};
6
7use serde::Serialize;
8use serde_json::json;
9use sqlx::{PgConnection, PgPool};
10use uuid::Uuid;
11
12use crate::{
13 auth::Actor,
14 domain::{self, RevisionRule},
15 error::{AppError, AppResult},
16 models::{BomLine, Revision},
17 services::{access, audit, items, revisions},
18 util,
19};
20
21const MAX_EXPANDED_REVISIONS: usize = 5000;
23const MAX_DEPTH: usize = 64;
25
26const LINE_SELECT: &str = "
27 SELECT bl.id, bl.parent_revision_id, bl.child_item_id, i.number AS child_number,
28 i.item_type AS child_type, i.unit AS child_unit, bl.find_number, bl.quantity, bl.notes,
29 lr.id AS child_latest_revision_id, lr.code AS child_latest_code,
30 lr.state AS child_latest_state, lr.name AS child_name,
31 bl.released_child_revision_id, rc.code AS released_child_code,
32 i.project_id AS child_project_id
33 FROM bom_lines bl
34 JOIN items i ON i.id = bl.child_item_id
35 CROSS JOIN LATERAL (
36 SELECT r.id, r.code, r.state, r.name FROM revisions r
37 WHERE r.item_id = i.id ORDER BY r.seq DESC LIMIT 1
38 ) lr
39 LEFT JOIN revisions rc ON rc.id = bl.released_child_revision_id";
40
41pub async fn lines(
44 pool: &PgPool,
45 actor: &Actor,
46 parent_revision_id: Uuid,
47) -> AppResult<Vec<BomLine>> {
48 revisions::get(pool, actor, parent_revision_id).await?;
49 let mut rows = lines_unchecked(pool, parent_revision_id).await?;
50 for l in &mut rows {
51 if !actor.can_see_project(l.child_project_id) {
52 l.mask();
53 }
54 }
55 Ok(rows)
56}
57
58pub(crate) async fn lines_unchecked(
59 pool: &PgPool,
60 parent_revision_id: Uuid,
61) -> AppResult<Vec<BomLine>> {
62 Ok(sqlx::query_as::<_, BomLine>(&format!(
63 "{LINE_SELECT} WHERE bl.parent_revision_id = $1 ORDER BY bl.find_number"
64 ))
65 .bind(parent_revision_id)
66 .fetch_all(pool)
67 .await?)
68}
69
70pub async fn line(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<BomLine> {
72 let l = line_unmasked(pool, actor, id).await?;
73 Ok(masked_for(actor, l))
74}
75
76async fn line_unmasked(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<BomLine> {
79 let l = sqlx::query_as::<_, BomLine>(&format!("{LINE_SELECT} WHERE bl.id = $1"))
80 .bind(id)
81 .fetch_optional(pool)
82 .await?
83 .ok_or_else(|| AppError::not_found("BOM line not found."))?;
84 revisions::get(pool, actor, l.parent_revision_id)
85 .await
86 .map_err(|_| AppError::not_found("BOM line not found."))?;
87 Ok(l)
88}
89
90fn masked_for(actor: &Actor, mut l: BomLine) -> BomLine {
91 if !actor.can_see_project(l.child_project_id) {
92 l.mask();
93 }
94 l
95}
96
97pub(crate) async fn creates_cycle(
101 conn: &mut PgConnection,
102 parent_item_id: Uuid,
103 child_item_id: Uuid,
104) -> AppResult<bool> {
105 if parent_item_id == child_item_id {
106 return Ok(true);
107 }
108 let cycle = sqlx::query_scalar::<_, bool>(
109 "WITH RECURSIVE descendants(item_id) AS (
110 SELECT $1::uuid
111 UNION
112 SELECT bl.child_item_id
113 FROM bom_lines bl
114 JOIN revisions r ON r.id = bl.parent_revision_id
115 JOIN descendants d ON d.item_id = r.item_id
116 )
117 SELECT EXISTS (SELECT 1 FROM descendants WHERE item_id = $2)",
118 )
119 .bind(child_item_id)
120 .bind(parent_item_id)
121 .fetch_one(&mut *conn)
122 .await?;
123 Ok(cycle)
124}
125
126#[derive(Clone, Debug)]
127pub struct NewBomLine {
128 pub child_number: String,
130 pub quantity: f64,
131 pub find_number: Option<i32>,
133 pub notes: String,
134}
135
136pub async fn add(
149 pool: &PgPool,
150 actor: &Actor,
151 parent_revision_id: Uuid,
152 input: NewBomLine,
153) -> AppResult<BomLine> {
154 let quantity = domain::validate_quantity(input.quantity)?;
155 let notes = domain::limit_text("Notes", &input.notes, 500)?;
156 if let Some(f) = input.find_number {
157 if f <= 0 {
158 return Err(AppError::BadRequest(
159 "Find numbers must be positive.".into(),
160 ));
161 }
162 }
163 let child = items::get_by_number(pool, actor, &input.child_number).await?;
164
165 let mut tx = pool.begin().await?;
166 let parent = revisions::lock(&mut tx, parent_revision_id).await?;
167 let id = add_tx(
168 &mut tx,
169 actor,
170 &parent,
171 child.id,
172 &child.number,
173 quantity,
174 input.find_number,
175 ¬es,
176 )
177 .await?;
178 tx.commit().await?;
179 line(pool, actor, id).await
180}
181
182#[allow(clippy::too_many_arguments)]
185pub(crate) async fn add_tx(
186 conn: &mut PgConnection,
187 actor: &Actor,
188 parent: &Revision,
189 child_id: Uuid,
190 child_number: &str,
191 quantity: f64,
192 find_number: Option<i32>,
193 notes: &str,
194) -> AppResult<Uuid> {
195 let tx = conn;
196 revisions::ensure_editable(parent, actor)?;
197 if creates_cycle(tx, parent.item_id, child_id).await? {
198 return Err(AppError::Conflict(format!(
199 "Adding {} to {} would create a circular BOM structure.",
200 child_number,
201 revisions::label(parent)
202 )));
203 }
204 let find_number = match find_number {
205 Some(f) => f,
206 None => {
207 let max = sqlx::query_scalar::<_, i32>(
208 "SELECT COALESCE(MAX(find_number), 0) FROM bom_lines WHERE parent_revision_id = $1",
209 )
210 .bind(parent.id)
211 .fetch_one(&mut *tx)
212 .await?;
213 (max / 10 + 1) * 10
214 }
215 };
216 let id = Uuid::new_v4();
217 sqlx::query(
218 "INSERT INTO bom_lines (id, parent_revision_id, child_item_id, find_number, quantity, notes)
219 VALUES ($1, $2, $3, $4, $5, $6)",
220 )
221 .bind(id)
222 .bind(parent.id)
223 .bind(child_id)
224 .bind(find_number)
225 .bind(quantity)
226 .bind(notes)
227 .execute(&mut *tx)
228 .await
229 .map_err(|e| AppError::unique_violation(e, format!("Find number {find_number} is already used in this BOM.")))?;
230 touch(tx, parent.id).await?;
231 audit::record_related(
232 tx,
233 Some(actor),
234 "bom.add",
235 "revision",
236 Some(parent.id),
237 Some(child_id),
238 format!(
239 "Added {} × {} (find no. {find_number}) to {}",
240 util::format_number(quantity, 4),
241 child_number,
242 revisions::label(parent)
243 ),
244 json!({ "item_id": parent.item_id, "line_id": id, "child_item_id": child_id }),
245 )
246 .await?;
247 Ok(id)
248}
249
250#[derive(Clone, Debug)]
251pub struct BomLineUpdate {
252 pub quantity: f64,
253 pub find_number: i32,
254 pub notes: String,
255}
256
257pub async fn update(
269 pool: &PgPool,
270 actor: &Actor,
271 line_id: Uuid,
272 upd: BomLineUpdate,
273) -> AppResult<BomLine> {
274 let quantity = domain::validate_quantity(upd.quantity)?;
275 let notes = domain::limit_text("Notes", &upd.notes, 500)?;
276 if upd.find_number <= 0 {
277 return Err(AppError::BadRequest(
278 "Find numbers must be positive.".into(),
279 ));
280 }
281 let existing = line_unmasked(pool, actor, line_id).await?;
282 let notes = if actor.can_see_project(existing.child_project_id) {
285 notes
286 } else {
287 existing.notes.clone()
288 };
289 let mut tx = pool.begin().await?;
290 let parent = revisions::lock(&mut tx, existing.parent_revision_id).await?;
291 revisions::ensure_editable(&parent, actor)?;
292 sqlx::query("UPDATE bom_lines SET quantity = $2, find_number = $3, notes = $4 WHERE id = $1")
293 .bind(line_id)
294 .bind(quantity)
295 .bind(upd.find_number)
296 .bind(¬es)
297 .execute(&mut *tx)
298 .await
299 .map_err(|e| {
300 AppError::unique_violation(
301 e,
302 format!(
303 "Find number {} is already used in this BOM.",
304 upd.find_number
305 ),
306 )
307 })?;
308 touch(&mut tx, parent.id).await?;
309 audit::record_related(
310 &mut tx,
311 Some(actor),
312 "bom.update",
313 "revision",
314 Some(parent.id),
315 Some(existing.child_item_id),
316 format!(
317 "Changed BOM line {} of {}: {} × {} (find no. {})",
318 existing.child_number,
319 revisions::label(&parent),
320 util::format_number(quantity, 4),
321 existing.child_number,
322 upd.find_number
323 ),
324 json!({ "item_id": parent.item_id, "line_id": line_id }),
325 )
326 .await?;
327 tx.commit().await?;
328 line(pool, actor, line_id).await
329}
330
331pub async fn remove(pool: &PgPool, actor: &Actor, line_id: Uuid) -> AppResult<BomLine> {
343 let existing = line_unmasked(pool, actor, line_id).await?;
344 let mut tx = pool.begin().await?;
345 let parent = revisions::lock(&mut tx, existing.parent_revision_id).await?;
346 revisions::ensure_editable(&parent, actor)?;
347 sqlx::query("DELETE FROM bom_lines WHERE id = $1")
348 .bind(line_id)
349 .execute(&mut *tx)
350 .await?;
351 touch(&mut tx, parent.id).await?;
352 audit::record_related(
353 &mut tx,
354 Some(actor),
355 "bom.remove",
356 "revision",
357 Some(parent.id),
358 Some(existing.child_item_id),
359 format!(
360 "Removed {} from {}",
361 existing.child_number,
362 revisions::label(&parent)
363 ),
364 json!({ "item_id": parent.item_id, "line_id": line_id }),
365 )
366 .await?;
367 tx.commit().await?;
368 Ok(masked_for(actor, existing))
369}
370
371pub(crate) async fn touch(conn: &mut PgConnection, revision_id: Uuid) -> AppResult<()> {
372 sqlx::query("UPDATE revisions SET updated_at = now() WHERE id = $1")
373 .bind(revision_id)
374 .execute(&mut *conn)
375 .await?;
376 Ok(())
377}
378
379const MAX_EXPANDED_LINES: usize = 100_000;
396
397#[derive(Clone, Debug, Serialize)]
399pub struct ResolvedRevision {
400 pub id: Uuid,
401 pub code: String,
402 pub state: String,
403 pub name: String,
404 pub mass_kg: Option<f64>,
405 pub unit_cost: Option<f64>,
406}
407
408impl From<&Revision> for ResolvedRevision {
409 fn from(r: &Revision) -> Self {
410 ResolvedRevision {
411 id: r.id,
412 code: r.code.clone(),
413 state: r.state.clone(),
414 name: r.name.clone(),
415 mass_kg: r.mass_kg,
416 unit_cost: r.unit_cost,
417 }
418 }
419}
420
421#[derive(Clone, Debug, Serialize)]
422pub struct BomNode {
423 pub line_id: Uuid,
424 pub level: usize,
425 pub find_number: i32,
426 pub quantity: f64,
427 pub notes: String,
428 pub item_id: Uuid,
429 pub item_number: String,
430 pub item_type: String,
431 pub unit: String,
432 pub revision: Option<ResolvedRevision>,
434 pub unit_mass_kg: f64,
436 pub total_mass_kg: f64,
438 pub unit_cost_total: f64,
439 pub total_cost: f64,
440 pub mass_complete: bool,
442 pub cost_complete: bool,
443 pub cycle: bool,
445 pub restricted: bool,
448 pub project_id: Option<Uuid>,
451 pub children: Vec<BomNode>,
452}
453
454impl BomNode {
455 fn shallow(&self) -> BomNode {
457 BomNode {
458 line_id: self.line_id,
459 level: self.level,
460 find_number: self.find_number,
461 quantity: self.quantity,
462 notes: self.notes.clone(),
463 item_id: self.item_id,
464 item_number: self.item_number.clone(),
465 item_type: self.item_type.clone(),
466 unit: self.unit.clone(),
467 revision: self.revision.clone(),
468 unit_mass_kg: self.unit_mass_kg,
469 total_mass_kg: self.total_mass_kg,
470 unit_cost_total: self.unit_cost_total,
471 total_cost: self.total_cost,
472 mass_complete: self.mass_complete,
473 cost_complete: self.cost_complete,
474 cycle: self.cycle,
475 restricted: self.restricted,
476 project_id: self.project_id,
477 children: Vec::new(),
478 }
479 }
480}
481
482#[derive(Clone, Debug, Serialize)]
483pub struct BomTree {
484 pub rule: RevisionRule,
485 pub root: ResolvedRevision,
486 pub root_item_number: String,
487 pub children: Vec<BomNode>,
488 pub total_mass_kg: f64,
489 pub total_cost: f64,
490 pub mass_complete: bool,
491 pub cost_complete: bool,
492 pub line_count: usize,
494 pub unresolved_count: usize,
496 pub restricted_lines: usize,
498 pub rollup_incomplete: bool,
500 #[serde(skip)]
502 pub queries: usize,
503}
504
505#[derive(Clone, Debug, Serialize)]
507pub struct FlatBomRow {
508 pub level: usize,
509 pub indent: String,
510 pub has_children: bool,
511 pub node: BomNode,
512}
513
514#[derive(Clone, Debug, sqlx::FromRow)]
517pub(crate) struct StructureLine {
518 pub id: Uuid,
519 pub parent_revision_id: Uuid,
520 pub child_item_id: Uuid,
521 pub child_number: String,
522 pub child_type: String,
523 pub child_unit: String,
524 pub find_number: i32,
525 pub quantity: f64,
526 pub notes: String,
527 pub child_revision_id: Option<Uuid>,
528 pub child_code: Option<String>,
529 pub child_state: Option<String>,
530 pub child_name: Option<String>,
531 pub child_mass_kg: Option<f64>,
532 pub child_unit_cost: Option<f64>,
533 #[sqlx(default)]
535 pub child_project_id: Option<Uuid>,
536 #[sqlx(default)]
539 pub restricted: bool,
540}
541
542impl StructureLine {
543 fn mask(&mut self) {
545 self.child_item_id = Uuid::nil();
546 self.child_number = access::RESTRICTED_LABEL.into();
547 self.child_type = String::new();
548 self.child_unit = String::new();
549 self.child_revision_id = None;
550 self.child_code = None;
551 self.child_state = None;
552 self.child_name = None;
553 self.child_mass_kg = None;
554 self.child_unit_cost = None;
555 self.notes = String::new();
556 self.child_project_id = None;
557 self.restricted = true;
558 }
559}
560
561#[derive(Clone, Debug, sqlx::FromRow)]
563pub(crate) struct ResolvedItem {
564 pub item_id: Uuid,
565 pub number: String,
566 pub revision_id: Option<Uuid>,
568}
569
570pub(crate) async fn resolve_items(
575 pool: &PgPool,
576 item_ids: &[Uuid],
577 rule: RevisionRule,
578) -> AppResult<HashMap<Uuid, ResolvedItem>> {
579 if item_ids.is_empty() {
580 return Ok(HashMap::new());
581 }
582 let rows = sqlx::query_as::<_, ResolvedItem>(
583 "SELECT i.id AS item_id, i.number, p.id AS revision_id
584 FROM items i
585 LEFT JOIN LATERAL (
586 SELECT r.id FROM revisions r
587 WHERE r.item_id = i.id AND ($2 OR r.state = 'released')
588 ORDER BY r.seq DESC LIMIT 1
589 ) p ON true
590 WHERE i.id = ANY($1)",
591 )
592 .bind(item_ids)
593 .bind(rule == RevisionRule::Latest)
594 .fetch_all(pool)
595 .await?;
596 Ok(rows.into_iter().map(|r| (r.item_id, r)).collect())
597}
598
599impl StructureLine {
600 fn child(&self) -> Option<ResolvedRevision> {
601 self.child_revision_id.map(|id| ResolvedRevision {
602 id,
603 code: self.child_code.clone().unwrap_or_default(),
604 state: self.child_state.clone().unwrap_or_default(),
605 name: self.child_name.clone().unwrap_or_default(),
606 mass_kg: self.child_mass_kg,
607 unit_cost: self.child_unit_cost,
608 })
609 }
610}
611
612const LEVEL_SQL: &str = "
626 WITH l AS (
627 SELECT bl.id, bl.parent_revision_id, bl.child_item_id, bl.find_number, bl.quantity,
628 bl.notes, bl.released_child_revision_id
629 FROM bom_lines bl
630 WHERE bl.parent_revision_id = ANY($1)
631 ),
632 pick AS (
633 SELECT c.item_id, p.id, p.code, p.state, p.name, p.mass_kg, p.unit_cost
634 FROM (SELECT DISTINCT child_item_id AS item_id FROM l) c
635 CROSS JOIN LATERAL (
636 SELECT r.id, r.code, r.state, r.name, r.mass_kg, r.unit_cost
637 FROM revisions r
638 WHERE r.item_id = c.item_id AND ($2 OR r.state = 'released')
639 ORDER BY r.seq DESC LIMIT 1
640 ) p
641 )
642 SELECT l.id, l.parent_revision_id, l.child_item_id, i.number AS child_number,
643 i.item_type AS child_type, i.unit AS child_unit, l.find_number, l.quantity, l.notes,
644 COALESCE(fr.id, p.id) AS child_revision_id,
645 CASE WHEN fr.id IS NULL THEN p.code ELSE fr.code END AS child_code,
646 CASE WHEN fr.id IS NULL THEN p.state ELSE fr.state END AS child_state,
647 CASE WHEN fr.id IS NULL THEN p.name ELSE fr.name END AS child_name,
648 CASE WHEN fr.id IS NULL THEN p.mass_kg ELSE fr.mass_kg END AS child_mass_kg,
649 CASE WHEN fr.id IS NULL THEN p.unit_cost ELSE fr.unit_cost END AS child_unit_cost,
650 i.project_id AS child_project_id
651 FROM l
652 CROSS JOIN LATERAL (
653 SELECT it.number, it.item_type, it.unit, it.project_id
654 FROM items it WHERE it.id = l.child_item_id LIMIT 1
655 ) i
656 LEFT JOIN LATERAL (
657 SELECT r.id, r.code, r.state, r.name, r.mass_kg, r.unit_cost
658 FROM revisions r WHERE $3 AND r.id = l.released_child_revision_id LIMIT 1
659 ) fr ON true
660 LEFT JOIN pick p ON p.item_id = l.child_item_id
661 ORDER BY l.parent_revision_id, l.find_number";
662
663pub(crate) async fn load_structure(
672 pool: &PgPool,
673 actor: &Actor,
674 root_id: Uuid,
675 rule: RevisionRule,
676) -> AppResult<(HashMap<Uuid, Vec<StructureLine>>, usize)> {
677 let mut lines_by_rev: HashMap<Uuid, Vec<StructureLine>> = HashMap::new();
678 let mut seen = HashSet::from([root_id]);
679 let mut frontier = vec![root_id];
680 let mut queries = 0;
681 for _ in 0..MAX_DEPTH {
683 if frontier.is_empty() {
684 break;
685 }
686 if seen.len() > MAX_EXPANDED_REVISIONS {
687 return Err(AppError::BadRequest(format!(
688 "The BOM is too large to expand (more than {MAX_EXPANDED_REVISIONS} assemblies)."
689 )));
690 }
691 let rows = sqlx::query_as::<_, StructureLine>(LEVEL_SQL)
692 .bind(&frontier)
693 .bind(rule == RevisionRule::Latest)
694 .bind(rule == RevisionRule::AsReleased)
695 .fetch_all(pool)
696 .await?;
697 queries += 1;
698 let mut next = Vec::new();
699 for mut row in rows {
700 if !actor.can_see_project(row.child_project_id) {
701 row.mask();
702 }
703 if let Some(child) = row.child_revision_id {
704 if seen.insert(child) {
705 next.push(child);
706 }
707 }
708 lines_by_rev
709 .entry(row.parent_revision_id)
710 .or_default()
711 .push(row);
712 }
713 frontier = next;
714 }
715 Ok((lines_by_rev, queries))
716}
717
718pub async fn tree(
722 pool: &PgPool,
723 actor: &Actor,
724 root_revision_id: Uuid,
725 rule: RevisionRule,
726) -> AppResult<BomTree> {
727 let root = revisions::get(pool, actor, root_revision_id).await?;
728 let (lines_by_rev, queries) = load_structure(pool, actor, root.id, rule).await?;
729 let mut tree = assemble(
730 rule,
731 ResolvedRevision::from(&root),
732 root.item_number.clone(),
733 &lines_by_rev,
734 )?;
735 tree.queries = queries + 1;
736 Ok(tree)
737}
738
739#[derive(Clone, Debug, Default, PartialEq, Eq)]
742pub(crate) struct StructureRevisions {
743 pub revisions: Vec<(Uuid, Uuid)>,
747 pub unresolved: Vec<String>,
749 pub restricted_lines: usize,
751}
752
753pub(crate) fn structure_revisions(
759 root_id: Uuid,
760 lines_by_rev: &HashMap<Uuid, Vec<StructureLine>>,
761) -> StructureRevisions {
762 struct Walk<'a> {
763 lines_by_rev: &'a HashMap<Uuid, Vec<StructureLine>>,
764 seen: HashSet<Uuid>,
765 unresolved: HashSet<String>,
766 walked_at: HashMap<Uuid, usize>,
768 out: StructureRevisions,
769 }
770 impl Walk<'_> {
771 fn lines(&mut self, rev_id: Uuid, level: usize, path: &mut Vec<Uuid>) {
773 let Some(lines) = self.lines_by_rev.get(&rev_id) else {
774 return;
775 };
776 for l in lines {
777 if l.restricted {
778 self.out.restricted_lines += 1;
779 continue;
780 }
781 let Some(child) = l.child_revision_id else {
782 if self.unresolved.insert(l.child_number.clone()) {
783 self.out.unresolved.push(l.child_number.clone());
784 }
785 continue;
786 };
787 if self.seen.insert(child) {
788 self.out.revisions.push((child, l.child_item_id));
789 }
790 if level >= MAX_DEPTH || path.contains(&child) {
791 continue;
792 }
793 if self
795 .walked_at
796 .get(&child)
797 .is_some_and(|&at| at <= level + 1)
798 {
799 continue;
800 }
801 self.walked_at.insert(child, level + 1);
802 path.push(child);
803 self.lines(child, level + 1, path);
804 path.pop();
805 }
806 }
807 }
808 let mut walk = Walk {
809 lines_by_rev,
810 seen: HashSet::from([root_id]),
811 unresolved: HashSet::new(),
812 walked_at: HashMap::from([(root_id, 1)]),
813 out: StructureRevisions::default(),
814 };
815 walk.lines(root_id, 1, &mut vec![root_id]);
816 walk.out
817}
818
819pub(crate) fn assemble(
822 rule: RevisionRule,
823 root: ResolvedRevision,
824 root_item_number: String,
825 lines_by_rev: &HashMap<Uuid, Vec<StructureLine>>,
826) -> AppResult<BomTree> {
827 let mut builder = Builder {
828 lines_by_rev,
829 budget: MAX_EXPANDED_LINES,
830 };
831 let mut path = vec![root.id];
832 let children = builder.build(root.id, 1, &mut path)?;
833 let (mass, mass_ok, cost, cost_ok) = rollup(root.mass_kg, root.unit_cost, &children);
834 let (line_count, unresolved_count) = count_lines(&children);
835 let restricted_lines = count_restricted(&children);
836 Ok(BomTree {
837 rule,
838 root,
839 root_item_number,
840 children,
841 total_mass_kg: mass,
842 total_cost: cost,
843 mass_complete: mass_ok,
844 cost_complete: cost_ok,
845 line_count,
846 unresolved_count,
847 restricted_lines,
848 rollup_incomplete: restricted_lines > 0,
849 queries: 0,
850 })
851}
852
853fn count_restricted(nodes: &[BomNode]) -> usize {
854 nodes
855 .iter()
856 .map(|n| usize::from(n.restricted) + count_restricted(&n.children))
857 .sum()
858}
859
860struct Builder<'a> {
861 lines_by_rev: &'a HashMap<Uuid, Vec<StructureLine>>,
862 budget: usize,
864}
865
866impl Builder<'_> {
867 fn build(
868 &mut self,
869 rev_id: Uuid,
870 level: usize,
871 path: &mut Vec<Uuid>,
872 ) -> AppResult<Vec<BomNode>> {
873 let Some(rev_lines) = self.lines_by_rev.get(&rev_id) else {
874 return Ok(Vec::new());
875 };
876 if rev_lines.len() > self.budget {
877 return Err(AppError::BadRequest(format!(
878 "The BOM is too large to expand (more than {MAX_EXPANDED_LINES} lines)."
879 )));
880 }
881 self.budget -= rev_lines.len();
882 let mut nodes = Vec::with_capacity(rev_lines.len());
883 for l in rev_lines {
884 let child_rev = l.child();
885 let cycle = child_rev.as_ref().is_some_and(|r| path.contains(&r.id));
886 let children = match &child_rev {
887 Some(r) if !cycle && level < MAX_DEPTH => {
888 path.push(r.id);
889 let c = self.build(r.id, level + 1, path);
890 path.pop();
891 c?
892 }
893 _ => Vec::new(),
894 };
895 let (unit_mass, mass_ok, unit_cost, cost_ok) = match &child_rev {
896 Some(r) => rollup(r.mass_kg, r.unit_cost, &children),
897 None => (0.0, false, 0.0, false),
898 };
899 nodes.push(BomNode {
900 line_id: l.id,
901 level,
902 find_number: l.find_number,
903 quantity: l.quantity,
904 notes: l.notes.clone(),
905 item_id: l.child_item_id,
906 item_number: l.child_number.clone(),
907 item_type: l.child_type.clone(),
908 unit: l.child_unit.clone(),
909 revision: child_rev,
910 unit_mass_kg: unit_mass,
911 total_mass_kg: unit_mass * l.quantity,
912 unit_cost_total: unit_cost,
913 total_cost: unit_cost * l.quantity,
914 mass_complete: mass_ok && !cycle,
915 cost_complete: cost_ok && !cycle,
916 cycle,
917 restricted: l.restricted,
918 project_id: if l.restricted {
919 None
920 } else {
921 l.child_project_id
922 },
923 children,
924 });
925 }
926 Ok(nodes)
927 }
928}
929
930fn rollup(
933 own_mass: Option<f64>,
934 own_cost: Option<f64>,
935 children: &[BomNode],
936) -> (f64, bool, f64, bool) {
937 let child_mass: f64 = children.iter().map(|c| c.total_mass_kg).sum();
938 let child_cost: f64 = children.iter().map(|c| c.total_cost).sum();
939 let mass_ok = if children.is_empty() {
940 own_mass.is_some()
941 } else {
942 children.iter().all(|c| c.mass_complete)
943 };
944 let cost_ok = if children.is_empty() {
945 own_cost.is_some()
946 } else {
947 children.iter().all(|c| c.cost_complete)
948 };
949 (
950 own_mass.unwrap_or(0.0) + child_mass,
951 mass_ok,
952 own_cost.unwrap_or(0.0) + child_cost,
953 cost_ok,
954 )
955}
956
957fn count_lines(nodes: &[BomNode]) -> (usize, usize) {
958 nodes.iter().fold((0, 0), |(lines, unresolved), n| {
959 let (cl, cu) = count_lines(&n.children);
960 (
961 lines + 1 + cl,
962 unresolved + usize::from(n.revision.is_none() && !n.restricted) + cu,
963 )
964 })
965}
966
967pub fn tree_revision_ids(tree: &BomTree) -> Vec<Uuid> {
969 let mut ids = vec![tree.root.id];
970 ids.extend(
971 flatten(&tree.children)
972 .iter()
973 .filter_map(|r| r.node.revision.as_ref().map(|v| v.id)),
974 );
975 ids
976}
977
978pub fn flatten(nodes: &[BomNode]) -> Vec<FlatBomRow> {
980 let mut out = Vec::new();
981 fn walk(nodes: &[BomNode], out: &mut Vec<FlatBomRow>) {
982 for n in nodes {
983 out.push(FlatBomRow {
984 level: n.level,
985 indent: "\u{2003}".repeat(n.level.saturating_sub(1)),
986 has_children: !n.children.is_empty(),
987 node: n.shallow(),
988 });
989 walk(&n.children, out);
990 }
991 }
992 walk(nodes, &mut out);
993 out
994}
995
996#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
999pub struct ViewOptions {
1000 pub depth: Option<usize>,
1002 pub group: Option<Uuid>,
1005}
1006
1007#[derive(Clone, Debug, Serialize)]
1009pub struct ViewRow {
1010 pub level: usize,
1011 pub has_children: bool,
1013 pub hidden_below: usize,
1016 pub group_match: bool,
1018 pub project_code: Option<String>,
1020 pub node: BomNode,
1021}
1022
1023pub fn view_rows(nodes: &[BomNode], opts: ViewOptions) -> Vec<ViewRow> {
1029 fn filtered(nodes: &[BomNode], group: Option<Uuid>, out: &mut Vec<ViewRow>) {
1030 for n in nodes {
1031 let at = out.len();
1032 out.push(ViewRow {
1033 level: n.level,
1034 has_children: !n.children.is_empty(),
1035 hidden_below: 0,
1036 group_match: group.is_some() && n.project_id == group,
1037 project_code: None,
1038 node: n.shallow(),
1039 });
1040 filtered(&n.children, group, out);
1041 if group.is_some() && !out[at].group_match && out.len() == at + 1 {
1042 out.pop();
1043 }
1044 }
1045 }
1046 let mut all = Vec::new();
1047 filtered(nodes, opts.group, &mut all);
1048 let Some(depth) = opts.depth else {
1049 return all;
1050 };
1051 let mut out: Vec<ViewRow> = Vec::new();
1052 for row in all {
1053 if row.level <= depth {
1054 out.push(row);
1055 } else if let Some(last) = out.last_mut() {
1056 last.hidden_below += 1;
1059 }
1060 }
1061 out
1062}
1063
1064pub async fn project_codes(pool: &PgPool, ids: &[Uuid]) -> AppResult<HashMap<Uuid, String>> {
1067 if ids.is_empty() {
1068 return Ok(HashMap::new());
1069 }
1070 let rows: Vec<(Uuid, String)> =
1071 sqlx::query_as("SELECT id, code FROM projects WHERE id = ANY($1)")
1072 .bind(ids)
1073 .fetch_all(pool)
1074 .await?;
1075 Ok(rows.into_iter().collect())
1076}
1077
1078pub fn tree_projects(nodes: &[BomNode]) -> Vec<Uuid> {
1080 fn walk(nodes: &[BomNode], out: &mut HashSet<Uuid>) {
1081 for n in nodes {
1082 out.extend(n.project_id);
1083 walk(&n.children, out);
1084 }
1085 }
1086 let mut set = HashSet::new();
1087 walk(nodes, &mut set);
1088 set.into_iter().collect()
1089}
1090
1091pub fn to_csv(tree: &BomTree) -> String {
1093 let mut out = String::from(
1094 "level,find_number,item_number,revision,state,name,quantity,unit,unit_mass_kg,total_mass_kg,unit_cost,total_cost,notes\n",
1095 );
1096 for row in flatten(&tree.children) {
1097 let n = &row.node;
1098 let (code, state, name) = match &n.revision {
1099 Some(r) => (r.code.as_str(), r.state.as_str(), r.name.as_str()),
1100 None if n.restricted => ("", "restricted", ""),
1101 None => ("", "unresolved", ""),
1102 };
1103 out.push_str(&format!(
1104 "{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
1105 n.level,
1106 n.find_number,
1107 util::csv_field(&n.item_number),
1108 util::csv_field(code),
1109 util::csv_field(state),
1110 util::csv_field(name),
1111 util::format_number(n.quantity, 6),
1112 util::csv_field(&n.unit),
1113 util::format_number(n.unit_mass_kg, 6),
1114 util::format_number(n.total_mass_kg, 6),
1115 util::format_number(n.unit_cost_total, 2),
1116 util::format_number(n.total_cost, 2),
1117 util::csv_field(&n.notes),
1118 ));
1119 }
1120 out
1121}
1122
1123pub fn to_xlsx(tree: &BomTree) -> AppResult<Vec<u8>> {
1126 use rust_xlsxwriter::{Format, Workbook};
1127 let xlsx =
1128 |e: rust_xlsxwriter::XlsxError| AppError::Other(anyhow::anyhow!("Excel export: {e}"));
1129 let mut book = Workbook::new();
1130 let sheet = book.add_worksheet();
1131 sheet.set_name("BOM").map_err(xlsx)?;
1132 let bold = Format::new().set_bold();
1133 let headers = [
1134 "Level",
1135 "Find no.",
1136 "Number",
1138 "Revision",
1139 "State",
1140 "Name",
1141 "Quantity",
1142 "Unit",
1143 "Unit mass [kg]",
1144 "Total mass [kg]",
1145 "Unit cost",
1146 "Total cost",
1147 "Notes",
1148 ];
1149 for (c, h) in headers.iter().enumerate() {
1150 sheet
1151 .write_string_with_format(0, c as u16, *h, &bold)
1152 .map_err(xlsx)?;
1153 }
1154 let mass = Format::new().set_num_format("0.0000");
1155 let cost = Format::new().set_num_format("0.00");
1156 let rows = flatten(&tree.children);
1157 for (i, row) in rows.iter().enumerate() {
1158 let r = i as u32 + 1;
1159 let n = &row.node;
1160 let (code, state, name) = match &n.revision {
1161 Some(rev) => (rev.code.as_str(), rev.state.as_str(), rev.name.as_str()),
1162 None if n.restricted => ("", "restricted", ""),
1163 None => ("", "unresolved", ""),
1164 };
1165 let indent = Format::new().set_indent(row.level.saturating_sub(1).min(15) as u8);
1166 sheet.write_number(r, 0, row.level as f64).map_err(xlsx)?;
1167 sheet
1168 .write_number(r, 1, f64::from(n.find_number))
1169 .map_err(xlsx)?;
1170 sheet
1171 .write_string_with_format(r, 2, &n.item_number, &indent)
1172 .map_err(xlsx)?;
1173 sheet.write_string(r, 3, code).map_err(xlsx)?;
1174 sheet.write_string(r, 4, state).map_err(xlsx)?;
1175 sheet.write_string(r, 5, name).map_err(xlsx)?;
1176 sheet.write_number(r, 6, n.quantity).map_err(xlsx)?;
1177 sheet.write_string(r, 7, &n.unit).map_err(xlsx)?;
1178 sheet
1179 .write_number_with_format(r, 8, n.unit_mass_kg, &mass)
1180 .map_err(xlsx)?;
1181 sheet
1182 .write_number_with_format(r, 9, n.total_mass_kg, &mass)
1183 .map_err(xlsx)?;
1184 sheet
1185 .write_number_with_format(r, 10, n.unit_cost_total, &cost)
1186 .map_err(xlsx)?;
1187 sheet
1188 .write_number_with_format(r, 11, n.total_cost, &cost)
1189 .map_err(xlsx)?;
1190 sheet.write_string(r, 12, &n.notes).map_err(xlsx)?;
1191 }
1192 sheet.set_freeze_panes(1, 0).map_err(xlsx)?;
1193 sheet
1194 .autofilter(0, 0, rows.len() as u32, headers.len() as u16 - 1)
1195 .map_err(xlsx)?;
1196 sheet.autofit();
1197 book.save_to_buffer().map_err(xlsx)
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202 use super::*;
1203
1204 fn node(mass: Option<f64>, qty: f64, children: Vec<BomNode>) -> BomNode {
1205 let (unit_mass, ok, unit_cost, cost_ok) = rollup(mass, Some(1.0), &children);
1206 BomNode {
1207 line_id: Uuid::new_v4(),
1208 level: 1,
1209 find_number: 10,
1210 quantity: qty,
1211 notes: String::new(),
1212 item_id: Uuid::new_v4(),
1213 item_number: "PRT-000001".into(),
1214 item_type: "part".into(),
1215 unit: "each".into(),
1216 revision: Some(ResolvedRevision {
1217 id: Uuid::new_v4(),
1218 code: "A".into(),
1219 state: "released".into(),
1220 name: "Bolt, M6".into(),
1221 mass_kg: mass,
1222 unit_cost: Some(1.0),
1223 }),
1224 unit_mass_kg: unit_mass,
1225 total_mass_kg: unit_mass * qty,
1226 unit_cost_total: unit_cost,
1227 total_cost: unit_cost * qty,
1228 mass_complete: ok,
1229 cost_complete: cost_ok,
1230 cycle: false,
1231 restricted: false,
1232 project_id: None,
1233 children,
1234 }
1235 }
1236
1237 fn gnode(name: &str, g: Option<Uuid>, level: usize, children: Vec<BomNode>) -> BomNode {
1239 let mut n = node(Some(1.0), 1.0, children);
1240 n.item_number = name.into();
1241 n.project_id = g;
1242 n.level = level;
1243 n
1244 }
1245
1246 fn names(rows: &[ViewRow]) -> Vec<&str> {
1247 rows.iter().map(|r| r.node.item_number.as_str()).collect()
1248 }
1249
1250 fn sample(g1: Uuid, g2: Uuid) -> Vec<BomNode> {
1252 vec![gnode(
1253 "A",
1254 Some(g1),
1255 1,
1256 vec![
1257 gnode(
1258 "B",
1259 Some(g1),
1260 2,
1261 vec![
1262 gnode("C", Some(g2), 3, vec![]),
1263 gnode("D", Some(g1), 3, vec![]),
1264 ],
1265 ),
1266 gnode("E", Some(g2), 2, vec![gnode("F", Some(g2), 3, vec![])]),
1267 gnode("G", Some(g1), 2, vec![]),
1268 ],
1269 )]
1270 }
1271
1272 #[test]
1273 fn view_without_options_is_the_whole_tree() {
1274 let (g1, g2) = (Uuid::new_v4(), Uuid::new_v4());
1275 let rows = view_rows(&sample(g1, g2), ViewOptions::default());
1276 assert_eq!(names(&rows), ["A", "B", "C", "D", "E", "F", "G"]);
1277 assert!(rows.iter().all(|r| r.hidden_below == 0 && !r.group_match));
1278 }
1279
1280 #[test]
1281 fn depth_cuts_levels_and_counts_what_is_hidden() {
1282 let (g1, g2) = (Uuid::new_v4(), Uuid::new_v4());
1283 let tree = sample(g1, g2);
1284 let two = ViewOptions {
1285 depth: Some(2),
1286 group: None,
1287 };
1288 let rows = view_rows(&tree, two);
1289 assert_eq!(names(&rows), ["A", "B", "E", "G"]);
1290 let hidden: Vec<usize> = rows.iter().map(|r| r.hidden_below).collect();
1291 assert_eq!(hidden, [0, 2, 1, 0]);
1292 let one = ViewOptions {
1293 depth: Some(1),
1294 group: None,
1295 };
1296 let rows = view_rows(&tree, one);
1297 assert_eq!(names(&rows), ["A"]);
1298 assert_eq!(rows[0].hidden_below, 6);
1299 assert!(rows[0].has_children);
1300 }
1301
1302 #[test]
1303 fn group_filter_keeps_matches_and_their_ancestors() {
1304 let (g1, g2) = (Uuid::new_v4(), Uuid::new_v4());
1305 let tree = sample(g1, g2);
1306 let only = |group| ViewOptions {
1307 depth: None,
1308 group: Some(group),
1309 };
1310 let rows = view_rows(&tree, only(g2));
1311 assert_eq!(names(&rows), ["A", "B", "C", "E", "F"]);
1313 let matches: Vec<bool> = rows.iter().map(|r| r.group_match).collect();
1314 assert_eq!(matches, [false, false, true, true, true]);
1315 assert!(view_rows(&tree, only(Uuid::new_v4())).is_empty());
1316 }
1317
1318 #[test]
1319 fn group_filter_and_depth_combine_and_the_roll_up_is_untouched() {
1320 let (g1, g2) = (Uuid::new_v4(), Uuid::new_v4());
1321 let tree = sample(g1, g2);
1322 let rows = view_rows(
1323 &tree,
1324 ViewOptions {
1325 depth: Some(2),
1326 group: Some(g2),
1327 },
1328 );
1329 assert_eq!(names(&rows), ["A", "B", "E"]);
1331 assert_eq!(rows[1].hidden_below, 1);
1332 assert_eq!(rows[2].hidden_below, 1);
1333 assert_eq!(rows[0].node.total_mass_kg, 7.0);
1335 }
1336
1337 #[test]
1338 fn a_restricted_placeholder_never_matches_a_group() {
1339 let g1 = Uuid::new_v4();
1340 let mut hidden = gnode("(restricted)", None, 2, vec![]);
1341 hidden.restricted = true;
1342 let tree = vec![gnode("A", Some(g1), 1, vec![hidden])];
1343 let only = ViewOptions {
1344 depth: None,
1345 group: Some(g1),
1346 };
1347 assert_eq!(names(&view_rows(&tree, only)), ["A"]);
1348 }
1349
1350 #[test]
1351 fn rollup_adds_children_times_quantity() {
1352 let bolts = node(Some(0.01), 8.0, vec![]);
1353 let plate = node(Some(0.5), 1.0, vec![]);
1354 let (mass, ok, cost, cost_ok) = rollup(Some(0.1), None, &[bolts, plate]);
1355 assert!((mass - (0.1 + 0.08 + 0.5)).abs() < 1e-9);
1356 assert!(ok);
1357 assert!((cost - 9.0).abs() < 1e-9);
1358 assert!(cost_ok);
1359 }
1360
1361 #[test]
1362 fn missing_leaf_mass_is_incomplete() {
1363 let unknown = node(None, 2.0, vec![]);
1364 assert!(!unknown.mass_complete);
1365 let (_, ok, _, _) = rollup(Some(1.0), None, &[unknown]);
1366 assert!(!ok);
1367 let (_, ok, _, cost_ok) = rollup(None, None, &[]);
1369 assert!(!ok && !cost_ok);
1370 }
1371
1372 #[test]
1373 fn flatten_and_csv() {
1374 let child = node(Some(0.01), 4.0, vec![]);
1375 let mut sub = node(Some(0.2), 2.0, vec![child]);
1376 sub.children[0].level = 2;
1377 let tree = BomTree {
1378 rule: RevisionRule::Latest,
1379 root: sub.revision.clone().unwrap(),
1380 root_item_number: "ASM-000001".into(),
1381 children: vec![sub],
1382 total_mass_kg: 0.0,
1383 total_cost: 0.0,
1384 mass_complete: true,
1385 cost_complete: true,
1386 line_count: 2,
1387 unresolved_count: 0,
1388 restricted_lines: 0,
1389 rollup_incomplete: false,
1390 queries: 0,
1391 };
1392 let rows = flatten(&tree.children);
1393 assert_eq!(rows.len(), 2);
1394 assert!(rows[0].has_children);
1395 assert_eq!(rows[1].level, 2);
1396 assert_eq!(rows[1].indent.chars().count(), 1);
1397 let csv = to_csv(&tree);
1398 let lines: Vec<&str> = csv.lines().collect();
1399 assert_eq!(lines.len(), 3);
1400 assert!(lines[1].starts_with("1,10,PRT-000001,A,released,\"Bolt, M6\",2,each,0.24,0.48,"));
1401 assert_eq!(count_lines(&tree.children), (2, 0));
1402 let xlsx = to_xlsx(&tree).unwrap();
1403 assert!(xlsx.starts_with(b"PK"), "an .xlsx file is a ZIP archive");
1404 }
1405
1406 fn rev(code: &str, mass: Option<f64>, cost: Option<f64>) -> ResolvedRevision {
1409 ResolvedRevision {
1410 id: Uuid::new_v4(),
1411 code: code.into(),
1412 state: "released".into(),
1413 name: format!("Revision {code}"),
1414 mass_kg: mass,
1415 unit_cost: cost,
1416 }
1417 }
1418
1419 fn sline(
1421 parent: Uuid,
1422 number: &str,
1423 find: i32,
1424 qty: f64,
1425 child: Option<&ResolvedRevision>,
1426 ) -> StructureLine {
1427 StructureLine {
1428 id: Uuid::new_v4(),
1429 parent_revision_id: parent,
1430 child_item_id: Uuid::new_v4(),
1431 child_number: number.into(),
1432 child_type: "part".into(),
1433 child_unit: "each".into(),
1434 find_number: find,
1435 quantity: qty,
1436 notes: String::new(),
1437 child_revision_id: child.map(|c| c.id),
1438 child_code: child.map(|c| c.code.clone()),
1439 child_state: child.map(|c| c.state.clone()),
1440 child_name: child.map(|c| c.name.clone()),
1441 child_mass_kg: child.and_then(|c| c.mass_kg),
1442 child_unit_cost: child.and_then(|c| c.unit_cost),
1443 child_project_id: None,
1444 restricted: false,
1445 }
1446 }
1447
1448 fn structure(lines: Vec<StructureLine>) -> HashMap<Uuid, Vec<StructureLine>> {
1449 let mut map: HashMap<Uuid, Vec<StructureLine>> = HashMap::new();
1450 for l in lines {
1451 map.entry(l.parent_revision_id).or_default().push(l);
1452 }
1453 map
1454 }
1455
1456 #[test]
1457 fn assemble_rolls_up_shared_sub_assemblies() {
1458 let root = rev("A", Some(1.0), Some(10.0));
1459 let sub = rev("A", Some(0.5), Some(2.0));
1460 let bolt = rev("B", Some(0.01), Some(0.1));
1461 let map = structure(vec![
1463 sline(root.id, "ASM-000002", 10, 2.0, Some(&sub)),
1464 sline(root.id, "PRT-000001", 20, 4.0, Some(&bolt)),
1465 sline(sub.id, "PRT-000001", 10, 3.0, Some(&bolt)),
1466 ]);
1467 let tree = assemble(RevisionRule::Latest, root, "ASM-000001".into(), &map).unwrap();
1468 assert_eq!(tree.line_count, 3);
1469 assert_eq!(tree.unresolved_count, 0);
1470 assert!(tree.mass_complete && tree.cost_complete);
1471 assert!((tree.children[0].unit_mass_kg - 0.53).abs() < 1e-9);
1473 assert!((tree.total_mass_kg - (1.0 + 1.06 + 0.04)).abs() < 1e-9);
1474 assert!((tree.total_cost - (10.0 + 2.0 * 2.3 + 0.4)).abs() < 1e-9);
1475 assert_eq!(tree.children[0].children[0].level, 2);
1476 assert_eq!(flatten(&tree.children).len(), 3);
1477 }
1478
1479 #[test]
1480 fn assemble_keeps_find_number_order_and_counts_unresolved() {
1481 let root = rev("A", None, None);
1482 let a = rev("A", Some(1.0), Some(1.0));
1483 let map = structure(vec![
1484 sline(root.id, "PRT-000001", 10, 1.0, Some(&a)),
1485 sline(root.id, "PRT-000002", 20, 1.0, None),
1486 ]);
1487 let tree = assemble(RevisionRule::Released, root, "ASM-000001".into(), &map).unwrap();
1488 let finds: Vec<i32> = tree.children.iter().map(|n| n.find_number).collect();
1489 assert_eq!(finds, [10, 20]);
1490 assert_eq!((tree.line_count, tree.unresolved_count), (2, 1));
1491 assert!(tree.children[1].revision.is_none());
1492 assert!(!tree.mass_complete && !tree.cost_complete);
1493 assert!((tree.total_mass_kg - 1.0).abs() < 1e-9);
1494 }
1495
1496 #[test]
1497 fn assemble_stops_at_cycles() {
1498 let root = rev("A", Some(1.0), Some(1.0));
1499 let sub = rev("A", Some(1.0), Some(1.0));
1500 let map = structure(vec![
1502 sline(root.id, "ASM-000002", 10, 1.0, Some(&sub)),
1503 sline(sub.id, "ASM-000001", 10, 1.0, Some(&root)),
1504 ]);
1505 let tree = assemble(RevisionRule::Latest, root, "ASM-000001".into(), &map).unwrap();
1506 assert_eq!(tree.line_count, 2);
1507 let back = &tree.children[0].children[0];
1508 assert!(back.cycle && back.children.is_empty());
1509 assert!(!back.mass_complete && !tree.mass_complete);
1510 assert!(!tree.children[0].cycle);
1511 }
1512
1513 #[test]
1514 fn assemble_limits_depth_and_size() {
1515 let root = rev("A", Some(0.0), Some(0.0));
1517 let mut parent = root.id;
1518 let mut lines = Vec::new();
1519 for i in 0..MAX_DEPTH + 5 {
1520 let child = rev("A", Some(1.0), Some(1.0));
1521 lines.push(sline(parent, &format!("ASM-{i:06}"), 10, 1.0, Some(&child)));
1522 parent = child.id;
1523 }
1524 let tree = assemble(RevisionRule::Latest, root, "R".into(), &structure(lines)).unwrap();
1525 assert_eq!(tree.line_count, MAX_DEPTH);
1526 let rows = flatten(&tree.children);
1527 assert_eq!(rows.last().unwrap().level, MAX_DEPTH);
1528
1529 let root = rev("A", Some(0.0), Some(0.0));
1531 let mut parent = root.id;
1532 let mut lines = Vec::new();
1533 for _ in 0..20 {
1534 let child = rev("A", Some(1.0), Some(1.0));
1535 for find in [10, 20] {
1536 lines.push(sline(parent, "ASM", find, 1.0, Some(&child)));
1537 }
1538 parent = child.id;
1539 }
1540 let err = assemble(RevisionRule::Latest, root, "R".into(), &structure(lines)).unwrap_err();
1541 assert!(err.to_string().contains("too large"), "{err}");
1542 }
1543
1544 fn revisions_from_tree(tree: &BomTree) -> StructureRevisions {
1550 let mut seen = HashSet::from([tree.root.id]);
1551 let mut out = StructureRevisions::default();
1552 for row in flatten(&tree.children) {
1553 match &row.node.revision {
1554 Some(r) => {
1555 if seen.insert(r.id) {
1556 out.revisions.push((r.id, row.node.item_id));
1557 }
1558 }
1559 None => {
1560 if !out.unresolved.contains(&row.node.item_number) {
1561 out.unresolved.push(row.node.item_number.clone());
1562 }
1563 }
1564 }
1565 }
1566 out
1567 }
1568
1569 fn dline(
1571 revs: &[ResolvedRevision],
1572 items: &[Uuid],
1573 parent: usize,
1574 child: usize,
1575 find: i32,
1576 ) -> StructureLine {
1577 let mut l = sline(
1578 revs[parent].id,
1579 &format!("ITM-{child:04}"),
1580 find,
1581 1.0,
1582 Some(&revs[child]),
1583 );
1584 l.child_item_id = items[child];
1585 l
1586 }
1587
1588 fn compare(root: &ResolvedRevision, map: &HashMap<Uuid, Vec<StructureLine>>) -> bool {
1589 let Ok(tree) = assemble(RevisionRule::Latest, root.clone(), "R".into(), map) else {
1590 return false; };
1592 assert_eq!(
1593 structure_revisions(root.id, map),
1594 revisions_from_tree(&tree)
1595 );
1596 true
1597 }
1598
1599 #[test]
1600 fn structure_revisions_match_the_expanded_tree() {
1601 let mut seed: u64 = 0x5eed;
1604 let mut next = move |n: u64| {
1605 seed = seed
1606 .wrapping_mul(6_364_136_223_846_793_005)
1607 .wrapping_add(1_442_695_040_888_963_407);
1608 (seed >> 33) % n
1609 };
1610 let mut compared = 0;
1611 for _ in 0..200 {
1612 let n = 2 + next(30) as usize;
1613 let revs: Vec<ResolvedRevision> = (0..n).map(|_| rev("A", Some(1.0), None)).collect();
1614 let items: Vec<Uuid> = (0..n).map(|_| Uuid::new_v4()).collect();
1615 let mut lines = Vec::new();
1616 for parent in 0..n - 1 {
1617 let mut find = 0;
1618 for _ in 0..next(4) {
1619 find += 10;
1620 if next(6) == 0 {
1621 let number = format!("UNRESOLVED-{}", next(3));
1622 lines.push(sline(revs[parent].id, &number, find, 1.0, None));
1623 } else {
1624 let child = parent + 1 + next((n - parent - 1) as u64) as usize;
1625 lines.push(dline(&revs, &items, parent, child, find));
1626 }
1627 }
1628 }
1629 compared += usize::from(compare(&revs[0], &structure(lines)));
1630 }
1631 assert!(compared > 100, "only {compared} structures compared");
1632 }
1633
1634 #[test]
1635 fn structure_revisions_match_the_expanded_tree_with_cycles() {
1636 let mut seed: u64 = 0xc1c1e;
1640 let mut next = move |n: u64| {
1641 seed = seed
1642 .wrapping_mul(6_364_136_223_846_793_005)
1643 .wrapping_add(1_442_695_040_888_963_407);
1644 (seed >> 33) % n
1645 };
1646 let mut compared = 0;
1647 for _ in 0..2000 {
1648 let n = 2 + next(9) as usize;
1649 let revs: Vec<ResolvedRevision> = (0..n).map(|_| rev("A", None, None)).collect();
1650 let items: Vec<Uuid> = (0..n).map(|_| Uuid::new_v4()).collect();
1651 let mut lines = Vec::new();
1652 for parent in 0..n {
1653 let mut find = 0;
1654 for _ in 0..next(3) {
1655 find += 10;
1656 let child = next(n as u64) as usize;
1657 lines.push(dline(&revs, &items, parent, child, find));
1658 }
1659 }
1660 compared += usize::from(compare(&revs[0], &structure(lines)));
1661 }
1662 assert!(compared > 1000, "only {compared} structures compared");
1663 }
1664
1665 #[test]
1666 fn structure_revisions_rewalk_where_the_depth_limit_cut_off() {
1667 let n = MAX_DEPTH + 2;
1671 let revs: Vec<ResolvedRevision> = (0..n).map(|_| rev("A", None, None)).collect();
1672 let items: Vec<Uuid> = (0..n).map(|_| Uuid::new_v4()).collect();
1673 let (s, q, r) = (n - 3, n - 2, n - 1);
1674 let mut lines = Vec::new();
1675 for i in 0..s {
1676 lines.push(dline(&revs, &items, i, i + 1, 10));
1677 }
1678 lines.push(dline(&revs, &items, 0, s, 20));
1679 lines.push(dline(&revs, &items, s, q, 10));
1680 lines.push(dline(&revs, &items, q, r, 10));
1681 let map = structure(lines);
1682 assert!(compare(&revs[0], &map));
1683 let walked = structure_revisions(revs[0].id, &map);
1684 assert_eq!(walked.revisions.last().map(|x| x.0), Some(revs[r].id));
1685 assert_eq!(walked.revisions.len(), n - 1);
1686
1687 let a = rev("A", None, None);
1689 let b = rev("A", None, None);
1690 let map = structure(vec![
1691 sline(a.id, "B", 10, 1.0, Some(&b)),
1692 sline(b.id, "A", 10, 1.0, Some(&a)),
1693 ]);
1694 let walked = structure_revisions(a.id, &map);
1695 assert_eq!(walked.revisions.len(), 1);
1696 assert!(compare(&a, &map));
1697 }
1698}