1use 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 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#[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 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
71pub 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#[derive(Clone, Debug, Serialize)]
88pub struct LineDiff {
89 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 pub before: Option<LineValues>,
98 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 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#[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
145struct 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
187fn 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 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 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
225fn 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
257pub 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
269pub 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
282pub 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
310const 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
327const 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
332pub fn to_xlsx(diffs: &[LineDiff]) -> AppResult<Vec<u8>> {
336 let mut workbook = Workbook::new();
337 let sheet = workbook.add_worksheet();
338 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
382fn 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 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 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}