Skip to main content

teamcraft/services/
bom.rs

1//! Bills of materials: single-level editing, multi-level expansion with
2//! revision rules, mass/cost roll-up, CSV and Excel export and cycle
3//! prevention.
4
5use 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
21/// Upper bound for the number of revisions expanded in one BOM view.
22const MAX_EXPANDED_REVISIONS: usize = 5000;
23/// Upper bound for the depth of a BOM.
24const 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
41/// Single-level BOM of a revision the actor may read, ordered by find
42/// number. Lines to children the actor may not see are placeholders.
43pub 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
70/// A BOM line of a parent the actor may read (hidden child = placeholder).
71pub 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
76/// [`line`] without the placeholder: the audit trail of a write names the
77/// real child (redacted when read by somebody who may not see it).
78async 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
97/// Would adding `child_item_id` below `parent_item_id` create a cycle?
98/// Checks the structure of *all* revisions of the child, which is
99/// conservative but guarantees that no revision rule can produce a loop.
100pub(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    /// Item number of the child.
129    pub child_number: String,
130    pub quantity: f64,
131    /// `None` = next multiple of 10.
132    pub find_number: Option<i32>,
133    pub notes: String,
134}
135
136/// Adds a BOM line to an in-work revision.
137///
138/// # Invariants
139///
140/// * **Permission:** the child item is visible ("not found" otherwise); the parent revision is
141///   checked out by the actor (409 otherwise) and editable (`revisions::ensure_editable`: in
142///   work, CAD editor of its group); the line creates no cycle (409) and its find number is
143///   free (409).
144/// * **Locks:** the parent revision row (`revisions::lock`).
145/// * **Audit:** `bom.add`.
146/// * **Notifications / webhooks:** none.
147/// * **Atomic:** one transaction.
148pub 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        &notes,
176    )
177    .await?;
178    tx.commit().await?;
179    line(pool, actor, id).await
180}
181
182/// Adds a BOM line inside the caller's transaction; `parent` must be locked.
183/// Checks the lock, cycles and the find number; returns the line id.
184#[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
257/// Changes quantity, find number and notes of a BOM line.
258///
259/// # Invariants
260///
261/// * **Permission:** the line's parent revision is visible ("not found" otherwise), checked out
262///   by the actor (409 otherwise) and editable (`revisions::ensure_editable`: in work, CAD
263///   editor of its group); the new find number is free (409).
264/// * **Locks:** the parent revision row (`revisions::lock`).
265/// * **Audit:** `bom.update`.
266/// * **Notifications / webhooks:** none.
267/// * **Atomic:** one transaction.
268pub 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    // The notes of a line to a hidden child are not shown to the actor
283    // (placeholder), so a form sends them back empty: they stay as stored.
284    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(&notes)
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
331/// Removes a BOM line from an in-work revision.
332///
333/// # Invariants
334///
335/// * **Permission:** the line's parent revision is visible ("not found" otherwise), checked out
336///   by the actor (409 otherwise) and editable (`revisions::ensure_editable`: in work, CAD
337///   editor of its group).
338/// * **Locks:** the parent revision row (`revisions::lock`).
339/// * **Audit:** `bom.remove`.
340/// * **Notifications / webhooks:** none.
341/// * **Atomic:** one transaction.
342pub 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
379// ---------------------------------------------------------------------------
380// Multi-level expansion
381// ---------------------------------------------------------------------------
382//
383// The expansion is split in two halves:
384//
385// * [`load_structure`] reads the structure level by level – one query per
386//   BOM level, whatever the number of lines – and resolves the child
387//   revision of every line under the revision rule inside that query;
388// * [`assemble`] is pure: it builds the nested tree from the loaded lines
389//   with cycle detection and the mass/cost roll-ups (unit tested without a
390//   database).
391
392/// Upper bound for the number of lines of one expanded BOM. Shared
393/// sub-assemblies are expanded wherever they are used, so a small database
394/// structure can expand to a very large tree.
395const MAX_EXPANDED_LINES: usize = 100_000;
396
397/// The revision chosen for a BOM line under the active revision rule.
398#[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    /// `None` if the revision rule found no matching revision.
433    pub revision: Option<ResolvedRevision>,
434    /// Mass of one unit including its children.
435    pub unit_mass_kg: f64,
436    /// `unit_mass_kg × quantity`.
437    pub total_mass_kg: f64,
438    pub unit_cost_total: f64,
439    pub total_cost: f64,
440    /// False if a mass is missing somewhere in this sub-tree.
441    pub mass_complete: bool,
442    pub cost_complete: bool,
443    /// Set when the structure loops back to an ancestor (should never happen).
444    pub cycle: bool,
445    /// Placeholder for a child the reader may not see (no identity, not
446    /// expanded, left out of the roll-ups).
447    pub restricted: bool,
448    /// Project group of the item; `None` for a restricted placeholder (the
449    /// group of a hidden item is never revealed) and for ungrouped items.
450    pub project_id: Option<Uuid>,
451    pub children: Vec<BomNode>,
452}
453
454impl BomNode {
455    /// A copy of the node without its sub-tree.
456    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    /// Number of lines in the fully expanded structure.
493    pub line_count: usize,
494    /// Number of lines whose child could not be resolved with the rule.
495    pub unresolved_count: usize,
496    /// Lines to children the reader may not see (placeholders).
497    pub restricted_lines: usize,
498    /// Mass and cost exclude restricted lines.
499    pub rollup_incomplete: bool,
500    /// Database queries the expansion took (root + one per BOM level).
501    #[serde(skip)]
502    pub queries: usize,
503}
504
505/// One row of the flattened (indented) BOM, used by the UI and CSV export.
506#[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/// A BOM line of the loaded structure together with the child revision the
515/// revision rule picked for it (`child_revision_id` is `None` if none matched).
516#[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    /// Project group of the child item.
534    #[sqlx(default)]
535    pub child_project_id: Option<Uuid>,
536    /// The child is hidden from the reader: the line is a placeholder that
537    /// is never expanded.
538    #[sqlx(default)]
539    pub restricted: bool,
540}
541
542impl StructureLine {
543    /// Turns the line into a placeholder for a hidden child.
544    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/// An item and the revision the rule picks for it (see [`resolve_items`]).
562#[derive(Clone, Debug, sqlx::FromRow)]
563pub(crate) struct ResolvedItem {
564    pub item_id: Uuid,
565    pub number: String,
566    /// `None` if no revision of the item matches the rule.
567    pub revision_id: Option<Uuid>,
568}
569
570/// Picks the revision of each item according to the rule – one query for
571/// all of them: `latest` = the newest revision, `released` / `as_released`
572/// = the newest released one (an item outside a BOM line has no frozen
573/// as-released revision). Unknown ids are left out.
574pub(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
612/// The BOM lines of a set of parent revisions with their child revisions
613/// resolved by the rule, ordered by parent and find number – one query.
614///
615/// * `latest`: the newest revision of the child item;
616/// * `released`: its newest released revision;
617/// * `as_released`: the revision frozen in the line when the parent was
618///   released (`released_child_revision_id`), else like `released`.
619///
620/// Every lookup per line or child item is an index probe (`LATERAL …
621/// LIMIT 1`, which PostgreSQL does not turn into a join): the planner's
622/// estimate of the lines below thousands of parents is often several times
623/// off, and a join planned for the estimate read all items and revisions
624/// (the scale check: 110 ms instead of a few for one level).
625const 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
663/// Loads every BOM line reachable from `root_id` under the rule, grouped by
664/// parent revision (each group ordered by find number). One query per BOM
665/// level; a revision used in several places is loaded once. Returns the
666/// lines and the number of queries.
667///
668/// Lines whose child the actor may not see become placeholders
669/// ([`StructureLine::restricted`]) and the walk stops there, so nothing
670/// below a hidden sub-assembly is read.
671pub(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    // Lines below MAX_DEPTH are never shown, so their parents are not loaded.
682    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
718/// Expands the full multi-level BOM below a revision the actor may read.
719/// Hidden children are placeholder leaves; the roll-ups leave them out and
720/// say so (`restricted_lines`, `rollup_incomplete`).
721pub 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/// The distinct revisions of a loaded structure, without building the
740/// expanded tree (see [`structure_revisions`]).
741#[derive(Clone, Debug, Default, PartialEq, Eq)]
742pub(crate) struct StructureRevisions {
743    /// `(revision id, item id)` below the root (the root itself is not
744    /// included), in the order of their first appearance in the depth-first,
745    /// find-number-ordered expansion – the order of [`flatten`].
746    pub revisions: Vec<(Uuid, Uuid)>,
747    /// Item numbers of lines the rule could not resolve, first appearance first.
748    pub unresolved: Vec<String>,
749    /// Lines to children the reader may not see (skipped).
750    pub restricted_lines: usize,
751}
752
753/// Lists the revisions and unresolved items of the structure below `root_id`
754/// in the order `flatten(assemble(..))` would show them first, with the same
755/// depth limit and cycle check – but a sub-assembly used in many places is
756/// walked once (again only where it sits higher up, as the depth limit may
757/// then reach further), so shared structures cost no exponential expansion.
758pub(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        /// Shallowest level at which the lines of a revision were walked.
767        walked_at: HashMap<Uuid, usize>,
768        out: StructureRevisions,
769    }
770    impl Walk<'_> {
771        /// Walks the lines of `rev_id`, which sit at `level`.
772        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                // Walked before at this level or higher up: nothing new below.
794                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
819/// Builds the tree from the loaded lines (pure): cycle detection along the
820/// path, the depth limit, roll-ups and line counts.
821pub(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    /// Lines that may still be expanded (see [`MAX_EXPANDED_LINES`]).
863    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
930/// Mass/cost of one unit: its own value plus the totals of its children.
931/// A leaf without a value, or any incomplete child, marks the result incomplete.
932fn 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
967/// The root and every resolved revision of a tree (for the read log).
968pub 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
978/// Flattens the tree into display rows (depth first).
979pub 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/// What the BOM page shows of the expanded structure (the totals always
997/// cover all of it).
998#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
999pub struct ViewOptions {
1000    /// Show the levels `1..=depth`; `None` = all levels.
1001    pub depth: Option<usize>,
1002    /// Keep only the lines of this project group and the path from the top
1003    /// to them.
1004    pub group: Option<Uuid>,
1005}
1006
1007/// One row of the BOM page.
1008#[derive(Clone, Debug, Serialize)]
1009pub struct ViewRow {
1010    pub level: usize,
1011    /// The line has children in the structure (shown or not).
1012    pub has_children: bool,
1013    /// Lines below this one that the depth limit or the group filter left
1014    /// out of this view.
1015    pub hidden_below: usize,
1016    /// The line belongs to the group of the filter.
1017    pub group_match: bool,
1018    /// Code of the project group (`None` for restricted placeholders).
1019    pub project_code: Option<String>,
1020    pub node: BomNode,
1021}
1022
1023/// Rows of the BOM page (depth first): first the group filter – a line stays
1024/// if it or a line below it is in the group, so the path from the top is
1025/// kept, but lines of other groups below a match are not – then the depth
1026/// cut. A line at the last level shown counts the lines hidden below it.
1027/// Pure: roll-ups were computed on the whole tree and are not touched.
1028pub 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            // depth first: the row before is the one at the last level
1057            // shown whose sub-tree this is
1058            last.hidden_below += 1;
1059        }
1060    }
1061    out
1062}
1063
1064/// Codes of the project groups of the given ids (for display; the caller
1065/// passes ids of lines the reader may see).
1066pub 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
1078/// The distinct project groups of the lines of an expanded structure.
1079pub 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
1091/// Exports the expanded BOM as CSV (comma separated, UTF-8).
1092pub 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
1123/// Exports the expanded BOM as Excel workbook: the CSV columns, item numbers
1124/// indented by level, header row frozen and filterable.
1125pub 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" (not "Item") so that the BOM import reads the file back.
1137        "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    /// A node of group `g` at `level` (its children sit one level lower).
1238    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    /// A (g1) > [B (g1) > [C (g2), D (g1)], E (g2) > [F (g2)], G (g1)]
1251    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        // A and B stay as the path to C; D and G belong to another group
1312        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        // C is below the cut: its ancestor B stays and counts it
1330        assert_eq!(names(&rows), ["A", "B", "E"]);
1331        assert_eq!(rows[1].hidden_below, 1);
1332        assert_eq!(rows[2].hidden_below, 1);
1333        // the roll-up of the whole structure is what the rows carry
1334        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        // a leaf without children needs its own value
1368        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    // ----- assemble (the pure half of the expansion) -----
1407
1408    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    /// A line below `parent` that resolved to `child` (`None` = unresolved).
1420    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        // root: 2 × sub, 4 × bolt; sub: 3 × bolt (the bolt revision is shared)
1462        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        // sub unit: 0.5 + 3 × 0.01 = 0.53; root: 1 + 2 × 0.53 + 4 × 0.01
1472        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        // root → sub → root (prevented on input, but the expansion must not loop)
1501        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        // a chain deeper than MAX_DEPTH stops expanding at MAX_DEPTH
1516        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        // a shared sub-assembly used everywhere expands exponentially: refused
1530        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    // ----- structure_revisions (the working set's walk) -----
1545
1546    /// What the working set derived from the expanded tree before
1547    /// `structure_revisions` existed: every revision of `flatten(tree)` once,
1548    /// first appearance first, and the unresolved item numbers.
1549    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    /// A line from `parent` to `revs[child]` whose item is `items[child]`.
1570    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; // too large to expand the old way
1591        };
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        // Random acyclic structures with shared sub-assemblies and unresolved
1602        // lines (a tiny LCG keeps the test deterministic without a dependency).
1603        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        // Under `latest` / `released` the resolved structure can contain
1637        // cycles (A's newest revision uses B, B's newest uses A): lines may
1638        // point to any revision, including ancestors and the root.
1639        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        // root → c1 → … → c(MAX_DEPTH-2) → s → q → r, and root → s directly
1668        // (find no. 20): below the deep s only q is shown (r is too deep),
1669        // below the shallow s also r – both ways must find r.
1670        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        // a cycle stops like in the tree
1688        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}