1use serde::Serialize;
18use sqlx::PgPool;
19use uuid::Uuid;
20
21use crate::{
22 auth::Actor,
23 error::{AppError, AppResult},
24 services::{access, Page},
25};
26
27pub const MAX_CHILDREN: i64 = 2000;
29
30#[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#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
58pub struct StructureNode {
59 pub item_number: String,
60 pub item_type: String,
61 pub name: String,
62 pub code: Option<String>,
65 pub state: Option<String>,
66 pub checked_out_by_name: Option<String>,
67 pub unit: String,
68 pub used_in: i64,
70 pub bom_lines: i64,
72 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
99pub 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
140pub 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 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}