Skip to main content

brep_app/plm/
bom.rs

1//! BOM, where-used and part attributes on the PLM (plan S6, D11).
2//!
3//! - [`fetch_bom`]: `GET /api/parts/:id/revisions/:rev/bom`, indented or flat,
4//!   with the server's own sourcing and costs. The app shows them as sent and
5//!   recomputes nothing.
6//! - [`document_bom`]: the SAME shape read off the assembly's document: its
7//!   occurrences folded into lines as S5 publishes them ([`super::uses`]),
8//!   descending into each sub-assembly's own document. [`compare`] holds the
9//!   two side by side, line for line. They agree exactly when the published
10//!   uses lists are the documents', so a difference is a list that was not
11//!   republished, or a Replace everywhere the document has not caught up with.
12//! - [`where_used`]: "what breaks if I change this", before a new revision.
13//! - [`patch_attribute`]: a part's catalog value, through `PATCH
14//!   /api/parts/:id` (D11). No checkout, and `lock_released_attributes`
15//!   decides whether a released part takes it at all: the refusal is the
16//!   server's sentence.
17use super::client::{PlmClient, PlmError};
18use crate::store::DocumentIdentity;
19use serde::Deserialize;
20use serde_json::{json, Value};
21
22/// The server's BOM of one revision (`BREP_plm/src/bom.rs` `Bom`).
23#[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/// One line of the server's BOM.
41#[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    /// Counted in the totals (a priced line with nothing beneath it).
69    pub costed: bool,
70    pub warnings: Vec<String>,
71    /// One word per warning (`unreleased`, `superseded`, …), for a chip.
72    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/// `GET /api/parts/:id/where-used`.
84#[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    /// The parent revision is released or in work: one a change still reaches.
105    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
114/// The server's BOM of `part`/`revision`: every level, indented or flat.
115pub 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
120/// Every assembly that uses `part` (at `revision`, or any), every level up.
121pub 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
129/// Set one catalog value on `part` (`null` or empty clears it). The key is
130/// the server's (attribute keys are lower-case); the refusal, a released
131/// part's lock included, is the server's sentence.
132pub 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
140/// The server's key for a BOM column's field: catalog keys are lower-case.
141pub 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
149/// The PLM revision an open document IS, from its name: the store key
150/// `part/<id>/rev/<id>`, or the explorer's path form of it
151/// (`/models/part/<id>/rev/<id>.nbrep`).
152pub 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/// One line of the document's BOM, in the server's terms.
163#[derive(Clone, Debug, PartialEq)]
164pub struct DocumentLine {
165    pub level: usize,
166    /// `1.2.3`, as the server numbers its indented view.
167    pub position: String,
168    pub part_id: String,
169    pub revision_id: String,
170    /// Per ONE parent.
171    pub quantity: f64,
172    /// For ONE of the top: the quantities along the path multiplied.
173    pub total: f64,
174}
175
176/// The document's BOM: each level's occurrences folded into lines the way S5
177/// publishes them ([`super::uses::uses_lines`]), then each line's
178/// sub-assembly descended into through the `partsLibrary` entry's own
179/// document. `Err` names every occurrence, at any depth, that is no PLM
180/// revision: such a document has no server BOM to be compared with.
181pub 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        // The first occurrence of the line names the library entry; every
212        // occurrence of one line places the same part and revision.
213        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
224/// Line for line: the document's BOM against the server's indented one.
225/// Empty when they agree on every line's level, position, part, revision,
226/// quantity and total; otherwise one sentence per difference, naming the
227/// position and the server's part number.
228pub 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
268/// Draw a where-used answer: one row per parent revision, indented by level,
269/// current ones plain and history weak. For the BOM panel and for S3's New
270/// revision, which shows it before a new revision starts.
271pub 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