Skip to main content

teamcraft/services/
imports.rs

1//! Import of bills of materials exported by CAD programs (see
2//! [`crate::bomtable`]) and of the product structure of STEP assemblies (see
3//! [`crate::stepbom`]).
4//!
5//! An import creates the items that are not in TeamCraft yet and the
6//! multi-level BOM below an assembly: a new top-level assembly or a revision
7//! the user has checked out. The preview and the import use the same planning
8//! code, so the preview shows exactly what the import does; the import runs in
9//! one transaction (all or nothing) and writes the usual audit entries per
10//! item and BOM line plus one `import.bom` summary.
11//!
12//! Rules (defaults in brackets):
13//!
14//! * rows with the same part number (or, without one, the same name) are one
15//!   item; repeated rows below the same parent are merged (quantities added),
16//! * `match_existing` \[on\]: a part number that is an item number in TeamCraft
17//!   uses that item – its own BOM is never changed by an import,
18//! * `numbers` \[`file`\]: new items get the part number of the file when it is
19//!   a valid, unused item number, otherwise a generated one (the CAD number
20//!   is then kept in the description); `generate` always generates numbers,
21//! * a sub-assembly takes its children from its first occurrence; when the
22//!   rows below it are skipped there (e.g. below an existing item) or come
23//!   later, they are read at the occurrence that creates the sub-assembly,
24//! * components already in the BOM of the target revision are not added
25//!   again, so importing the same table twice does not duplicate lines.
26//!
27//! A STEP file is attached to the new top-level assembly it creates, so its 3D
28//! preview shows the whole assembly. Web uploads wait in `staged_imports`
29//! until the user confirms the preview – tables in the row, STEP files in the
30//! vault.
31
32use 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
53/// Largest BOM table accepted.
54pub const MAX_TABLE_BYTES: usize = 20 * 1024 * 1024;
55/// Largest STEP assembly accepted.
56pub const MAX_STEP_BYTES: usize = 200 * 1024 * 1024;
57
58/// Largest import file accepted with this name.
59pub 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
67/// "Tables (BOM, result values) can be at most 20 MiB." / "STEP files …"
68pub 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
80/// Staged uploads older than this are removed by the housekeeping task.
81pub const STAGED_HOURS: i32 = 24;
82
83/// Where the numbers of new items come from.
84#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum NumberSource {
87    /// The part number of the file if it is a valid, unused item number.
88    #[default]
89    File,
90    /// Always a generated number (`PRT-000042`).
91    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    /// Revision to import into (checked out by the user); `None` creates a
120    /// new top-level assembly.
121    #[serde(default)]
122    pub target: Option<Uuid>,
123    /// Project group of new items; default: the target's.
124    #[serde(default)]
125    pub project_id: Option<Uuid>,
126    /// Name of a new top-level assembly; default: the assembly row of the
127    /// table or the file name.
128    #[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/// An item the import uses or creates.
149#[derive(Clone, Debug, Serialize)]
150pub struct PlannedItem {
151    /// Part number as written in the file.
152    pub file_number: String,
153    /// Number of an existing item or the number taken from the file;
154    /// `None` = generated during the import.
155    pub number: Option<String>,
156    /// Existing item (not changed by the import).
157    pub existing: Option<Uuid>,
158    /// Latest revision of an existing item, e.g. `PRT-000001/B`.
159    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    /// Row in the file (first occurrence).
167    pub row: usize,
168    /// From the BOM structure / procurement column (new items only).
169    pub classification: Classification,
170    /// Custom attribute values from the file (new items only), validated.
171    pub attributes: Vec<PlannedAttribute>,
172}
173
174/// A custom attribute value a new item gets from the file.
175#[derive(Clone, Debug, Serialize)]
176pub struct PlannedAttribute {
177    pub key: String,
178    pub label: String,
179    /// As written in the file.
180    pub value: String,
181}
182
183/// A column of the file that fills a custom attribute.
184#[derive(Clone, Debug, Serialize)]
185pub struct AttributeColumn {
186    pub header: String,
187    pub key: String,
188    pub label: String,
189}
190
191/// A BOM line the import adds.
192#[derive(Clone, Debug, Serialize)]
193pub struct PlannedLine {
194    /// Row in the file.
195    pub row: usize,
196    /// 1 = direct child of the root.
197    pub level: usize,
198    /// Index into [`Plan::items`]; `None` = the root.
199    pub parent: Option<usize>,
200    pub child: usize,
201    pub quantity: f64,
202    pub find_number: i32,
203}
204
205/// What an import would do.
206#[derive(Clone, Debug, Serialize)]
207pub struct Plan {
208    pub filename: String,
209    /// Existing revision the BOM is imported into.
210    pub target: Option<Uuid>,
211    /// `ASM-000001/A` for a target, the name of a new assembly otherwise.
212    pub root_label: String,
213    /// Number of a new top-level assembly; `None` = generated.
214    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    /// In file order (depth first), so it can be shown as an indented tree.
220    pub lines: Vec<PlannedLine>,
221    pub columns: Vec<Column>,
222    /// Columns read into custom attributes (header = key or label).
223    pub attribute_columns: Vec<AttributeColumn>,
224    /// Columns that are not imported.
225    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/// What an import did.
239#[derive(Clone, Debug, Serialize)]
240pub struct ImportResult {
241    pub root_revision_id: Uuid,
242    /// `ASM-000007/A`.
243    pub root: String,
244    pub created: Vec<CreatedItem>,
245    pub existing_items: usize,
246    pub lines: usize,
247    pub warnings: Vec<String>,
248}
249
250/// Reads an uploaded BOM table or STEP assembly (format and size checks
251/// included).
252pub 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
270/// Stores an uploaded STEP assembly in the vault, ready to be attached to the
271/// assembly it creates; `None` for tables.
272pub 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
282/// Rows with the same part number – or, without one, the same name – are one item.
283fn 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
313/// Whether `to` can be reached from `from` along the planned lines.
314fn 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
330/// Plans an import. Used for the preview (any connection) and by [`run`]
331/// (inside its transaction, the target revision stays locked).
332pub 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    // Further columns: a header equal to the key or label of an active
375    // custom attribute fills that attribute of new items.
376    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    // (index into BomRow::extra, definition)
385    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    // Items of TeamCraft with the part numbers of the file.
407    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    // Numbers of items the actor may not see: taken, but never matched or
426    // linked, and neither type nor revision is shown.
427    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    // The root: the target revision or a new top-level assembly.
450    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    // Lines already in the target BOM.
492    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    // (level, item) of the current ancestors; the root is level 0.
515    let mut stack: Vec<(usize, Option<usize>)> = vec![(0, None)];
516    let mut skip_below: Option<usize> = None;
517    let rows = &table.rows;
518    // The rows below the first occurrence of each sub-assembly (index of its
519    // row): tables that list a BOM only once (STEP files) need them again
520    // when that occurrence is skipped, e.g. below an existing item.
521    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                // Why the number from the file cannot be used, if it cannot.
605                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        // A new sub-assembly whose BOM rows were skipped (or come later) at
660        // another occurrence: read them here.
661        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    // Attribute values are checked once the item types are final (a row
714    // with rows below it became an assembly); a value that does not fit is
715    // left out with a warning instead of stopping the import.
716    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    // Find numbers: the file's where free, then the next multiples of 10.
745    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
857/// Runs an import in one transaction. `source` (the STEP file, see
858/// [`step_file`]) is attached to a new top-level assembly.
859///
860/// # Invariants
861///
862/// * **Permission:** editor role (403). With a target revision: visible,
863///   checked out by the actor (409 otherwise) and editable
864///   (`revisions::ensure_editable`). The project group must exist and be
865///   visible (400), not archived (400) and the actor a member (403). A table
866///   without importable rows is a 400.
867/// * **Locks:** the target revision (`revisions::lock`), each new revision it
868///   fills, the project group row (`FOR SHARE`) and the number counters of
869///   the new items.
870/// * **Audit:** `item.create` per new item, `file.upload` for the attached
871///   STEP file, `bom.add` per line, then `import.bom` on the root revision.
872/// * **Notifications:** none; the importer only starts watching new items.
873/// * **Webhooks:** none.
874/// * **Atomic:** one transaction (`tests/import_atomicity.rs`); a refused or
875///   failing import leaves no item, line, counter step or audit entry.
876pub 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    // (item id, number, new revision) per planned item. Existing items and
902    // items with numbers from the file come first, so that generated numbers
903    // (the root's included) can never take one of them.
904    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), &notes)
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            &notes,
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// ---------------------------------------------------------------------------
1051// Staged uploads (web UI: upload → preview → import)
1052// ---------------------------------------------------------------------------
1053
1054/// An upload waiting for the user's confirmation.
1055#[derive(Clone, Debug)]
1056pub struct Staged {
1057    pub id: Uuid,
1058    pub filename: String,
1059    pub options: ImportOptions,
1060    content: Vec<u8>,
1061    /// Vault blob of a STEP file.
1062    sha256: Option<String>,
1063}
1064
1065impl Staged {
1066    pub fn table(&self) -> AppResult<BomTable> {
1067        read_table(&self.filename, &self.content)
1068    }
1069
1070    /// The staged STEP file, to attach to the new assembly.
1071    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
1080/// Stores an upload for the preview; fails right away if the file cannot be
1081/// read. STEP files go to the vault, tables into the row.
1082pub 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
1144/// A staged upload of the actor.
1145pub 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
1159/// The preview of a staged upload.
1160pub 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
1173/// Runs a staged import and removes the upload (a second click finds nothing).
1174///
1175/// # Invariants
1176///
1177/// * **Permission:** as [`run`]; the staged upload must belong to the actor
1178///   (otherwise "not found", like a used or expired one).
1179/// * **Locks:** the staged row (`FOR UPDATE`), then those of [`run`].
1180/// * **Audit / notifications / webhooks:** as [`run`].
1181/// * **Atomic:** one transaction: the import and the removal of the staged
1182///   upload commit together, so a second click finds nothing.
1183pub 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
1221/// Discards a staged upload; returns its options (to go back to the target).
1222/// A table goes at once. A STEP file is only marked expired: its row is
1223/// unusable from now on, and [`purge_stale`] removes it together with its
1224/// blob once the blob is past the grace period of the vault clean-up.
1225pub 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
1248/// Removes staged uploads older than [`STAGED_HOURS`] and discarded ones
1249/// (housekeeping), the blobs of STEP files with them – unless something
1250/// else refers to the blob (a file made from the same content, another
1251/// staged upload) or it was stored again within
1252/// [`retention::IN_FLIGHT_GRACE`]; such a staged upload stays (expired)
1253/// until the next run. Returns the number of staged uploads removed.
1254pub 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        // the blob is released as seen without this row
1273        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        // A STEP name goes to the STEP reader, whatever the content.
1339        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        // Table names go to the table reader.
1345        let t = read_table("bom.TSV", b"Part Number\tName\nA\tB\n").unwrap();
1346        assert_eq!(t.rows[0].number, "A");
1347        // Unsupported names are refused with the list of formats.
1348        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}