Skip to main content

teamcraft/services/
bom_compare.rs

1//! BOM comparison between two revisions (Teamcenter's structure compare):
2//! which lines were added, removed or changed (quantity, find number, notes
3//! and, over all levels, the resolved child revision). Lines are matched by
4//! child item and find number first, then by child item. Over all levels, the
5//! children of two matched lines are compared the same way, and an added or
6//! removed line brings its whole sub-tree.
7
8use rust_xlsxwriter::{Format, Workbook};
9use serde::Serialize;
10use sqlx::PgPool;
11use uuid::Uuid;
12
13use crate::{
14    auth::Actor,
15    domain::RevisionRule,
16    error::{AppError, AppResult},
17    models::BomLine,
18    services::bom::{self, BomNode},
19};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
22#[serde(rename_all = "snake_case")]
23pub enum Change {
24    Added,
25    Removed,
26    Changed,
27    Unchanged,
28}
29
30impl Change {
31    /// Label used in the Excel export.
32    pub fn label(self) -> &'static str {
33        match self {
34            Change::Added => "added",
35            Change::Removed => "removed",
36            Change::Changed => "changed",
37            Change::Unchanged => "unchanged",
38        }
39    }
40}
41
42/// How deep a comparison goes: the direct BOM lines or every level.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum Depth {
46    #[default]
47    Single,
48    All,
49}
50
51impl Depth {
52    /// `1` (or nothing) = single level, `all` = every level.
53    pub fn parse(raw: Option<&str>) -> AppResult<Depth> {
54        match raw.map(str::trim) {
55            None | Some("" | "1") => Ok(Depth::Single),
56            Some("all") => Ok(Depth::All),
57            Some(other) => Err(AppError::BadRequest(format!(
58                "Unknown depth '{other}': use 1 or all."
59            ))),
60        }
61    }
62
63    pub fn as_str(self) -> &'static str {
64        match self {
65            Depth::Single => "1",
66            Depth::All => "all",
67        }
68    }
69}
70
71/// Default revision rule of a comparison over all levels (states of the two
72/// revisions). *Latest* and *Latest released* resolve a child by its item
73/// alone, so both sides always get the same child revision and sub-tree;
74/// only *As released* uses the child revisions a released parent was
75/// released with. Without a released side, *Latest* still shows children
76/// that were never released.
77pub fn default_rule(before_state: &str, after_state: &str) -> RevisionRule {
78    let released = |s: &str| matches!(s, "released" | "obsolete");
79    if released(before_state) || released(after_state) {
80        RevisionRule::AsReleased
81    } else {
82        RevisionRule::Latest
83    }
84}
85
86/// One child of either BOM.
87#[derive(Clone, Debug, Serialize)]
88pub struct LineDiff {
89    /// 1 = direct child of the compared revisions.
90    pub level: usize,
91    pub change: Change,
92    pub child_item_id: Uuid,
93    pub child_number: String,
94    pub child_name: String,
95    pub unit: String,
96    /// The line in the first (older) revision, if any.
97    pub before: Option<LineValues>,
98    /// The line in the second (newer) revision, if any.
99    pub after: Option<LineValues>,
100}
101
102#[derive(Clone, Debug, PartialEq, Serialize)]
103pub struct LineValues {
104    pub find_number: i32,
105    pub quantity: f64,
106    pub notes: String,
107    /// The child revision the revision rule resolved (comparison over all
108    /// levels only).
109    pub revision: Option<String>,
110}
111
112impl From<&BomLine> for LineValues {
113    fn from(l: &BomLine) -> Self {
114        LineValues {
115            find_number: l.find_number,
116            quantity: l.quantity,
117            notes: l.notes.clone(),
118            revision: None,
119        }
120    }
121}
122
123/// Counts of a comparison, for the summary line.
124#[derive(Clone, Debug, Default, Serialize)]
125pub struct Summary {
126    pub added: usize,
127    pub removed: usize,
128    pub changed: usize,
129    pub unchanged: usize,
130}
131
132pub fn summary(diffs: &[LineDiff]) -> Summary {
133    let mut s = Summary::default();
134    for d in diffs {
135        match d.change {
136            Change::Added => s.added += 1,
137            Change::Removed => s.removed += 1,
138            Change::Changed => s.changed += 1,
139            Change::Unchanged => s.unchanged += 1,
140        }
141    }
142    s
143}
144
145/// A line of either side: a BOM line, or a line of the expanded structure
146/// with its children.
147struct Side<'a> {
148    item: Uuid,
149    number: &'a str,
150    name: &'a str,
151    unit: &'a str,
152    values: LineValues,
153    children: &'a [BomNode],
154}
155
156impl<'a> Side<'a> {
157    fn line(l: &'a BomLine) -> Self {
158        Side {
159            item: l.child_item_id,
160            number: &l.child_number,
161            name: &l.child_name,
162            unit: &l.child_unit,
163            values: l.into(),
164            children: &[],
165        }
166    }
167
168    fn node(n: &'a BomNode) -> Self {
169        Side {
170            item: n.item_id,
171            number: &n.item_number,
172            name: n.revision.as_ref().map_or("", |r| r.name.as_str()),
173            unit: &n.unit,
174            values: LineValues {
175                find_number: n.find_number,
176                quantity: n.quantity,
177                notes: n.notes.clone(),
178                revision: n.revision.as_ref().map(|r| r.code.clone()),
179            },
180            children: &n.children,
181        }
182    }
183}
184
185type Pair<'a> = (Option<Side<'a>>, Option<Side<'a>>);
186
187/// Matches the lines of one level, ordered by find number.
188fn pairs<'a>(before: Vec<Side<'a>>, after: Vec<Side<'a>>) -> Vec<Pair<'a>> {
189    let mut after: Vec<Option<Side<'a>>> = after.into_iter().map(Some).collect();
190    let mut out = Vec::new();
191    let mut unmatched = Vec::new();
192    // 1. same child and same find number
193    for b in before {
194        let same = after.iter().position(|a| {
195            a.as_ref()
196                .is_some_and(|a| a.item == b.item && a.values.find_number == b.values.find_number)
197        });
198        match same {
199            Some(i) => out.push((Some(b), after[i].take())),
200            None => unmatched.push(b),
201        }
202    }
203    // 2. same child, the find number changed
204    for b in unmatched {
205        match after
206            .iter()
207            .position(|a| a.as_ref().is_some_and(|a| a.item == b.item))
208        {
209            Some(i) => out.push((Some(b), after[i].take())),
210            None => out.push((Some(b), None)),
211        }
212    }
213    out.extend(after.into_iter().flatten().map(|a| (None, Some(a))));
214    out.sort_by(|x, y| sort_key(x).cmp(&sort_key(y)));
215    out
216}
217
218fn sort_key<'a>(pair: &Pair<'a>) -> (i32, &'a str) {
219    match pair.1.as_ref().or(pair.0.as_ref()) {
220        Some(s) => (s.values.find_number, s.number),
221        None => (0, ""),
222    }
223}
224
225/// Compares one level and, below matched, added and removed lines, their
226/// children (BOM lines have none).
227fn walk<'a>(before: Vec<Side<'a>>, after: Vec<Side<'a>>, level: usize, out: &mut Vec<LineDiff>) {
228    for (b, a) in pairs(before, after) {
229        let change = match (&b, &a) {
230            (Some(b), Some(a)) if b.values == a.values => Change::Unchanged,
231            (Some(_), Some(_)) => Change::Changed,
232            (Some(_), None) => Change::Removed,
233            (None, _) => Change::Added,
234        };
235        let Some(shown) = a.as_ref().or(b.as_ref()) else {
236            continue;
237        };
238        out.push(LineDiff {
239            level,
240            change,
241            child_item_id: shown.item,
242            child_number: shown.number.to_string(),
243            child_name: shown.name.to_string(),
244            unit: shown.unit.to_string(),
245            before: b.as_ref().map(|s| s.values.clone()),
246            after: a.as_ref().map(|s| s.values.clone()),
247        });
248        let children = |s: &Option<Side<'a>>| -> Vec<Side<'a>> {
249            s.as_ref()
250                .map(|s| s.children.iter().map(Side::node).collect())
251                .unwrap_or_default()
252        };
253        walk(children(&b), children(&a), level + 1, out);
254    }
255}
256
257/// Compares the BOM lines `before` with `after` (pure, see [`compare`]).
258pub fn diff(before: &[BomLine], after: &[BomLine]) -> Vec<LineDiff> {
259    let mut out = Vec::new();
260    walk(
261        before.iter().map(Side::line).collect(),
262        after.iter().map(Side::line).collect(),
263        1,
264        &mut out,
265    );
266    out
267}
268
269/// Compares two expanded BOMs over all levels (pure, see [`compare`]); the
270/// rows follow the structure, children right below their line.
271pub fn diff_trees(before: &[BomNode], after: &[BomNode]) -> Vec<LineDiff> {
272    let mut out = Vec::new();
273    walk(
274        before.iter().map(Side::node).collect(),
275        after.iter().map(Side::node).collect(),
276        1,
277        &mut out,
278    );
279    out
280}
281
282/// Compares the BOMs of two revisions: the direct lines, or every level with
283/// each child resolved by `rule`.
284pub async fn compare(
285    pool: &PgPool,
286    actor: &Actor,
287    before: Uuid,
288    after: Uuid,
289    depth: Depth,
290    rule: RevisionRule,
291) -> AppResult<Vec<LineDiff>> {
292    match depth {
293        Depth::Single => {
294            let (a, b) = tokio::try_join!(
295                bom::lines(pool, actor, before),
296                bom::lines(pool, actor, after)
297            )?;
298            Ok(diff(&a, &b))
299        }
300        Depth::All => {
301            let (a, b) = tokio::try_join!(
302                bom::tree(pool, actor, before, rule),
303                bom::tree(pool, actor, after, rule)
304            )?;
305            Ok(diff_trees(&a.children, &b.children))
306        }
307    }
308}
309
310/// Column headers of the Excel export, in order.
311const XLSX_HEADERS: [&str; 13] = [
312    "Level",
313    "Change",
314    "Child number",
315    "Name",
316    "Unit",
317    "Before: find no.",
318    "Before: quantity",
319    "Before: revision",
320    "Before: notes",
321    "After: find no.",
322    "After: quantity",
323    "After: revision",
324    "After: notes",
325];
326
327/// Column widths of the Excel export, matching [`XLSX_HEADERS`].
328const XLSX_WIDTHS: [f64; 13] = [
329    7.0, 10.0, 22.0, 36.0, 10.0, 16.0, 16.0, 16.0, 36.0, 16.0, 16.0, 16.0, 36.0,
330];
331
332/// Builds the comparison as an `.xlsx` workbook: a header row and one row per
333/// line (child numbers indented by level), with the values of the older and
334/// the newer revision side by side.
335pub fn to_xlsx(diffs: &[LineDiff]) -> AppResult<Vec<u8>> {
336    let mut workbook = Workbook::new();
337    let sheet = workbook.add_worksheet();
338    // An invalid sheet name is rejected when the workbook is saved.
339    sheet.set_name("BOM comparison").map_err(xlsx_error)?;
340    for (col, (title, width)) in XLSX_HEADERS.iter().zip(XLSX_WIDTHS).enumerate() {
341        let col = col as u16;
342        sheet.write_string(0, col, *title).map_err(xlsx_error)?;
343        sheet.set_column_width(col, width).map_err(xlsx_error)?;
344    }
345    for (i, line) in diffs.iter().enumerate() {
346        let row = i as u32 + 1;
347        let indent = Format::new().set_indent(line.level.saturating_sub(1).min(15) as u8);
348        sheet
349            .write_number(row, 0, line.level as f64)
350            .map_err(xlsx_error)?;
351        sheet
352            .write_string(row, 1, line.change.label())
353            .map_err(xlsx_error)?;
354        sheet
355            .write_string_with_format(row, 2, line.child_number.as_str(), &indent)
356            .map_err(xlsx_error)?;
357        sheet
358            .write_string(row, 3, line.child_name.as_str())
359            .map_err(xlsx_error)?;
360        sheet
361            .write_string(row, 4, line.unit.as_str())
362            .map_err(xlsx_error)?;
363        for (values, base) in [(&line.before, 5u16), (&line.after, 9u16)] {
364            let Some(values) = values else { continue };
365            sheet
366                .write_number(row, base, f64::from(values.find_number))
367                .map_err(xlsx_error)?;
368            sheet
369                .write_number(row, base + 1, values.quantity)
370                .map_err(xlsx_error)?;
371            sheet
372                .write_string(row, base + 2, values.revision.as_deref().unwrap_or(""))
373                .map_err(xlsx_error)?;
374            sheet
375                .write_string(row, base + 3, values.notes.as_str())
376                .map_err(xlsx_error)?;
377        }
378    }
379    workbook.save_to_buffer().map_err(xlsx_error)
380}
381
382/// The workbook is built in memory; a failure here is a bug, not user input.
383fn xlsx_error(e: rust_xlsxwriter::XlsxError) -> AppError {
384    AppError::Other(anyhow::anyhow!("xlsx export failed: {e}"))
385}
386
387#[cfg(test)]
388mod tests {
389    use std::io::Read;
390
391    use super::*;
392
393    fn line(child: u128, number: &str, find: i32, qty: f64) -> BomLine {
394        BomLine {
395            id: Uuid::new_v4(),
396            parent_revision_id: Uuid::nil(),
397            child_item_id: Uuid::from_u128(child),
398            child_number: number.into(),
399            child_type: "part".into(),
400            child_unit: "each".into(),
401            find_number: find,
402            quantity: qty,
403            notes: String::new(),
404            child_latest_revision_id: Uuid::nil(),
405            child_latest_code: "A".into(),
406            child_latest_state: "released".into(),
407            child_name: number.into(),
408            released_child_revision_id: None,
409            released_child_code: None,
410            child_project_id: None,
411            restricted: false,
412        }
413    }
414
415    #[test]
416    fn added_removed_changed_unchanged() {
417        let before = [
418            line(1, "PRT-1", 10, 4.0),
419            line(2, "PRT-2", 20, 1.0),
420            line(3, "PRT-3", 30, 2.0),
421            line(5, "PRT-5", 50, 1.0),
422        ];
423        let after = [
424            line(1, "PRT-1", 10, 4.0),
425            line(2, "PRT-2", 20, 2.0),
426            line(4, "PRT-4", 40, 1.0),
427            line(5, "PRT-5", 55, 1.0),
428        ];
429        let d = diff(&before, &after);
430        let by = |n: &str| d.iter().find(|x| x.child_number == n).unwrap().change;
431        assert_eq!(by("PRT-1"), Change::Unchanged);
432        assert_eq!(by("PRT-2"), Change::Changed, "quantity");
433        assert_eq!(by("PRT-3"), Change::Removed);
434        assert_eq!(by("PRT-4"), Change::Added);
435        assert_eq!(by("PRT-5"), Change::Changed, "find number moved");
436        let s = summary(&d);
437        assert_eq!((s.added, s.removed, s.changed, s.unchanged), (1, 1, 2, 1));
438        assert!(diff(&[], &[]).is_empty());
439    }
440
441    /// A line of an expanded BOM: `(item, number, find, quantity, revision)`.
442    fn node(
443        item: u128,
444        number: &str,
445        find: i32,
446        qty: f64,
447        code: &str,
448        children: Vec<BomNode>,
449    ) -> BomNode {
450        BomNode {
451            line_id: Uuid::new_v4(),
452            level: 1,
453            find_number: find,
454            quantity: qty,
455            notes: String::new(),
456            item_id: Uuid::from_u128(item),
457            item_number: number.into(),
458            item_type: if children.is_empty() {
459                "part"
460            } else {
461                "assembly"
462            }
463            .into(),
464            unit: "each".into(),
465            revision: Some(bom::ResolvedRevision {
466                id: Uuid::new_v4(),
467                code: code.into(),
468                state: "released".into(),
469                name: format!("{number} name"),
470                mass_kg: None,
471                unit_cost: None,
472            }),
473            unit_mass_kg: 0.0,
474            total_mass_kg: 0.0,
475            unit_cost_total: 0.0,
476            total_cost: 0.0,
477            mass_complete: false,
478            cost_complete: false,
479            cycle: false,
480            restricted: false,
481            project_id: None,
482            children,
483        }
484    }
485
486    #[test]
487    fn default_rules() {
488        assert_eq!(default_rule("in_work", "in_work"), RevisionRule::Latest);
489        assert_eq!(
490            default_rule("released", "in_work"),
491            RevisionRule::AsReleased
492        );
493        assert_eq!(
494            default_rule("obsolete", "released"),
495            RevisionRule::AsReleased
496        );
497    }
498
499    #[test]
500    fn all_levels() {
501        let before = [
502            node(
503                1,
504                "ROTOR",
505                10,
506                1.0,
507                "A",
508                vec![
509                    node(2, "SHAFT", 10, 1.0, "A", vec![]),
510                    node(3, "KEY", 20, 1.0, "A", vec![]),
511                ],
512            ),
513            node(
514                9,
515                "OLD-ASM",
516                30,
517                1.0,
518                "A",
519                vec![node(10, "OLD-PART", 10, 2.0, "A", vec![])],
520            ),
521        ];
522        let after = [
523            node(
524                1,
525                "ROTOR",
526                10,
527                1.0,
528                "B",
529                vec![
530                    node(2, "SHAFT", 10, 1.0, "A", vec![]),
531                    node(4, "BEARING", 30, 2.0, "A", vec![]),
532                ],
533            ),
534            node(
535                5,
536                "NEW-ASM",
537                20,
538                1.0,
539                "A",
540                vec![node(6, "NEW-PART", 10, 4.0, "A", vec![])],
541            ),
542        ];
543        let d = diff_trees(&before, &after);
544        let rows: Vec<(usize, &str, Change)> = d
545            .iter()
546            .map(|l| (l.level, l.child_number.as_str(), l.change))
547            .collect();
548        assert_eq!(
549            rows,
550            vec![
551                (1, "ROTOR", Change::Changed),
552                (2, "SHAFT", Change::Unchanged),
553                (2, "KEY", Change::Removed),
554                (2, "BEARING", Change::Added),
555                (1, "NEW-ASM", Change::Added),
556                (2, "NEW-PART", Change::Added),
557                (1, "OLD-ASM", Change::Removed),
558                (2, "OLD-PART", Change::Removed),
559            ]
560        );
561        let rotor = &d[0];
562        assert_eq!(
563            (
564                rotor.before.as_ref().unwrap().revision.as_deref(),
565                rotor.after.as_ref().unwrap().revision.as_deref()
566            ),
567            (Some("A"), Some("B")),
568            "a new child revision is a change"
569        );
570        assert_eq!(d[3].child_name, "BEARING name");
571        assert_eq!(Depth::parse(Some("all")).unwrap(), Depth::All);
572        assert_eq!(Depth::parse(None).unwrap().as_str(), "1");
573        assert!(Depth::parse(Some("2")).is_err());
574    }
575
576    #[test]
577    fn xlsx_export_contains_the_lines() {
578        let before = [line(1, "PRT-1", 10, 4.0), line(2, "PRT-2", 20, 1.0)];
579        let after = [line(1, "PRT-1", 10, 4.0), line(3, "PRT-3", 30, 2.0)];
580        let bytes = to_xlsx(&diff(&before, &after)).expect("xlsx export");
581
582        assert!(bytes.starts_with(b"PK"), "an xlsx file is a zip archive");
583        let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip archive");
584        assert!(
585            zip.by_name("xl/worksheets/sheet1.xml").is_ok(),
586            "the workbook has a worksheet"
587        );
588        // Text lives in the shared string table, numbers in the worksheet.
589        let mut xml = String::new();
590        for i in 0..zip.len() {
591            let mut entry = zip.by_index(i).expect("zip entry");
592            if entry.name().is_ok_and(|n| n.ends_with(".xml")) {
593                let mut text = String::new();
594                entry.read_to_string(&mut text).expect("UTF-8 XML");
595                xml.push_str(&text);
596            }
597        }
598        for needle in ["PRT-1", "PRT-2", "PRT-3", "removed", "added", "unchanged"] {
599            assert!(xml.contains(needle), "the workbook contains '{needle}'");
600        }
601    }
602}