1use std::collections::{hash_map::Entry, HashMap, HashSet, VecDeque};
33
34use serde::{Deserialize, Serialize};
35use serde_json::json;
36use sqlx::{PgConnection, PgPool};
37use uuid::Uuid;
38
39use crate::{
40 auth::Actor,
41 bomtable::{self, BomRow, BomTable, Column},
42 domain::{self, AttributeType, Classification, ItemType, Role},
43 error::{AppError, AppResult},
44 services::{
45 access, attributes, audit, bom,
46 files::{self, Upload},
47 items, retention, revisions,
48 },
49 stepbom,
50 vault::{StoredBlob, Vault},
51};
52
53pub const MAX_TABLE_BYTES: usize = 20 * 1024 * 1024;
55pub const MAX_STEP_BYTES: usize = 200 * 1024 * 1024;
57
58pub fn max_bytes(filename: &str) -> usize {
60 if stepbom::is_step(filename) {
61 MAX_STEP_BYTES
62 } else {
63 MAX_TABLE_BYTES
64 }
65}
66
67pub fn too_large(filename: &str) -> AppError {
69 AppError::PayloadTooLarge(format!(
70 "{} can be at most {} MiB.",
71 if stepbom::is_step(filename) {
72 "STEP files"
73 } else {
74 "Tables (BOM, result values)"
75 },
76 max_bytes(filename) / 1024 / 1024
77 ))
78}
79
80pub const STAGED_HOURS: i32 = 24;
82
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum NumberSource {
87 #[default]
89 File,
90 Generate,
92}
93
94impl NumberSource {
95 pub fn parse(raw: &str) -> AppResult<NumberSource> {
96 match raw.trim() {
97 "" | "file" => Ok(NumberSource::File),
98 "generate" => Ok(NumberSource::Generate),
99 other => Err(AppError::BadRequest(format!(
100 "Unknown number source '{other}': use 'file' or 'generate'."
101 ))),
102 }
103 }
104
105 pub fn as_str(self) -> &'static str {
106 match self {
107 NumberSource::File => "file",
108 NumberSource::Generate => "generate",
109 }
110 }
111}
112
113fn yes() -> bool {
114 true
115}
116
117#[derive(Clone, Debug, Serialize, Deserialize)]
118pub struct ImportOptions {
119 #[serde(default)]
122 pub target: Option<Uuid>,
123 #[serde(default)]
125 pub project_id: Option<Uuid>,
126 #[serde(default)]
129 pub name: String,
130 #[serde(default)]
131 pub numbers: NumberSource,
132 #[serde(default = "yes")]
133 pub match_existing: bool,
134}
135
136impl Default for ImportOptions {
137 fn default() -> Self {
138 ImportOptions {
139 target: None,
140 project_id: None,
141 name: String::new(),
142 numbers: NumberSource::File,
143 match_existing: true,
144 }
145 }
146}
147
148#[derive(Clone, Debug, Serialize)]
150pub struct PlannedItem {
151 pub file_number: String,
153 pub number: Option<String>,
156 pub existing: Option<Uuid>,
158 pub existing_label: Option<String>,
160 pub item_type: ItemType,
161 pub name: String,
162 pub description: String,
163 pub material: String,
164 pub mass_kg: Option<f64>,
165 pub unit: String,
166 pub row: usize,
168 pub classification: Classification,
170 pub attributes: Vec<PlannedAttribute>,
172}
173
174#[derive(Clone, Debug, Serialize)]
176pub struct PlannedAttribute {
177 pub key: String,
178 pub label: String,
179 pub value: String,
181}
182
183#[derive(Clone, Debug, Serialize)]
185pub struct AttributeColumn {
186 pub header: String,
187 pub key: String,
188 pub label: String,
189}
190
191#[derive(Clone, Debug, Serialize)]
193pub struct PlannedLine {
194 pub row: usize,
196 pub level: usize,
198 pub parent: Option<usize>,
200 pub child: usize,
201 pub quantity: f64,
202 pub find_number: i32,
203}
204
205#[derive(Clone, Debug, Serialize)]
207pub struct Plan {
208 pub filename: String,
209 pub target: Option<Uuid>,
211 pub root_label: String,
213 pub root_number: Option<String>,
215 pub root_name: String,
216 pub project_id: Uuid,
217 pub project_code: String,
218 pub items: Vec<PlannedItem>,
219 pub lines: Vec<PlannedLine>,
221 pub columns: Vec<Column>,
222 pub attribute_columns: Vec<AttributeColumn>,
224 pub unused_columns: Vec<String>,
226 pub warnings: Vec<String>,
227 pub new_items: usize,
228 pub existing_items: usize,
229}
230
231#[derive(Clone, Debug, Serialize)]
232pub struct CreatedItem {
233 pub file_number: String,
234 pub number: String,
235 pub name: String,
236}
237
238#[derive(Clone, Debug, Serialize)]
240pub struct ImportResult {
241 pub root_revision_id: Uuid,
242 pub root: String,
244 pub created: Vec<CreatedItem>,
245 pub existing_items: usize,
246 pub lines: usize,
247 pub warnings: Vec<String>,
248}
249
250pub fn read_table(filename: &str, bytes: &[u8]) -> AppResult<BomTable> {
253 let step = stepbom::is_step(filename);
254 if !step && !bomtable::supported(filename) {
255 return Err(AppError::BadRequest(format!(
256 "{filename}: BOMs are read from CSV, TSV, TXT, XLSX, XLSM, XLSB, XLS and ODS tables \
257 and from STEP assemblies (STEP, STP, P21)."
258 )));
259 }
260 if bytes.len() > max_bytes(filename) {
261 return Err(too_large(filename));
262 }
263 if step {
264 stepbom::read(bytes).map_err(AppError::BadRequest)
265 } else {
266 bomtable::read(filename, bytes).map_err(AppError::BadRequest)
267 }
268}
269
270pub async fn step_file(vault: &Vault, filename: &str, bytes: &[u8]) -> AppResult<Option<Upload>> {
273 if !stepbom::is_step(filename) {
274 return Ok(None);
275 }
276 let blob = vault.store_bytes(bytes).await?;
277 Ok(Some(
278 Upload::classify(vault, blob, filename.to_string(), "").await,
279 ))
280}
281
282fn item_key(row: &BomRow) -> String {
284 let number = row.number.trim();
285 if number.is_empty() {
286 format!("name:{}", row.name.trim().to_lowercase())
287 } else {
288 format!("number:{}", number.to_uppercase())
289 }
290}
291
292fn shown(row: &BomRow) -> &str {
293 if row.number.is_empty() {
294 &row.name
295 } else {
296 &row.number
297 }
298}
299
300fn file_stem(filename: &str) -> &str {
301 filename
302 .rsplit_once('.')
303 .map_or(filename, |(stem, _)| stem)
304 .trim()
305}
306
307struct Existing {
308 id: Uuid,
309 item_type: ItemType,
310 latest: String,
311}
312
313fn reaches(children: &HashMap<usize, Vec<usize>>, from: usize, to: usize) -> bool {
315 let mut stack = vec![from];
316 let mut seen = HashSet::new();
317 while let Some(n) = stack.pop() {
318 if n == to {
319 return true;
320 }
321 if seen.insert(n) {
322 if let Some(c) = children.get(&n) {
323 stack.extend(c);
324 }
325 }
326 }
327 false
328}
329
330pub async fn plan(
333 conn: &mut PgConnection,
334 actor: &Actor,
335 table: &BomTable,
336 filename: &str,
337 options: &ImportOptions,
338) -> AppResult<Plan> {
339 actor.require(Role::Editor)?;
340 let mut warnings = table.warnings.clone();
341
342 let target = match options.target {
343 Some(id) => {
344 let rev = revisions::lock(conn, id).await?;
345 revisions::ensure_editable(&rev, actor)?;
346 Some(rev)
347 }
348 None => None,
349 };
350 let project_id = options
351 .project_id
352 .or_else(|| target.as_ref().and_then(|t| t.project_id))
353 .ok_or_else(|| {
354 AppError::BadRequest("Choose the project group the new items belong to.".into())
355 })?;
356 let (project_code, active) =
357 sqlx::query_as::<_, (String, bool)>("SELECT code, active FROM projects WHERE id = $1")
358 .bind(project_id)
359 .fetch_optional(&mut *conn)
360 .await?
361 .ok_or_else(access::unknown_project)?;
362 access::ensure_visible(actor, Some(project_id), access::unknown_project())?;
363 if !active {
364 return Err(AppError::BadRequest(format!(
365 "The project group {project_code} is archived."
366 )));
367 }
368 if !actor.can_edit_project(Some(project_id)) {
369 return Err(AppError::Forbidden(format!(
370 "You are not a member of the project group {project_code}; ask an admin to add you."
371 )));
372 }
373
374 let defs = attributes::list_conn(conn, true).await?;
377 let squash = |s: &str| -> String {
378 s.chars()
379 .filter(|c| c.is_alphanumeric())
380 .flat_map(char::to_lowercase)
381 .collect()
382 };
383 let mut attribute_columns = Vec::new();
384 let mut attribute_sources: Vec<(usize, &crate::models::AttributeDef)> = Vec::new();
386 let mut unused_columns = Vec::new();
387 for (i, header) in table.extra_columns.iter().enumerate() {
388 let h = squash(header);
389 let def = defs
390 .iter()
391 .find(|d| squash(&d.key) == h || squash(&d.label) == h)
392 .filter(|d| !attribute_sources.iter().any(|(_, s)| s.key == d.key));
393 match def {
394 Some(d) => {
395 attribute_sources.push((i, d));
396 attribute_columns.push(AttributeColumn {
397 header: header.clone(),
398 key: d.key.clone(),
399 label: d.label.clone(),
400 });
401 }
402 None => unused_columns.push(header.clone()),
403 }
404 }
405
406 let numbers: Vec<String> = table
408 .root
409 .iter()
410 .chain(&table.rows)
411 .filter_map(|r| domain::normalize_item_number(&r.number).ok())
412 .collect::<HashSet<_>>()
413 .into_iter()
414 .collect();
415 let found = sqlx::query_as::<_, (Uuid, String, String, String, Option<Uuid>)>(
416 "SELECT i.id, i.number, i.item_type,
417 COALESCE((SELECT r.code FROM revisions r WHERE r.item_id = i.id
418 ORDER BY r.seq DESC LIMIT 1), '?'),
419 i.project_id
420 FROM items i WHERE i.number = ANY($1)",
421 )
422 .bind(&numbers)
423 .fetch_all(&mut *conn)
424 .await?;
425 let unavailable: HashSet<String> = found
428 .iter()
429 .filter(|f| !actor.can_see_project(f.4))
430 .map(|f| f.1.clone())
431 .collect();
432 let existing: HashMap<String, Existing> = found
433 .into_iter()
434 .filter(|f| actor.can_see_project(f.4))
435 .map(|(id, number, item_type, code, _)| {
436 let latest = format!("{number}/{code}");
437 let item_type = ItemType::parse(&item_type).unwrap_or(ItemType::Part);
438 (
439 number,
440 Existing {
441 id,
442 item_type,
443 latest,
444 },
445 )
446 })
447 .collect();
448
449 let (root_label, root_number, root_name, root_item_number) = match &target {
451 Some(rev) => (
452 revisions::label(rev),
453 None,
454 rev.name.clone(),
455 Some(rev.item_number.clone()),
456 ),
457 None => {
458 let row = table.root.as_ref();
459 let name = [
460 options.name.trim(),
461 row.map_or("", |r| r.name.as_str()),
462 file_stem(filename),
463 ]
464 .into_iter()
465 .find(|n| !n.is_empty())
466 .unwrap_or("Imported assembly");
467 let name = domain::truncate_chars(name, 200);
468 let number = match row.and_then(|r| domain::normalize_item_number(&r.number).ok()) {
469 Some(n) if options.numbers == NumberSource::File && unavailable.contains(&n) => {
470 warnings.push(format!(
471 "{n} is not available: the new assembly gets a generated number."
472 ));
473 None
474 }
475 Some(n) if options.numbers == NumberSource::File && !existing.contains_key(&n) => {
476 Some(n)
477 }
478 Some(n) if options.numbers == NumberSource::File => {
479 warnings.push(format!(
480 "{n} exists already: the new assembly gets a generated number. To fill \
481 the existing assembly, check it out and import into it."
482 ));
483 None
484 }
485 _ => None,
486 };
487 (name.clone(), number.clone(), name, number)
488 }
489 };
490
491 let mut used_find: HashMap<Option<usize>, HashSet<i32>> = HashMap::new();
493 let mut target_children = HashSet::new();
494 if let Some(rev) = &target {
495 for (find, child) in sqlx::query_as::<_, (i32, Uuid)>(
496 "SELECT find_number, child_item_id FROM bom_lines WHERE parent_revision_id = $1",
497 )
498 .bind(rev.id)
499 .fetch_all(&mut *conn)
500 .await?
501 {
502 used_find.entry(None).or_default().insert(find);
503 target_children.insert(child);
504 }
505 }
506
507 let mut items: Vec<PlannedItem> = Vec::new();
508 let mut by_key: HashMap<String, usize> = HashMap::new();
509 let mut expanded: HashSet<usize> = HashSet::new();
510 let mut children: HashMap<usize, Vec<usize>> = HashMap::new();
511 let mut lines: Vec<PlannedLine> = Vec::new();
512 let mut requested: Vec<Option<i32>> = Vec::new();
513 let mut line_of: HashMap<(Option<usize>, usize), usize> = HashMap::new();
514 let mut stack: Vec<(usize, Option<usize>)> = vec![(0, None)];
516 let mut skip_below: Option<usize> = None;
517 let rows = &table.rows;
518 let mut bom_rows: HashMap<String, usize> = HashMap::new();
522 for (i, row) in rows.iter().enumerate() {
523 if rows.get(i + 1).is_some_and(|n| n.level > row.level) {
524 bom_rows.entry(item_key(row)).or_insert(i);
525 }
526 }
527 let mut queue: VecDeque<BomRow> = rows.iter().cloned().collect();
528 let mut copied = 0;
529 while let Some(row) = queue.pop_front() {
530 let row = &row;
531 if let Some(l) = skip_below {
532 if row.level > l {
533 continue;
534 }
535 skip_below = None;
536 }
537 while stack.len() > 1 && stack.last().is_some_and(|(l, _)| *l >= row.level) {
538 stack.pop();
539 }
540 let parent = stack.last().and_then(|(_, p)| *p);
541 let mut has_children = queue.front().is_some_and(|n| n.level > row.level);
542 let skip = |reason: String, warnings: &mut Vec<String>, skip_below: &mut Option<usize>| {
543 warnings.push(format!("Row {}: {reason}", row.line));
544 if has_children {
545 *skip_below = Some(row.level);
546 }
547 };
548 let normalized = domain::normalize_item_number(&row.number).ok();
549 if normalized.is_some() && normalized == root_item_number {
550 skip(
551 format!("{} is the assembly itself and is skipped.", row.number),
552 &mut warnings,
553 &mut skip_below,
554 );
555 continue;
556 }
557 let matched = normalized
558 .as_ref()
559 .filter(|_| options.match_existing)
560 .and_then(|n| existing.get(n));
561 if parent.is_none() && matched.is_some_and(|e| target_children.contains(&e.id)) {
562 skip(
563 format!(
564 "{} is already in the BOM of {root_label} – not added again.",
565 shown(row)
566 ),
567 &mut warnings,
568 &mut skip_below,
569 );
570 continue;
571 }
572 if let (Some(t), Some(e)) = (&target, matched) {
573 if parent.is_none() && bom::creates_cycle(conn, t.item_id, e.id).await? {
574 skip(
575 format!(
576 "{} contains {root_label} – adding it would create a circular BOM.",
577 e.latest
578 ),
579 &mut warnings,
580 &mut skip_below,
581 );
582 continue;
583 }
584 }
585 let key = item_key(row);
586 let known = by_key.get(&key).copied();
587 if let Some(c) = known {
588 let circular = match parent {
589 Some(p) => p == c || reaches(&children, c, p),
590 None => false,
591 };
592 if circular {
593 skip(
594 format!("{} would contain itself – the row is skipped.", shown(row)),
595 &mut warnings,
596 &mut skip_below,
597 );
598 continue;
599 }
600 }
601 let child = match known {
602 Some(c) => c,
603 None => {
604 let taken = normalized.as_ref().and_then(|n| {
606 if unavailable.contains(n) {
607 Some("is not available")
608 } else if existing.contains_key(n) {
609 Some("exists already")
610 } else {
611 None
612 }
613 });
614 let mut item =
615 planned_item(row, normalized, matched, taken, options, &mut warnings);
616 if item.existing.is_none() {
617 item.attributes = attribute_sources
618 .iter()
619 .filter_map(|(i, d)| {
620 let value = row.extra.get(*i)?.trim();
621 (!value.is_empty()).then(|| PlannedAttribute {
622 key: d.key.clone(),
623 label: d.label.clone(),
624 value: value.to_string(),
625 })
626 })
627 .collect();
628 }
629 items.push(item);
630 by_key.insert(key, items.len() - 1);
631 items.len() - 1
632 }
633 };
634 match line_of.get(&(parent, child)) {
635 Some(&l) => {
636 lines[l].quantity += row.quantity;
637 warnings.push(format!(
638 "Row {}: {} appears again below the same assembly – the quantities are added.",
639 row.line,
640 shown(row)
641 ));
642 }
643 None => {
644 line_of.insert((parent, child), lines.len());
645 if let Some(p) = parent {
646 children.entry(p).or_default().push(child);
647 }
648 lines.push(PlannedLine {
649 row: row.line,
650 level: row.level,
651 parent,
652 child,
653 quantity: row.quantity,
654 find_number: 0,
655 });
656 requested.push(row.find_number);
657 }
658 }
659 if !has_children && items[child].existing.is_none() && !expanded.contains(&child) {
662 if let Some(&j) = bom_rows.get(&item_key(row)) {
663 let below: Vec<BomRow> = rows[j + 1..]
664 .iter()
665 .take_while(|r| r.level > rows[j].level)
666 .map(|r| BomRow {
667 level: r.level - rows[j].level + row.level,
668 ..r.clone()
669 })
670 .collect();
671 copied += below.len();
672 if copied > bomtable::MAX_ROWS {
673 return Err(AppError::BadRequest(format!(
674 "The BOM has more than {} rows.",
675 bomtable::MAX_ROWS
676 )));
677 }
678 for r in below.into_iter().rev() {
679 queue.push_front(r);
680 }
681 has_children = true;
682 }
683 }
684 if has_children {
685 if items[child].existing.is_some() {
686 warnings.push(format!(
687 "Row {}: {} exists already – its BOM is not changed by the import, the rows \
688 below it are skipped.",
689 row.line,
690 shown(row)
691 ));
692 skip_below = Some(row.level);
693 } else if !expanded.insert(child) {
694 warnings.push(format!(
695 "Row {}: the BOM of {} was read from an earlier row – the rows below it are \
696 skipped.",
697 row.line,
698 shown(row)
699 ));
700 skip_below = Some(row.level);
701 } else {
702 items[child].item_type = ItemType::Assembly;
703 stack.push((row.level, Some(child)));
704 }
705 }
706 }
707 if lines.is_empty() {
708 return Err(AppError::BadRequest(
709 "Nothing to import: every row of the table was skipped.".into(),
710 ));
711 }
712
713 for it in items.iter_mut().filter(|i| i.existing.is_none()) {
717 let item_type = it.item_type.as_str();
718 let row = it.row;
719 it.attributes.retain(|a| {
720 let Some(d) = defs.iter().find(|d| d.key == a.key) else {
721 return false;
722 };
723 if !attributes::applies_to(d, item_type) {
724 warnings.push(format!(
725 "Row {row}: {} does not apply to items of type {item_type} – '{}' is not imported.",
726 d.label, a.value
727 ));
728 return false;
729 }
730 let data_type = AttributeType::parse(&d.data_type).unwrap_or(AttributeType::Text);
731 match domain::parse_attribute_value(&d.label, data_type, &d.choices, &a.value) {
732 Ok(_) => true,
733 Err(e) => {
734 warnings.push(format!(
735 "Row {row}: {} The value is not imported.",
736 e.public_message()
737 ));
738 false
739 }
740 }
741 });
742 }
743
744 for (i, line) in lines.iter_mut().enumerate() {
746 if let Some(f) = requested[i] {
747 if used_find.entry(line.parent).or_default().insert(f) {
748 line.find_number = f;
749 } else {
750 warnings.push(format!(
751 "Row {}: find number {f} is already used – the next free one is taken.",
752 line.row
753 ));
754 }
755 }
756 }
757 for line in lines.iter_mut().filter(|l| l.find_number == 0) {
758 let used = used_find.entry(line.parent).or_default();
759 let next = (used.iter().max().copied().unwrap_or(0) / 10 + 1) * 10;
760 used.insert(next);
761 line.find_number = next;
762 }
763
764 let existing_items = items.iter().filter(|i| i.existing.is_some()).count();
765 Ok(Plan {
766 filename: filename.to_string(),
767 target: target.as_ref().map(|t| t.id),
768 root_label,
769 root_number,
770 root_name,
771 project_id,
772 project_code,
773 new_items: items.len() - existing_items,
774 existing_items,
775 items,
776 lines,
777 columns: table.columns.clone(),
778 attribute_columns,
779 unused_columns,
780 warnings,
781 })
782}
783
784fn planned_item(
785 row: &BomRow,
786 normalized: Option<String>,
787 matched: Option<&Existing>,
788 taken: Option<&'static str>,
789 options: &ImportOptions,
790 warnings: &mut Vec<String>,
791) -> PlannedItem {
792 let mut item = PlannedItem {
793 file_number: row.number.clone(),
794 number: None,
795 existing: None,
796 existing_label: None,
797 item_type: ItemType::Part,
798 name: domain::truncate_chars(&row.name, 200),
799 description: domain::truncate_chars(&row.description, 9_000),
800 material: domain::truncate_chars(&row.material, 200),
801 mass_kg: row.mass_kg,
802 unit: if row.unit.is_empty() {
803 "each".into()
804 } else {
805 row.unit.clone()
806 },
807 row: row.line,
808 classification: Classification::default(),
809 attributes: Vec::new(),
810 };
811 if !row.structure.is_empty() {
812 match Classification::from_bom_structure(&row.structure) {
813 Some(c) => item.classification = c,
814 None => warnings.push(format!(
815 "Row {}: BOM structure '{}' is not understood – the item is classified as in-house.",
816 row.line, row.structure
817 )),
818 }
819 }
820 match (normalized, matched) {
821 (Some(n), Some(e)) => {
822 item.existing = Some(e.id);
823 item.existing_label = Some(e.latest.clone());
824 item.item_type = e.item_type;
825 item.number = Some(n);
826 }
827 (Some(n), None) if options.numbers == NumberSource::File => {
828 if let Some(why) = taken {
829 warnings.push(format!(
830 "Row {}: {n} {why} – the new item gets a generated number.",
831 row.line
832 ));
833 } else {
834 item.number = Some(n);
835 }
836 }
837 (None, _) if !row.number.is_empty() && options.numbers == NumberSource::File => {
838 warnings.push(format!(
839 "Row {}: '{}' is not a valid item number (A–Z, 0–9, '.', '_', '-') – a number is \
840 generated.",
841 row.line, row.number
842 ));
843 }
844 _ => {}
845 }
846 if item.existing.is_none() && item.number.is_none() && !row.number.is_empty() {
847 let note = format!("Part number in CAD: {}", row.number);
848 item.description = if item.description.is_empty() {
849 note
850 } else {
851 format!("{}\n{note}", item.description)
852 };
853 }
854 item
855}
856
857pub async fn run(
877 pool: &PgPool,
878 actor: &Actor,
879 table: &BomTable,
880 filename: &str,
881 options: &ImportOptions,
882 source: Option<&Upload>,
883) -> AppResult<ImportResult> {
884 let mut tx = pool.begin().await?;
885 let result = run_tx(&mut tx, actor, table, filename, options, source).await?;
886 tx.commit().await?;
887 Ok(result)
888}
889
890async fn run_tx(
891 conn: &mut PgConnection,
892 actor: &Actor,
893 table: &BomTable,
894 filename: &str,
895 options: &ImportOptions,
896 source: Option<&Upload>,
897) -> AppResult<ImportResult> {
898 let plan = plan(conn, actor, table, filename, options).await?;
899 let notes = domain::truncate_chars(&format!("Imported from {}", plan.filename), 500);
900
901 let mut ids: Vec<Option<(Uuid, String, Option<Uuid>)>> = vec![None; plan.items.len()];
905 let mut created = Vec::new();
906 for (i, it) in plan.items.iter().enumerate() {
907 match (it.existing, &it.number) {
908 (Some(id), Some(n)) => ids[i] = Some((id, n.clone(), None)),
909 (None, Some(_)) => {
910 ids[i] = Some(create_item(conn, actor, &plan, it, &mut created).await?)
911 }
912 _ => {}
913 }
914 }
915 let root = match plan.target {
916 Some(id) => revisions::lock(conn, id).await?,
917 None => {
918 let c = items::create_tx(
919 conn,
920 actor,
921 items::NewItem {
922 number: plan.root_number.clone(),
923 item_type: ItemType::Assembly,
924 unit: "each".into(),
925 project_id: Some(plan.project_id),
926 name: plan.root_name.clone(),
927 description: format!("Imported from {}", plan.filename),
928 material: String::new(),
929 mass_kg: None,
930 unit_cost: None,
931 classification: Default::default(),
932 attributes: Vec::new(),
933 },
934 )
935 .await?;
936 let rev = revisions::lock(conn, c.revision_id).await?;
937 if let Some(upload) = source {
938 files::store_uploads(conn, actor, &rev, std::slice::from_ref(upload), ¬es)
939 .await?;
940 }
941 rev
942 }
943 };
944 for (i, it) in plan.items.iter().enumerate() {
945 if ids[i].is_none() {
946 ids[i] = Some(create_item(conn, actor, &plan, it, &mut created).await?);
947 }
948 }
949
950 let mut parents: HashMap<usize, crate::models::Revision> = HashMap::new();
951 for line in &plan.lines {
952 let Some((child_id, child_number, _)) = ids[line.child].clone() else {
953 return Err(AppError::Other(anyhow::anyhow!("import: item not created")));
954 };
955 let parent: &crate::models::Revision = match line.parent {
956 None => &root,
957 Some(p) => match parents.entry(p) {
958 Entry::Occupied(e) => e.into_mut(),
959 Entry::Vacant(v) => {
960 let rev_id = ids[p].as_ref().and_then(|(_, _, r)| *r).ok_or_else(|| {
961 AppError::Other(anyhow::anyhow!("import: parent without new revision"))
962 })?;
963 v.insert(revisions::lock(conn, rev_id).await?)
964 }
965 },
966 };
967 bom::add_tx(
968 conn,
969 actor,
970 parent,
971 child_id,
972 &child_number,
973 line.quantity,
974 Some(line.find_number),
975 ¬es,
976 )
977 .await?;
978 }
979
980 let label = revisions::label(&root);
981 audit::record(
982 conn,
983 Some(actor),
984 "import.bom",
985 "revision",
986 Some(root.id),
987 format!(
988 "Imported {} BOM lines from {} into {label} ({} new items)",
989 plan.lines.len(),
990 plan.filename,
991 created.len()
992 ),
993 json!({
994 "item_id": root.item_id,
995 "filename": plan.filename,
996 "created": created.len(),
997 "existing": plan.existing_items,
998 "lines": plan.lines.len(),
999 "warnings": plan.warnings.len(),
1000 }),
1001 )
1002 .await?;
1003 Ok(ImportResult {
1004 root_revision_id: root.id,
1005 root: label,
1006 created,
1007 existing_items: plan.existing_items,
1008 lines: plan.lines.len(),
1009 warnings: plan.warnings,
1010 })
1011}
1012
1013async fn create_item(
1014 conn: &mut PgConnection,
1015 actor: &Actor,
1016 plan: &Plan,
1017 it: &PlannedItem,
1018 created: &mut Vec<CreatedItem>,
1019) -> AppResult<(Uuid, String, Option<Uuid>)> {
1020 let c = items::create_tx(
1021 conn,
1022 actor,
1023 items::NewItem {
1024 number: it.number.clone(),
1025 item_type: it.item_type,
1026 unit: it.unit.clone(),
1027 project_id: Some(plan.project_id),
1028 name: it.name.clone(),
1029 description: it.description.clone(),
1030 material: it.material.clone(),
1031 mass_kg: it.mass_kg,
1032 unit_cost: None,
1033 classification: it.classification,
1034 attributes: it
1035 .attributes
1036 .iter()
1037 .map(|a| (a.key.clone(), a.value.clone()))
1038 .collect(),
1039 },
1040 )
1041 .await?;
1042 created.push(CreatedItem {
1043 file_number: it.file_number.clone(),
1044 number: c.number.clone(),
1045 name: it.name.clone(),
1046 });
1047 Ok((c.item_id, c.number, Some(c.revision_id)))
1048}
1049
1050#[derive(Clone, Debug)]
1056pub struct Staged {
1057 pub id: Uuid,
1058 pub filename: String,
1059 pub options: ImportOptions,
1060 content: Vec<u8>,
1061 sha256: Option<String>,
1063}
1064
1065impl Staged {
1066 pub fn table(&self) -> AppResult<BomTable> {
1067 read_table(&self.filename, &self.content)
1068 }
1069
1070 async fn source(&self, vault: &Vault) -> Option<Upload> {
1072 let blob = StoredBlob {
1073 sha256: self.sha256.clone()?,
1074 size: self.content.len() as u64,
1075 };
1076 Some(Upload::classify(vault, blob, self.filename.clone(), "").await)
1077 }
1078}
1079
1080pub async fn stage(
1083 pool: &PgPool,
1084 vault: &Vault,
1085 actor: &Actor,
1086 filename: &str,
1087 content: Vec<u8>,
1088 options: ImportOptions,
1089) -> AppResult<Uuid> {
1090 actor.require(Role::Editor)?;
1091 read_table(filename, &content)?;
1092 let (kind, row_content, sha256) = if stepbom::is_step(filename) {
1093 let blob = vault.store_bytes(&content).await?;
1094 ("step", None, Some(blob.sha256))
1095 } else {
1096 ("bom_table", Some(content.as_slice()), None)
1097 };
1098 let id = Uuid::new_v4();
1099 sqlx::query(
1100 "INSERT INTO staged_imports (id, user_id, kind, filename, size_bytes, content, sha256, options)
1101 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
1102 )
1103 .bind(id)
1104 .bind(actor.id)
1105 .bind(kind)
1106 .bind(domain::truncate_chars(filename, 255))
1107 .bind(content.len() as i64)
1108 .bind(row_content)
1109 .bind(sha256)
1110 .bind(sqlx::types::Json(&options))
1111 .execute(pool)
1112 .await?;
1113 Ok(id)
1114}
1115
1116const GONE: &str = "This import is no longer available – upload the file again.";
1117
1118type StagedRow = (
1119 Uuid,
1120 String,
1121 sqlx::types::Json<ImportOptions>,
1122 Option<Vec<u8>>,
1123 Option<String>,
1124);
1125
1126async fn staged_of(vault: &Vault, row: StagedRow) -> AppResult<Staged> {
1127 let (id, filename, options, content, sha256) = row;
1128 let content = match (content, &sha256) {
1129 (Some(content), _) => content,
1130 (None, Some(sha)) => tokio::fs::read(vault.object_path(sha)?)
1131 .await
1132 .map_err(|_| AppError::not_found(GONE))?,
1133 (None, None) => return Err(AppError::not_found(GONE)),
1134 };
1135 Ok(Staged {
1136 id,
1137 filename,
1138 options: options.0,
1139 content,
1140 sha256,
1141 })
1142}
1143
1144pub async fn staged(pool: &PgPool, vault: &Vault, actor: &Actor, id: Uuid) -> AppResult<Staged> {
1146 let row = sqlx::query_as::<_, StagedRow>(
1147 "SELECT id, filename, options, content, sha256 FROM staged_imports
1148 WHERE id = $1 AND user_id = $2 AND created_at >= now() - make_interval(hours => $3)",
1149 )
1150 .bind(id)
1151 .bind(actor.id)
1152 .bind(STAGED_HOURS)
1153 .fetch_optional(pool)
1154 .await?
1155 .ok_or_else(|| AppError::not_found(GONE))?;
1156 staged_of(vault, row).await
1157}
1158
1159pub async fn preview(
1161 pool: &PgPool,
1162 vault: &Vault,
1163 actor: &Actor,
1164 id: Uuid,
1165) -> AppResult<(Staged, Plan)> {
1166 let staged = staged(pool, vault, actor, id).await?;
1167 let table = staged.table()?;
1168 let mut conn = pool.acquire().await?;
1169 let plan = plan(&mut conn, actor, &table, &staged.filename, &staged.options).await?;
1170 Ok((staged, plan))
1171}
1172
1173pub async fn run_staged(
1184 pool: &PgPool,
1185 vault: &Vault,
1186 actor: &Actor,
1187 id: Uuid,
1188) -> AppResult<ImportResult> {
1189 let mut tx = pool.begin().await?;
1190 let row = sqlx::query_as::<_, StagedRow>(
1191 "SELECT id, filename, options, content, sha256 FROM staged_imports
1192 WHERE id = $1 AND user_id = $2 AND created_at >= now() - make_interval(hours => $3)
1193 FOR UPDATE",
1194 )
1195 .bind(id)
1196 .bind(actor.id)
1197 .bind(STAGED_HOURS)
1198 .fetch_optional(&mut *tx)
1199 .await?
1200 .ok_or_else(|| AppError::not_found(GONE))?;
1201 let staged = staged_of(vault, row).await?;
1202 let table = staged.table()?;
1203 let source = staged.source(vault).await;
1204 let result = run_tx(
1205 &mut tx,
1206 actor,
1207 &table,
1208 &staged.filename,
1209 &staged.options,
1210 source.as_ref(),
1211 )
1212 .await?;
1213 sqlx::query("DELETE FROM staged_imports WHERE id = $1")
1214 .bind(id)
1215 .execute(&mut *tx)
1216 .await?;
1217 tx.commit().await?;
1218 Ok(result)
1219}
1220
1221pub async fn discard(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ImportOptions> {
1226 let options = sqlx::query_scalar::<_, sqlx::types::Json<ImportOptions>>(
1227 "WITH tables AS (
1228 DELETE FROM staged_imports WHERE id = $1 AND user_id = $2 AND sha256 IS NULL
1229 RETURNING options
1230 ), steps AS (
1231 UPDATE staged_imports
1232 SET created_at = now() - make_interval(hours => $3) - interval '1 second'
1233 WHERE id = $1 AND user_id = $2 AND sha256 IS NOT NULL
1234 RETURNING options
1235 )
1236 SELECT options FROM tables UNION ALL SELECT options FROM steps",
1237 )
1238 .bind(id)
1239 .bind(actor.id)
1240 .bind(STAGED_HOURS)
1241 .fetch_optional(pool)
1242 .await?
1243 .map(|o| o.0)
1244 .unwrap_or_default();
1245 Ok(options)
1246}
1247
1248pub async fn purge_stale(pool: &PgPool, vault: &Vault) -> AppResult<u64> {
1255 let mut removed = sqlx::query(
1256 "DELETE FROM staged_imports
1257 WHERE sha256 IS NULL AND created_at < now() - make_interval(hours => $1)",
1258 )
1259 .bind(STAGED_HOURS)
1260 .execute(pool)
1261 .await?
1262 .rows_affected();
1263 let steps: Vec<Uuid> = sqlx::query_scalar(
1264 "SELECT id FROM staged_imports
1265 WHERE sha256 IS NOT NULL AND created_at < now() - make_interval(hours => $1)",
1266 )
1267 .bind(STAGED_HOURS)
1268 .fetch_all(pool)
1269 .await?;
1270 for id in steps {
1271 let mut tx = pool.begin().await?;
1272 let Some(sha256) = sqlx::query_scalar::<_, String>(
1274 "DELETE FROM staged_imports WHERE id = $1 RETURNING sha256",
1275 )
1276 .bind(id)
1277 .fetch_optional(&mut *tx)
1278 .await?
1279 else {
1280 continue;
1281 };
1282 match retention::release_blob(&mut tx, vault, &sha256, retention::IN_FLIGHT_GRACE).await? {
1283 retention::Release::Recent => tx.rollback().await?,
1284 retention::Release::Removed
1285 | retention::Release::Referenced
1286 | retention::Release::Missing => {
1287 tx.commit().await?;
1288 removed += 1;
1289 }
1290 }
1291 }
1292 Ok(removed)
1293}
1294
1295#[cfg(test)]
1296mod tests {
1297 use super::*;
1298
1299 #[test]
1300 fn options_and_keys() {
1301 assert_eq!(NumberSource::parse("").unwrap(), NumberSource::File);
1302 assert_eq!(
1303 NumberSource::parse("generate").unwrap().as_str(),
1304 "generate"
1305 );
1306 assert!(NumberSource::parse("random").is_err());
1307 let o: ImportOptions = serde_json::from_str("{}").unwrap();
1308 assert!(o.match_existing && o.target.is_none() && o.numbers == NumberSource::File);
1309 let t = read_table("a.csv", b"Part Number,Name,QTY\nab-1,One,1\n,Screw M6,2\n").unwrap();
1310 assert_eq!(item_key(&t.rows[0]), "number:AB-1");
1311 assert_eq!(item_key(&t.rows[1]), "name:screw m6");
1312 assert_eq!(file_stem("Pump BOM.xlsx"), "Pump BOM");
1313 assert!(read_table("a.pdf", b"x").is_err());
1314 }
1315
1316 #[test]
1317 fn size_limits_per_file_kind() {
1318 assert_eq!(max_bytes("pump.STEP"), MAX_STEP_BYTES);
1319 assert_eq!(max_bytes("pump.p21"), MAX_STEP_BYTES);
1320 assert_eq!(max_bytes("bom.xlsx"), MAX_TABLE_BYTES);
1321 assert_eq!(
1322 too_large("a.stp").to_string(),
1323 AppError::PayloadTooLarge("STEP files can be at most 200 MiB.".into()).to_string()
1324 );
1325 assert!(matches!(
1326 too_large("a.csv"),
1327 AppError::PayloadTooLarge(m) if m == "Tables (BOM, result values) can be at most 20 MiB."
1328 ));
1329 let big = vec![b'a'; MAX_TABLE_BYTES + 1];
1330 assert!(matches!(
1331 read_table("bom.csv", &big),
1332 Err(AppError::PayloadTooLarge(_))
1333 ));
1334 }
1335
1336 #[test]
1337 fn tables_and_step_assemblies_are_routed_by_name() {
1338 let err = read_table("bom.stp", b"Part Number,Name\nA,B\n").unwrap_err();
1340 assert!(
1341 matches!(&err, AppError::BadRequest(m) if m.contains("Not a STEP")),
1342 "{err:?}"
1343 );
1344 let t = read_table("bom.TSV", b"Part Number\tName\nA\tB\n").unwrap();
1346 assert_eq!(t.rows[0].number, "A");
1347 let err = read_table("bom.docx", b"").unwrap_err();
1349 assert!(matches!(&err, AppError::BadRequest(m) if m.contains("STEP assemblies")));
1350 }
1351
1352 #[test]
1353 fn keys_and_labels_of_rows() {
1354 let t = read_table(
1355 "a.csv",
1356 b"Part Number,Name\n ab-1 ,One\nAB-1,Same item\n, Screw M6 \n",
1357 )
1358 .unwrap();
1359 assert_eq!(
1360 item_key(&t.rows[0]),
1361 item_key(&t.rows[1]),
1362 "case-insensitive"
1363 );
1364 assert_eq!(item_key(&t.rows[2]), "name:screw m6");
1365 assert_eq!(shown(&t.rows[1]), "AB-1");
1366 assert_eq!(shown(&t.rows[2]), "Screw M6", "the name without a number");
1367 assert_eq!(file_stem(" no extension "), "no extension");
1368 assert_eq!(file_stem("a.b.csv"), "a.b");
1369 assert_eq!(NumberSource::parse(" file ").unwrap(), NumberSource::File);
1370 assert_eq!(
1371 serde_json::to_value(NumberSource::Generate).unwrap(),
1372 serde_json::json!("generate")
1373 );
1374 }
1375
1376 #[test]
1377 fn reachability_terminates_on_cycles() {
1378 let children = HashMap::from([(0, vec![1]), (1, vec![0, 2]), (2, vec![1])]);
1379 assert!(reaches(&children, 0, 2));
1380 assert!(!reaches(&children, 0, 5));
1381 assert!(reaches(&children, 4, 4), "a node reaches itself");
1382 }
1383
1384 #[test]
1385 fn reachability() {
1386 let children = HashMap::from([(0, vec![1]), (1, vec![2]), (3, vec![0])]);
1387 assert!(reaches(&children, 0, 2));
1388 assert!(reaches(&children, 3, 2));
1389 assert!(!reaches(&children, 2, 0));
1390 }
1391}