1use super::client::{PlmClient, PlmError};
18use crate::store::DocumentIdentity;
19use serde::Deserialize;
20use serde_json::{json, Value};
21
22#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
24#[serde(default)]
25pub struct ServerBom {
26 pub part_id: String,
27 pub number: String,
28 pub name: String,
29 pub revision_id: String,
30 pub revision_label: String,
31 pub state: String,
32 pub flat: bool,
33 pub levels: usize,
34 pub lines: Vec<ServerLine>,
35 pub totals: Vec<CurrencyTotal>,
36 pub unpriced: usize,
37 pub warnings: Vec<String>,
38}
39
40#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
42#[serde(default)]
43pub struct ServerLine {
44 pub level: usize,
45 pub position: String,
46 pub find_number: String,
47 pub reference: String,
48 pub part_id: String,
49 pub number: String,
50 pub name: String,
51 pub revision_id: String,
52 pub revision_label: String,
53 pub state: String,
54 pub floating: bool,
55 pub category_path: String,
56 pub attributes: Vec<String>,
57 pub quantity: f64,
58 pub unit: String,
59 pub total: f64,
60 pub assembly: bool,
61 pub mpn: String,
62 pub manufacturer: String,
63 pub supplier: String,
64 pub spn: String,
65 pub currency: String,
66 pub unit_price: Option<f64>,
67 pub extended: Option<f64>,
68 pub costed: bool,
70 pub warnings: Vec<String>,
71 pub flags: Vec<String>,
73}
74
75#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
76#[serde(default)]
77pub struct CurrencyTotal {
78 pub currency: String,
79 pub total: f64,
80 pub lines: usize,
81}
82
83#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
85#[serde(default)]
86pub struct WhereUsed {
87 pub part_id: String,
88 pub number: String,
89 pub revision_label: String,
90 pub levels: usize,
91 pub lines: Vec<WhereUsedLine>,
92}
93
94#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
95#[serde(default)]
96pub struct WhereUsedLine {
97 pub level: usize,
98 pub part_id: String,
99 pub number: String,
100 pub name: String,
101 pub revision_id: String,
102 pub revision_label: String,
103 pub state: String,
104 pub current: bool,
106 pub uses_revision: String,
107 pub floating: bool,
108 pub quantity: f64,
109 pub unit: String,
110 pub find_number: String,
111 pub top: bool,
112}
113
114pub async fn fetch_bom(client: &PlmClient, part: &str, revision: &str, flat: bool) -> Result<ServerBom, PlmError> {
116 let path = format!("/api/parts/{part}/revisions/{revision}/bom?levels=0{}", if flat { "&flat=true" } else { "" });
117 json(client.call("GET", &path, None).await?, &path)
118}
119
120pub async fn where_used(client: &PlmClient, part: &str, revision: Option<&str>) -> Result<WhereUsed, PlmError> {
122 let path = match revision {
123 Some(revision) => format!("/api/parts/{part}/where-used?levels=0&revision={revision}"),
124 None => format!("/api/parts/{part}/where-used?levels=0"),
125 };
126 json(client.call("GET", &path, None).await?, &path)
127}
128
129pub async fn patch_attribute(client: &PlmClient, part: &str, key: &str, value: &Value) -> Result<(), PlmError> {
133 let body = json!({ "attributes": { attribute_key(key): value } });
134 client
135 .call("PATCH", &format!("/api/parts/{part}"), Some(body.to_string().into_bytes()))
136 .await
137 .map(|_| ())
138}
139
140pub fn attribute_key(field: &str) -> String {
142 field.trim().to_ascii_lowercase()
143}
144
145fn json<T: for<'de> Deserialize<'de>>(response: super::PlmResponse, what: &str) -> Result<T, PlmError> {
146 serde_json::from_slice(&response.body).map_err(|e| PlmError::Malformed(format!("{what}: {e}")))
147}
148
149pub fn revision_of_document(name: &str) -> Option<(String, String)> {
153 let key = name.trim_start_matches('/');
154 let key = key.strip_prefix("models/").unwrap_or(key);
155 let key = key.strip_suffix(".nbrep").unwrap_or(key);
156 match DocumentIdentity::parse_revision_key(key)? {
157 DocumentIdentity::Revision { part, revision } => Some((part, revision)),
158 DocumentIdentity::Path(_) => None,
159 }
160}
161
162#[derive(Clone, Debug, PartialEq)]
164pub struct DocumentLine {
165 pub level: usize,
166 pub position: String,
168 pub part_id: String,
169 pub revision_id: String,
170 pub quantity: f64,
172 pub total: f64,
174}
175
176pub fn document_bom(document: &Value) -> Result<Vec<DocumentLine>, Vec<String>> {
182 let mut lines = Vec::new();
183 let mut refusals = Vec::new();
184 descend(document, 1, "", 1.0, &mut lines, &mut refusals);
185 if refusals.is_empty() {
186 Ok(lines)
187 } else {
188 Err(refusals)
189 }
190}
191
192fn descend(document: &Value, level: usize, prefix: &str, parent_total: f64, out: &mut Vec<DocumentLine>, refusals: &mut Vec<String>) {
193 let occurrences = super::uses::occurrences(document);
194 let list = super::uses::uses_lines(&occurrences, &super::uses::plm_identity);
195 for refusal in &list.refusals {
196 let at = if prefix.is_empty() { String::new() } else { format!(" (under {prefix})") };
197 refusals.push(format!("{}{at}: {}", refusal.occurrence, refusal.reason));
198 }
199 for (index, placed) in list.lines.iter().enumerate() {
200 let position = if prefix.is_empty() { format!("{}", index + 1) } else { format!("{prefix}.{}", index + 1) };
201 let quantity = f64::from(placed.line.quantity);
202 let total = parent_total * quantity;
203 out.push(DocumentLine {
204 level,
205 position: position.clone(),
206 part_id: placed.line.part.clone(),
207 revision_id: placed.line.revision.clone(),
208 quantity,
209 total,
210 });
211 let entry = placed
214 .occurrences
215 .first()
216 .and_then(|id| occurrences.iter().find(|o| &o.id == id))
217 .and_then(|o| document.get("partsLibrary")?.get(&o.part_name));
218 if let Some(child) = entry.and_then(|entry| entry.get("document")).filter(|d| d.is_object()) {
219 descend(child, level + 1, &position, total, out, refusals);
220 }
221 }
222}
223
224pub fn compare(document: &[DocumentLine], server: &[ServerLine]) -> Vec<String> {
229 let mut differences = Vec::new();
230 let named = |line: &ServerLine| {
231 if line.number.is_empty() {
232 line.part_id.clone()
233 } else {
234 format!("{} rev {}", line.number, line.revision_label)
235 }
236 };
237 for index in 0..document.len().max(server.len()) {
238 match (document.get(index), server.get(index)) {
239 (Some(doc), Some(srv)) => {
240 let mut says = Vec::new();
241 if doc.position != srv.position || doc.level != srv.level {
242 says.push(format!("the document has position {} (level {}), the PLM {} (level {})", doc.position, doc.level, srv.position, srv.level));
243 }
244 if doc.part_id != srv.part_id || doc.revision_id != srv.revision_id {
245 says.push(format!("the document places part {} revision {}", doc.part_id, doc.revision_id));
246 }
247 if (doc.quantity - srv.quantity).abs() > 1e-9 {
248 says.push(format!("quantity {} in the document, {} on the PLM", doc.quantity, srv.quantity));
249 }
250 if (doc.total - srv.total).abs() > 1e-9 {
251 says.push(format!("total {} in the document, {} on the PLM", doc.total, srv.total));
252 }
253 if !says.is_empty() {
254 differences.push(format!("line {} ({}): {}", srv.position, named(srv), says.join("; ")));
255 }
256 }
257 (Some(doc), None) => differences.push(format!(
258 "line {}: the document places part {} revision {}, which the PLM's BOM does not list",
259 doc.position, doc.part_id, doc.revision_id
260 )),
261 (None, Some(srv)) => differences.push(format!("line {} ({}): on the PLM's BOM, not in the document", srv.position, named(srv))),
262 (None, None) => {}
263 }
264 }
265 differences
266}
267
268pub fn show_where_used(ui: &mut eframe::egui::Ui, used: &WhereUsed) {
272 use eframe::egui;
273 if used.lines.is_empty() {
274 ui.label(format!("{} is used by no assembly.", used.number));
275 return;
276 }
277 let current = used.lines.iter().filter(|l| l.current).count();
278 ui.label(format!(
279 "{} is used in {} assembly revision{} ({} current, which a change still reaches):",
280 used.number,
281 used.lines.len(),
282 if used.lines.len() == 1 { "" } else { "s" },
283 current
284 ));
285 egui::Grid::new(("where-used", &used.part_id)).striped(true).show(ui, |ui| {
286 for heading in ["Assembly", "Rev", "State", "Uses", "Qty"] {
287 ui.strong(heading);
288 }
289 ui.end_row();
290 for line in &used.lines {
291 let text = format!("{}{} {}", " ".repeat(line.level.saturating_sub(1)), line.number, line.name);
292 if line.current {
293 ui.label(text);
294 } else {
295 ui.label(egui::RichText::new(text).weak());
296 }
297 ui.label(&line.revision_label);
298 ui.label(&line.state);
299 ui.label(&line.uses_revision);
300 ui.label(format!("{} {}", line.quantity, line.unit));
301 ui.end_row();
302 }
303 });
304}
305