1use std::collections::{BTreeMap, HashMap, HashSet};
6
7use serde::Serialize;
8use sqlx::PgPool;
9use uuid::Uuid;
10
11use crate::{
12 auth::Actor,
13 domain::RevisionRule,
14 error::AppResult,
15 models::Revision,
16 services::{
17 access,
18 bom::{self, BomNode},
19 revisions,
20 },
21};
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum Issue {
27 Unresolved,
29 Obsolete,
31 NotReleased,
33 MissingMass,
35 NoFiles,
37 CheckedOut,
39 CheckNeeded,
41 Cycle,
43 Restricted,
45}
46
47#[derive(Clone, Debug, Serialize)]
48pub struct Finding {
49 pub issue: Issue,
50 pub level: usize,
51 pub path: Vec<String>,
53 pub item_number: String,
54 pub revision: Option<String>,
55 pub detail: String,
56}
57
58#[derive(Clone, Debug, Serialize)]
59pub struct BomHealth {
60 pub root: String,
61 pub rule: RevisionRule,
62 pub line_count: usize,
63 pub counts: BTreeMap<Issue, usize>,
64 pub findings: Vec<Finding>,
65 pub truncated: bool,
67}
68
69const MAX_FINDINGS: usize = 500;
71
72fn walk<'a>(
73 nodes: &'a [BomNode],
74 path: &mut Vec<String>,
75 out: &mut Vec<(&'a BomNode, Vec<String>)>,
76) {
77 for n in nodes {
78 path.push(n.item_number.clone());
79 out.push((n, path.clone()));
80 walk(&n.children, path, out);
81 path.pop();
82 }
83}
84
85pub async fn bom_health(
87 pool: &PgPool,
88 actor: &Actor,
89 root_revision_id: Uuid,
90 rule: RevisionRule,
91) -> AppResult<BomHealth> {
92 let tree = bom::tree(pool, actor, root_revision_id, rule).await?;
93 let mut lines = Vec::new();
94 walk(&tree.children, &mut Vec::new(), &mut lines);
95
96 let rev_ids: Vec<Uuid> = lines
97 .iter()
98 .filter_map(|(n, _)| n.revision.as_ref().map(|r| r.id))
99 .collect::<HashSet<_>>()
100 .into_iter()
101 .collect();
102 let item_ids: Vec<Uuid> = lines
103 .iter()
104 .map(|(n, _)| n.item_id)
105 .collect::<HashSet<_>>()
106 .into_iter()
107 .collect();
108 let with_files: HashSet<Uuid> = sqlx::query_scalar::<_, Uuid>(
109 "SELECT DISTINCT revision_id FROM files WHERE revision_id = ANY($1)",
110 )
111 .bind(&rev_ids)
112 .fetch_all(pool)
113 .await?
114 .into_iter()
115 .collect();
116 let checked_out: HashMap<Uuid, String> = sqlx::query_as::<_, Revision>(&format!(
117 "{} WHERE r.id = ANY($1) AND r.checked_out_by IS NOT NULL",
118 revisions::REVISION_SELECT
119 ))
120 .bind(&rev_ids)
121 .fetch_all(pool)
122 .await?
123 .into_iter()
124 .map(|r| (r.id, r.checked_out_by_name.unwrap_or_default()))
125 .collect();
126 let check_needed: HashSet<Uuid> = sqlx::query_scalar::<_, Uuid>(
127 "SELECT DISTINCT item_id FROM impacts WHERE resolved_at IS NULL AND item_id = ANY($1)",
128 )
129 .bind(&item_ids)
130 .fetch_all(pool)
131 .await?
132 .into_iter()
133 .collect();
134
135 let mut findings = Vec::new();
136 let mut counts: BTreeMap<Issue, usize> = BTreeMap::new();
137 for (n, path) in &lines {
138 let code = n.revision.as_ref().map(|r| r.code.clone());
139 let mut add = |issue: Issue, detail: String| {
140 *counts.entry(issue).or_default() += 1;
141 findings.push(Finding {
142 issue,
143 level: n.level,
144 path: path.clone(),
145 item_number: n.item_number.clone(),
146 revision: code.clone(),
147 detail,
148 });
149 };
150 if n.cycle {
151 add(
152 Issue::Cycle,
153 "the structure loops back to an ancestor".into(),
154 );
155 }
156 if n.restricted {
157 add(
158 Issue::Restricted,
159 "restricted item – cannot be checked".into(),
160 );
161 continue;
162 }
163 match &n.revision {
164 None => add(
165 Issue::Unresolved,
166 format!("no revision matches the rule {}", rule.as_str()),
167 ),
168 Some(r) => {
169 match r.state.as_str() {
170 "obsolete" => add(
171 Issue::Obsolete,
172 format!("{}/{} is obsolete", n.item_number, r.code),
173 ),
174 "released" => {}
175 other => add(
176 Issue::NotReleased,
177 format!(
178 "{}/{} is {}",
179 n.item_number,
180 r.code,
181 other.replace('_', " ")
182 ),
183 ),
184 }
185 if n.children.is_empty()
187 && r.mass_kg.is_none()
188 && !matches!(n.item_type.as_str(), "document" | "software")
189 {
190 add(
191 Issue::MissingMass,
192 format!("{}/{} has no mass", n.item_number, r.code),
193 );
194 }
195 if !with_files.contains(&r.id) && n.item_type != "software" {
196 add(
197 Issue::NoFiles,
198 format!("{}/{} has no files", n.item_number, r.code),
199 );
200 }
201 if let Some(who) = checked_out.get(&r.id) {
202 add(
203 Issue::CheckedOut,
204 format!("{}/{} is checked out by {who}", n.item_number, r.code),
205 );
206 }
207 }
208 }
209 if check_needed.contains(&n.item_id) {
210 add(
211 Issue::CheckNeeded,
212 format!(
213 "{} has an open check after a change of something it uses",
214 n.item_number
215 ),
216 );
217 }
218 }
219 let truncated = findings.len() > MAX_FINDINGS;
220 findings.truncate(MAX_FINDINGS);
221 Ok(BomHealth {
222 root: format!("{}/{}", tree.root_item_number, tree.root.code),
223 rule,
224 line_count: tree.line_count,
225 counts,
226 findings,
227 truncated,
228 })
229}
230
231#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
233pub struct DuplicateFile {
234 pub sha256: String,
235 pub filename: String,
236 pub size_bytes: i64,
237 pub item_number: String,
238 pub revision_code: String,
239 pub revision_state: String,
240}
241
242pub async fn duplicate_files(
248 pool: &PgPool,
249 actor: &Actor,
250 revision_id: Option<Uuid>,
251 limit: i64,
252) -> AppResult<Vec<DuplicateFile>> {
253 if let Some(id) = revision_id {
254 revisions::get(pool, actor, id).await?;
255 }
256 Ok(sqlx::query_as::<_, DuplicateFile>(&format!(
257 "WITH latest AS (
258 SELECT f.sha256, f.filename, f.size_bytes, i.number AS item_number, r.item_id,
259 r.code AS revision_code, r.state AS revision_state
260 FROM files f
261 JOIN revisions r ON r.id = f.revision_id
262 JOIN items i ON i.id = r.item_id
263 WHERE r.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r.item_id)
264 AND {}
265 ),
266 shared AS (
267 SELECT sha256 FROM latest GROUP BY sha256 HAVING COUNT(DISTINCT item_id) > 1
268 )
269 SELECT l.sha256, l.filename, l.size_bytes, l.item_number, l.revision_code, l.revision_state
270 FROM latest l JOIN shared s ON s.sha256 = l.sha256
271 WHERE $1::uuid IS NULL OR l.sha256 IN (SELECT sha256 FROM files WHERE revision_id = $1)
272 ORDER BY l.size_bytes DESC, l.sha256, l.item_number
273 LIMIT $2",
274 access::item_visible("i", 3)
275 ))
276 .bind(revision_id)
277 .bind(limit.clamp(1, 1000))
278 .bind(&actor.hidden_projects)
279 .fetch_all(pool)
280 .await?)
281}