1use std::collections::{HashMap, HashSet};
42
43use serde::{Deserialize, Serialize};
44use serde_json::json;
45use sqlx::PgPool;
46use uuid::Uuid;
47
48use crate::{
49 auth::Actor,
50 cadformat::FormatKind,
51 error::{AppError, AppResult},
52 services::{
53 audit, bom,
54 references::{self, FileWithReferences},
55 revisions,
56 },
57 util, AppState,
58};
59
60pub const ASSUMED_NOTE: &str = "quantity not known from the CAD file – check it";
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
64#[serde(rename_all = "snake_case")]
65pub enum QuantitySource {
66 CadInstances,
68 Assumed,
70}
71
72impl QuantitySource {
73 pub fn label(self) -> &'static str {
74 match self {
75 QuantitySource::CadInstances => "CAD instances",
76 QuantitySource::Assumed => "assumed 1",
77 }
78 }
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
82#[serde(rename_all = "snake_case")]
83pub enum ChildStatus {
84 New,
86 AlreadyInBom,
88 Differs,
90 Circular,
92}
93
94impl ChildStatus {
95 pub fn label(self) -> &'static str {
96 match self {
97 ChildStatus::New => "new line",
98 ChildStatus::AlreadyInBom => "already in the BOM",
99 ChildStatus::Differs => "differs",
100 ChildStatus::Circular => "circular",
101 }
102 }
103}
104
105#[derive(Clone, Debug, Serialize)]
107pub struct PlannedChild {
108 pub item_id: Uuid,
109 pub item_number: String,
110 pub item_name: String,
111 pub quantity: f64,
112 pub quantity_source: QuantitySource,
113 pub quantity_label: &'static str,
114 pub quantity_text: String,
116 pub bom_quantity_text: Option<String>,
117 pub via: String,
119 pub status: ChildStatus,
120 pub status_label: &'static str,
121 pub bom_quantity: Option<f64>,
123 pub selected: bool,
125}
126
127#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
129pub struct Unmatched {
130 pub name: String,
131 pub via: String,
132}
133
134#[derive(Clone, Debug, Default, Serialize)]
135pub struct Plan {
136 pub revision_id: Uuid,
137 pub children: Vec<PlannedChild>,
138 pub not_in_teamcraft: Vec<Unmatched>,
139 pub hidden: i64,
142 pub excluded: Vec<String>,
144}
145
146#[derive(Clone, Debug, PartialEq)]
148pub(crate) struct Candidate {
149 pub item_id: Uuid,
150 pub item_number: String,
151 pub item_name: String,
152 pub quantity: f64,
153 pub source: QuantitySource,
154 pub via: String,
155}
156
157pub(crate) struct SourceFile<'a> {
159 pub file: &'a FileWithReferences,
160 pub format: &'a str,
162 pub counts: Option<&'a HashMap<String, u32>>,
164}
165
166#[derive(Debug, Default, PartialEq)]
167pub(crate) struct Collected {
168 pub children: Vec<Candidate>,
169 pub unmatched: Vec<Unmatched>,
170 pub excluded: Vec<String>,
171}
172
173fn is_structure(src: &SourceFile<'_>) -> bool {
175 match src.file.format_kind {
176 Some(FormatKind::Assembly) => true,
177 Some(FormatKind::Model) if src.format == "nx-part" => {
179 src.counts.is_some_and(|c| !c.is_empty())
180 }
181 Some(FormatKind::Exchange) if src.format == "step" => {
183 src.counts.is_some_and(|c| !c.is_empty())
184 }
185 _ => false,
186 }
187}
188
189pub(crate) fn collect(files: &[SourceFile<'_>]) -> Collected {
192 let mut out = Collected::default();
193 let mut seen: HashSet<Uuid> = HashSet::new();
194 let mut seen_unmatched: HashSet<String> = HashSet::new();
195 for src in files.iter().filter(|s| is_structure(s)) {
196 for name in &src.file.references {
197 let key = name.name.to_lowercase();
198 let counted = src.counts.and_then(|c| c.get(&key)).copied();
199 if counted == Some(0) {
200 if !out.excluded.contains(&name.name) {
201 out.excluded.push(name.name.clone());
202 }
203 continue;
204 }
205 if name.matches.is_empty() {
206 if seen_unmatched.insert(key) {
207 out.unmatched.push(Unmatched {
208 name: name.name.clone(),
209 via: src.file.filename.clone(),
210 });
211 }
212 continue;
213 }
214 let Some(m) = name.matches.iter().find(|m| !m.same_revision) else {
216 continue;
217 };
218 if !seen.insert(m.item_id) {
219 continue;
220 }
221 let (quantity, source) = match counted {
222 Some(n) => (f64::from(n), QuantitySource::CadInstances),
223 None => (1.0, QuantitySource::Assumed),
224 };
225 out.children.push(Candidate {
226 item_id: m.item_id,
227 item_number: m.item_number.clone(),
228 item_name: m.item_name.clone(),
229 quantity,
230 source,
231 via: format!("{} → {}", src.file.filename, m.filename),
232 });
233 }
234 }
235 out
236}
237
238pub(crate) fn status_of(bom_quantity: Option<f64>, quantity: f64, circular: bool) -> ChildStatus {
241 match bom_quantity {
242 Some(q) if (q - quantity).abs() < 1e-9 => ChildStatus::AlreadyInBom,
243 Some(_) => ChildStatus::Differs,
244 None if circular => ChildStatus::Circular,
245 None => ChildStatus::New,
246 }
247}
248
249pub(crate) fn line_note(c: &Candidate) -> String {
251 match c.source {
252 QuantitySource::CadInstances => format!(
253 "From the CAD references ({} instance{})",
254 util::format_number(c.quantity, 4),
255 if (c.quantity - 1.0).abs() < 1e-9 {
256 ""
257 } else {
258 "s"
259 }
260 ),
261 QuantitySource::Assumed => ASSUMED_NOTE.to_string(),
262 }
263}
264
265#[derive(sqlx::FromRow)]
266struct FileRow {
267 id: Uuid,
268 sha256: String,
269 format: String,
270}
271
272async fn read_counts(
274 st: &AppState,
275 sha256: &str,
276 format: &str,
277) -> AppResult<Option<HashMap<String, u32>>> {
278 let path = st.vault.object_path(sha256)?;
279 let format = format.to_string();
280 tokio::task::spawn_blocking(move || crate::cadrefs::instance_counts(&path, &format))
282 .await
283 .map_err(|e| AppError::Other(anyhow::anyhow!("reading the CAD file failed: {e}")))
284}
285
286async fn hidden_names(
288 pool: &PgPool,
289 actor: &Actor,
290 revision_id: Uuid,
291 names: &[String],
292) -> AppResult<Vec<(String, Uuid)>> {
293 if names.is_empty() || actor.hidden_projects.is_empty() {
294 return Ok(Vec::new());
295 }
296 let keys: Vec<String> = names.iter().map(|n| n.to_lowercase()).collect();
297 Ok(sqlx::query_as::<_, (String, Uuid)>(
298 "SELECT DISTINCT lower(f.filename), i.id
299 FROM files f
300 JOIN revisions r ON r.id = f.revision_id
301 JOIN items i ON i.id = r.item_id
302 WHERE lower(f.filename) = ANY($1)
303 AND r.item_id <> (SELECT item_id FROM revisions WHERE id = $2)
304 AND r.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r.item_id)
305 AND i.project_id = ANY($3)",
306 )
307 .bind(&keys)
308 .bind(revision_id)
309 .bind(&actor.hidden_projects)
310 .fetch_all(pool)
311 .await?)
312}
313
314pub async fn plan(st: &AppState, actor: &Actor, revision_id: Uuid) -> AppResult<Plan> {
318 let rev = revisions::get(&st.db, actor, revision_id).await?;
319 let files = references::files_of_revision(&st.db, actor, revision_id).await?;
320 let ids: Vec<Uuid> = files.iter().map(|f| f.file_id).collect();
321 let rows = sqlx::query_as::<_, FileRow>(
322 "SELECT id, sha256, COALESCE(format, '') AS format FROM files WHERE id = ANY($1)",
323 )
324 .bind(&ids)
325 .fetch_all(&st.db)
326 .await?;
327 let meta: HashMap<Uuid, &FileRow> = rows.iter().map(|r| (r.id, r)).collect();
328 let mut counts: HashMap<Uuid, HashMap<String, u32>> = HashMap::new();
329 for f in &files {
330 if let Some(row) = meta.get(&f.file_id) {
331 if let Some(c) = read_counts(st, &row.sha256, &row.format).await? {
332 counts.insert(f.file_id, c);
333 }
334 }
335 }
336 let sources: Vec<SourceFile<'_>> = files
337 .iter()
338 .map(|f| SourceFile {
339 file: f,
340 format: meta.get(&f.file_id).map_or("", |r| r.format.as_str()),
341 counts: counts.get(&f.file_id),
342 })
343 .collect();
344 let mut collected = collect(&sources);
345
346 let existing: HashMap<Uuid, f64> = bom::lines(&st.db, actor, revision_id)
347 .await?
348 .into_iter()
349 .map(|l| (l.child_item_id, l.quantity))
350 .collect();
351 let mut conn = st.db.acquire().await?;
352 let mut children = Vec::new();
353 for c in collected.children {
354 let bom_quantity = existing.get(&c.item_id).copied();
355 let circular =
356 bom_quantity.is_none() && bom::creates_cycle(&mut conn, rev.item_id, c.item_id).await?;
357 let status = status_of(bom_quantity, c.quantity, circular);
358 children.push(PlannedChild {
359 item_id: c.item_id,
360 item_number: c.item_number,
361 item_name: c.item_name,
362 quantity: c.quantity,
363 quantity_source: c.source,
364 quantity_label: c.source.label(),
365 quantity_text: util::format_number(c.quantity, 4),
366 bom_quantity_text: bom_quantity.map(|q| util::format_number(q, 4)),
367 via: c.via,
368 status,
369 status_label: status.label(),
370 bom_quantity,
371 selected: status == ChildStatus::New,
372 });
373 }
374 drop(conn);
375
376 let names: Vec<String> = collected.unmatched.iter().map(|u| u.name.clone()).collect();
378 let hidden = hidden_names(&st.db, actor, revision_id, &names).await?;
379 let hidden_keys: HashSet<&str> = hidden.iter().map(|(k, _)| k.as_str()).collect();
380 collected
381 .unmatched
382 .retain(|u| !hidden_keys.contains(u.name.to_lowercase().as_str()));
383 let hidden_items: HashSet<Uuid> = hidden.iter().map(|(_, id)| *id).collect();
384 Ok(Plan {
385 revision_id,
386 children,
387 not_in_teamcraft: collected.unmatched,
388 hidden: hidden_items.len() as i64,
389 excluded: collected.excluded,
390 })
391}
392
393#[derive(Clone, Debug, Default, Deserialize)]
394pub struct ApplyRequest {
395 #[serde(default)]
397 pub children: Vec<String>,
398 #[serde(default)]
400 pub update_quantities: bool,
401}
402
403#[derive(Clone, Debug, Default, Serialize)]
404pub struct Refused {
405 pub child: String,
406 pub reason: String,
407}
408
409#[derive(Clone, Debug, Default, Serialize)]
410pub struct ApplyResult {
411 pub added: Vec<String>,
412 pub updated: Vec<String>,
413 pub skipped: Vec<String>,
416 pub refused: Vec<Refused>,
417}
418
419pub async fn apply(
421 st: &AppState,
422 actor: &Actor,
423 revision_id: Uuid,
424 req: &ApplyRequest,
425) -> AppResult<ApplyResult> {
426 let rev = revisions::get(&st.db, actor, revision_id).await?;
427 revisions::ensure_editable(&rev, actor)?;
428 if req.children.is_empty() {
429 return Err(AppError::BadRequest(
430 "Choose at least one child to add.".into(),
431 ));
432 }
433 let plan = plan(st, actor, revision_id).await?;
434 let by_number: HashMap<&str, &PlannedChild> = plan
435 .children
436 .iter()
437 .map(|c| (c.item_number.as_str(), c))
438 .collect();
439 let mut chosen: Vec<&PlannedChild> = Vec::new();
440 let mut seen = HashSet::new();
441 for number in &req.children {
442 let number = number.trim();
443 let Some(child) = by_number.get(number) else {
444 return Err(AppError::BadRequest(format!(
445 "{number} is not a part the CAD references of this revision lead to."
446 )));
447 };
448 if seen.insert(number.to_string()) {
449 chosen.push(child);
450 }
451 }
452
453 let mut tx = st.db.begin().await?;
454 let parent = revisions::lock(&mut tx, revision_id).await?;
455 revisions::ensure_editable(&parent, actor)?;
456 let existing: HashMap<Uuid, (Uuid, f64)> = sqlx::query_as::<_, (Uuid, Uuid, f64)>(
458 "SELECT child_item_id, id, quantity FROM bom_lines WHERE parent_revision_id = $1",
459 )
460 .bind(parent.id)
461 .fetch_all(&mut *tx)
462 .await?
463 .into_iter()
464 .map(|(child, id, q)| (child, (id, q)))
465 .collect();
466 let mut result = ApplyResult::default();
467 for child in chosen {
468 let candidate = Candidate {
469 item_id: child.item_id,
470 item_number: child.item_number.clone(),
471 item_name: child.item_name.clone(),
472 quantity: child.quantity,
473 source: child.quantity_source,
474 via: child.via.clone(),
475 };
476 match existing.get(&child.item_id) {
477 Some((line_id, q)) if (q - child.quantity).abs() >= 1e-9 && req.update_quantities => {
478 sqlx::query("UPDATE bom_lines SET quantity = $2 WHERE id = $1")
479 .bind(line_id)
480 .bind(child.quantity)
481 .execute(&mut *tx)
482 .await?;
483 bom::touch(&mut tx, parent.id).await?;
484 audit::record_related(
485 &mut tx,
486 Some(actor),
487 "bom.update",
488 "revision",
489 Some(parent.id),
490 Some(child.item_id),
491 format!(
492 "Changed the quantity of {} in {} from {} to {} (CAD references)",
493 child.item_number,
494 revisions::label(&parent),
495 util::format_number(*q, 4),
496 util::format_number(child.quantity, 4)
497 ),
498 json!({ "item_id": parent.item_id, "line_id": line_id }),
499 )
500 .await?;
501 result.updated.push(child.item_number.clone());
502 }
503 Some(_) => result.skipped.push(child.item_number.clone()),
504 None => {
505 match bom::add_tx(
506 &mut tx,
507 actor,
508 &parent,
509 child.item_id,
510 &child.item_number,
511 child.quantity,
512 None,
513 &line_note(&candidate),
514 )
515 .await
516 {
517 Ok(_) => result.added.push(child.item_number.clone()),
518 Err(AppError::Conflict(reason)) if child.status == ChildStatus::Circular => {
519 result.refused.push(Refused {
520 child: child.item_number.clone(),
521 reason,
522 })
523 }
524 Err(e) => return Err(e),
525 }
526 }
527 }
528 }
529 audit::record_related(
530 &mut tx,
531 Some(actor),
532 "bom.from_references",
533 "revision",
534 Some(parent.id),
535 None,
536 format!(
537 "Created BOM lines of {} from the CAD references: {} added, {} updated",
538 revisions::label(&parent),
539 result.added.len(),
540 result.updated.len()
541 ),
542 json!({
543 "item_id": parent.item_id,
544 "source": "cad_references",
545 "added": result.added.len(),
546 "updated": result.updated.len(),
547 "skipped": result.skipped.len(),
548 "refused": result.refused.len(),
549 }),
550 )
551 .await?;
552 tx.commit().await?;
553 Ok(result)
554}
555
556pub fn new_children(plan: &Plan) -> usize {
559 plan.children
560 .iter()
561 .filter(|c| c.status == ChildStatus::New)
562 .count()
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568 use crate::services::references::{FileMatch, ReferencedName};
569
570 fn m(item: u128, number: &str, filename: &str, same: bool) -> FileMatch {
571 FileMatch {
572 key: filename.to_lowercase(),
573 item_id: Uuid::from_u128(item),
574 item_number: number.into(),
575 item_name: format!("{number} name"),
576 revision_id: Uuid::from_u128(item + 1000),
577 revision_code: "A".into(),
578 revision_state: "in_work".into(),
579 file_id: Uuid::from_u128(item + 2000),
580 filename: filename.into(),
581 same_revision: same,
582 export_name: false,
583 }
584 }
585
586 fn file(name: &str, kind: FormatKind, refs: Vec<(&str, Vec<FileMatch>)>) -> FileWithReferences {
587 FileWithReferences {
588 file_id: Uuid::from_u128(9),
589 filename: name.into(),
590 format_kind: Some(kind),
591 references: refs
592 .into_iter()
593 .map(|(n, matches)| ReferencedName {
594 name: n.into(),
595 instances: None,
596 matches,
597 })
598 .collect(),
599 }
600 }
601
602 #[test]
603 fn dedupes_skips_self_and_lists_unmatched() {
604 let asm = file(
605 "A.SLDASM",
606 FormatKind::Assembly,
607 vec![
608 ("bolt.sldprt", vec![m(1, "PRT-1", "bolt.sldprt", false)]),
609 ("bolt2.sldprt", vec![m(1, "PRT-1", "bolt2.sldprt", false)]),
611 ("own.sldprt", vec![m(2, "ASM-1", "own.sldprt", true)]),
613 ("ghost.sldprt", vec![]),
614 ("Ghost.SLDPRT", vec![]),
615 ],
616 );
617 let c = collect(&[SourceFile {
618 file: &asm,
619 format: "solidworks-assembly",
620 counts: None,
621 }]);
622 assert_eq!(c.children.len(), 1);
623 assert_eq!(c.children[0].item_number, "PRT-1");
624 assert_eq!(c.children[0].quantity, 1.0);
625 assert_eq!(c.children[0].source, QuantitySource::Assumed);
626 assert_eq!(c.unmatched.len(), 1, "unmatched names once, any case");
627 assert_eq!(c.unmatched[0].name, "ghost.sldprt");
628 assert!(line_note(&c.children[0]).contains("check it"));
629 }
630
631 #[test]
632 fn quantities_come_from_the_instance_counts() {
633 let asm = file(
634 "A.SLDASM",
635 FormatKind::Assembly,
636 vec![
637 ("bolt.sldprt", vec![m(1, "PRT-1", "bolt.sldprt", false)]),
638 ("glue.sldprt", vec![m(3, "PRT-3", "glue.sldprt", false)]),
639 ("nut.sldprt", vec![m(4, "PRT-4", "nut.sldprt", false)]),
640 ],
641 );
642 let counts: HashMap<String, u32> = [
643 ("bolt.sldprt".to_string(), 4),
644 ("glue.sldprt".to_string(), 0),
645 ]
646 .into();
647 let c = collect(&[SourceFile {
648 file: &asm,
649 format: "solidworks-assembly",
650 counts: Some(&counts),
651 }]);
652 let numbers: Vec<_> = c.children.iter().map(|c| c.item_number.as_str()).collect();
653 assert_eq!(numbers, ["PRT-1", "PRT-4"]);
654 assert_eq!(c.children[0].quantity, 4.0);
655 assert_eq!(c.children[0].source, QuantitySource::CadInstances);
656 assert_eq!(
657 line_note(&c.children[0]),
658 "From the CAD references (4 instances)"
659 );
660 assert_eq!(c.children[1].source, QuantitySource::Assumed);
661 assert_eq!(c.excluded, ["glue.sldprt"]);
662 }
663
664 #[test]
665 fn drawings_and_parts_do_not_count_and_nx_needs_instances() {
666 let refs = || vec![("bolt.prt", vec![m(1, "PRT-1", "bolt.prt", false)])];
667 let drawing = file("D.SLDDRW", FormatKind::Drawing, refs());
668 let part = file("P.SLDPRT", FormatKind::Part, refs());
669 let nx = file("N.prt", FormatKind::Model, refs());
670 let sources = |nx_counts| {
671 vec![
672 SourceFile {
673 file: &drawing,
674 format: "solidworks-drawing",
675 counts: None,
676 },
677 SourceFile {
678 file: &part,
679 format: "solidworks-part",
680 counts: None,
681 },
682 SourceFile {
683 file: &nx,
684 format: "nx-part",
685 counts: nx_counts,
686 },
687 ]
688 };
689 assert!(collect(&sources(None)).children.is_empty());
690 let empty = HashMap::new();
691 assert!(collect(&sources(Some(&empty))).children.is_empty());
692 let counts: HashMap<String, u32> = [("bolt.prt".to_string(), 6)].into();
693 let c = collect(&sources(Some(&counts)));
694 assert_eq!(c.children.len(), 1);
695 assert_eq!(c.children[0].quantity, 6.0);
696 }
697
698 #[test]
699 fn step_files_count_only_with_external_occurrences() {
700 let step = file(
701 "pump.stp",
702 FormatKind::Exchange,
703 vec![("bolt.stp", vec![m(1, "PRT-1", "bolt.stp", false)])],
704 );
705 let source = |counts| SourceFile {
706 file: &step,
707 format: "step",
708 counts,
709 };
710 assert!(collect(&[source(None)]).children.is_empty());
712 let counts: HashMap<String, u32> = [("bolt.stp".to_string(), 4)].into();
713 let c = collect(&[source(Some(&counts))]);
714 assert_eq!(c.children.len(), 1);
715 assert_eq!(c.children[0].quantity, 4.0);
716 assert_eq!(c.children[0].source, QuantitySource::CadInstances);
717 }
718
719 #[test]
720 fn status_compares_with_the_bom() {
721 assert_eq!(status_of(None, 2.0, false), ChildStatus::New);
722 assert_eq!(status_of(None, 2.0, true), ChildStatus::Circular);
723 assert_eq!(status_of(Some(2.0), 2.0, false), ChildStatus::AlreadyInBom);
724 assert_eq!(status_of(Some(2.0), 4.0, false), ChildStatus::Differs);
725 assert_eq!(status_of(Some(2.0), 2.0, true), ChildStatus::AlreadyInBom);
727 }
728}