Skip to main content

teamcraft/services/
structure.rs

1//! The structure view of the desktop navigator: the top-level items of a
2//! project group and the parts of one assembly revision, one level at a time
3//! (read-only; a big group or assembly is never loaded as a whole).
4//!
5//! * [`roots`]: the items of a group that no BOM line of the group uses
6//!   (assemblies and loose parts), with the number of assemblies that use them.
7//! * [`children`]: the BOM lines of one revision under a revision rule
8//!   (`latest` or `released`), each with the revision it resolves to, its
9//!   state, the lock owner and how many assemblies use the part. A child in a
10//!   group the actor cannot read is a placeholder without identity.
11//!
12//! # Invariants
13//!
14//! * **Permission:** hidden groups answer "not found"; hidden children and
15//!   hidden parents (the usage count) are never revealed.
16
17use serde::Serialize;
18use sqlx::PgPool;
19use uuid::Uuid;
20
21use crate::{
22    auth::Actor,
23    error::{AppError, AppResult},
24    services::{access, Page},
25};
26
27/// More lines than this are never returned in one level.
28pub const MAX_CHILDREN: i64 = 2000;
29
30/// Revision rules of the structure view.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum Rule {
33    Latest,
34    Released,
35}
36
37impl Rule {
38    pub fn parse(raw: Option<&str>) -> AppResult<Rule> {
39        match raw.map(str::trim).filter(|r| !r.is_empty()) {
40            None | Some("latest") => Ok(Rule::Latest),
41            Some("released") => Ok(Rule::Released),
42            Some(_) => Err(AppError::BadRequest(
43                "rule must be latest or released.".into(),
44            )),
45        }
46    }
47
48    fn as_str(self) -> &'static str {
49        match self {
50            Rule::Latest => "latest",
51            Rule::Released => "released",
52        }
53    }
54}
55
56/// One item or BOM line of the structure view.
57#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
58pub struct StructureNode {
59    pub item_number: String,
60    pub item_type: String,
61    pub name: String,
62    /// The revision the rule resolves to; `None`: none for the rule
63    /// ("not released yet" under `released`).
64    pub code: Option<String>,
65    pub state: Option<String>,
66    pub checked_out_by_name: Option<String>,
67    pub unit: String,
68    /// Assemblies (distinct items) that use the item, visible to the actor.
69    pub used_in: i64,
70    /// BOM lines of the resolved revision: more than 0 = expandable.
71    pub bom_lines: i64,
72    /// Only for BOM lines.
73    pub find_number: Option<i32>,
74    pub quantity: Option<f64>,
75    pub restricted: bool,
76}
77
78#[derive(Debug, Serialize)]
79pub struct Level {
80    pub nodes: Vec<StructureNode>,
81    pub total: i64,
82}
83
84#[derive(sqlx::FromRow)]
85struct Counted {
86    #[sqlx(flatten)]
87    node: StructureNode,
88    total: i64,
89}
90
91fn level(rows: Vec<Counted>) -> Level {
92    let total = rows.first().map(|r| r.total).unwrap_or(0);
93    Level {
94        nodes: rows.into_iter().map(|r| r.node).collect(),
95        total,
96    }
97}
98
99/// The top-level items of a project group: not used in any BOM line whose
100/// parent belongs to the group, by number.
101pub async fn roots(pool: &PgPool, actor: &Actor, project_id: Uuid, page: Page) -> AppResult<Level> {
102    access::ensure_visible(
103        actor,
104        Some(project_id),
105        AppError::not_found("Project group not found."),
106    )?;
107    let rows = sqlx::query_as::<_, Counted>(&format!(
108        "SELECT i.number AS item_number, i.item_type, lr.name, lr.code AS code, lr.state AS state,
109                u.display_name AS checked_out_by_name, i.unit,
110                (SELECT COUNT(DISTINCT pr.item_id) FROM bom_lines x
111                   JOIN revisions pr ON pr.id = x.parent_revision_id
112                   JOIN items pi ON pi.id = pr.item_id
113                  WHERE x.child_item_id = i.id AND {pv}) AS used_in,
114                (SELECT COUNT(*) FROM bom_lines x WHERE x.parent_revision_id = lr.id) AS bom_lines,
115                NULL::int AS find_number, NULL::float8 AS quantity, FALSE AS restricted,
116                COUNT(*) OVER () AS total
117         FROM items i
118         CROSS JOIN LATERAL (SELECT r.id, r.code, r.state, r.name, r.checked_out_by
119                             FROM revisions r WHERE r.item_id = i.id
120                             ORDER BY r.seq DESC LIMIT 1) lr
121         LEFT JOIN users u ON u.id = lr.checked_out_by
122         WHERE i.project_id = $1 AND {iv}
123           AND NOT EXISTS (SELECT 1 FROM bom_lines x
124                             JOIN revisions pr ON pr.id = x.parent_revision_id
125                             JOIN items pi ON pi.id = pr.item_id
126                            WHERE x.child_item_id = i.id AND pi.project_id = $1)
127         ORDER BY i.number LIMIT $3 OFFSET $4",
128        pv = access::item_visible("pi", 2),
129        iv = access::item_visible("i", 2),
130    ))
131    .bind(project_id)
132    .bind(&actor.hidden_projects)
133    .bind(page.limit)
134    .bind(page.offset)
135    .fetch_all(pool)
136    .await?;
137    Ok(level(rows))
138}
139
140/// The BOM lines of a revision (one level), resolved by the rule. The caller
141/// has checked that the actor may read the revision.
142pub async fn children(
143    pool: &PgPool,
144    actor: &Actor,
145    revision_id: Uuid,
146    rule: Rule,
147    limit: i64,
148) -> AppResult<Level> {
149    let mut rows = sqlx::query_as::<_, Counted>(&format!(
150        "SELECT ci.number AS item_number, ci.item_type, COALESCE(cr.name, '') AS name,
151                cr.code AS code, cr.state AS state, u.display_name AS checked_out_by_name, ci.unit,
152                (SELECT COUNT(DISTINCT pr.item_id) FROM bom_lines x
153                   JOIN revisions pr ON pr.id = x.parent_revision_id
154                   JOIN items pi ON pi.id = pr.item_id
155                  WHERE x.child_item_id = ci.id AND {pv}) AS used_in,
156                (SELECT COUNT(*) FROM bom_lines x WHERE x.parent_revision_id = cr.id) AS bom_lines,
157                bl.find_number, bl.quantity, NOT {iv} AS restricted,
158                COUNT(*) OVER () AS total
159         FROM bom_lines bl
160         JOIN items ci ON ci.id = bl.child_item_id
161         LEFT JOIN LATERAL (SELECT r.id, r.code, r.state, r.name, r.checked_out_by
162                            FROM revisions r
163                            WHERE r.item_id = ci.id AND ($2::text = 'latest' OR r.state = 'released')
164                            ORDER BY r.seq DESC LIMIT 1) cr ON TRUE
165         LEFT JOIN users u ON u.id = cr.checked_out_by
166         WHERE bl.parent_revision_id = $1
167         ORDER BY bl.find_number LIMIT $4",
168        pv = access::item_visible("pi", 3),
169        iv = access::item_visible("ci", 3),
170    ))
171    .bind(revision_id)
172    .bind(rule.as_str())
173    .bind(&actor.hidden_projects)
174    .bind(limit.clamp(1, MAX_CHILDREN))
175    .fetch_all(pool)
176    .await?;
177    // a hidden child shows nothing but its place in the BOM
178    for r in rows.iter_mut().filter(|r| r.node.restricted) {
179        let n = &mut r.node;
180        n.item_number.clear();
181        n.name.clear();
182        n.item_type.clear();
183        n.unit.clear();
184        n.code = None;
185        n.state = None;
186        n.checked_out_by_name = None;
187        n.used_in = 0;
188        n.bom_lines = 0;
189    }
190    Ok(level(rows))
191}