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 comparison_lines: Vec<ServerLine>,
27    pub part_id: String,
28    pub number: String,
29    pub name: String,
30    pub revision_id: String,
31    pub revision_label: String,
32    pub state: String,
33    pub flat: bool,
34    pub levels: usize,
35    pub lines: Vec<ServerLine>,
36    pub totals: Vec<CurrencyTotal>,
37    pub unpriced: usize,
38    pub warnings: Vec<String>,
39}
40
41/// One line of the server's BOM.
42#[derive(Clone, Debug, Default, PartialEq, Deserialize, serde::Serialize)]
43#[serde(default)]
44pub struct ServerLine {
45    pub part_type: String,
46    pub part_values: std::collections::BTreeMap<String,Value>,
47    pub owner_part: String,
48    pub owner_revision: String,
49    pub occurrence_ids: Vec<String>,
50    pub occurrence_attributes: std::collections::BTreeMap<String,Value>,
51    pub notes: String,
52    pub level: usize,
53    pub position: String,
54    pub find_number: String,
55    pub reference: String,
56    pub part_id: String,
57    pub number: String,
58    pub name: String,
59    pub revision_id: String,
60    pub revision_label: String,
61    pub state: String,
62    pub floating: bool,
63    pub category_path: String,
64    pub attributes: Vec<String>,
65    pub quantity: f64,
66    pub unit: String,
67    pub total: f64,
68    pub assembly: bool,
69    pub mpn: String,
70    pub manufacturer: String,
71    pub supplier: String,
72    pub spn: String,
73    pub currency: String,
74    pub unit_price: Option<f64>,
75    pub extended: Option<f64>,
76    /// Counted in the totals (a priced line with nothing beneath it).
77    pub costed: bool,
78    pub warnings: Vec<String>,
79    /// One word per warning (`unreleased`, `superseded`, …), for a chip.
80    pub flags: Vec<String>,
81}
82
83#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
84#[serde(default)]
85pub struct CurrencyTotal {
86    pub currency: String,
87    pub total: f64,
88    pub lines: usize,
89}
90
91/// `GET /api/parts/:id/where-used`.
92#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
93#[serde(default)]
94pub struct WhereUsed {
95    pub part_id: String,
96    pub number: String,
97    pub revision_label: String,
98    pub levels: usize,
99    pub lines: Vec<WhereUsedLine>,
100}
101
102#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
103#[serde(default)]
104pub struct WhereUsedLine {
105    pub level: usize,
106    pub part_id: String,
107    pub number: String,
108    pub name: String,
109    pub revision_id: String,
110    pub revision_label: String,
111    pub state: String,
112    /// The parent revision is released or in work: one a change still reaches.
113    pub current: bool,
114    pub uses_revision: String,
115    pub floating: bool,
116    pub quantity: f64,
117    pub unit: String,
118    pub find_number: String,
119    pub top: bool,
120}
121
122/// The server's BOM of `part`/`revision`: every level, indented or flat.
123pub async fn fetch_bom(client: &PlmClient, part: &str, revision: &str, flat: bool) -> Result<ServerBom, PlmError> {
124    let path = format!("{}/bom?levels=0{}", super::identity::revision_path(part, revision), if flat { "&flat=true" } else { "" });
125    json(client.call("GET", &path, None).await?, &path)
126}
127
128pub async fn fetch_occurrence_bom(client:&PlmClient,part:&str,revision:&str,flat:bool)->Result<ServerBom,PlmError>{
129    let path=format!("{}/bom?levels=0&occurrences=true&flat={flat}",super::identity::revision_path(part,revision));
130    json(client.call("GET",&path,None).await?,&path)
131}
132
133/// Every assembly that uses `part` (at `revision`, or any), every level up.
134pub async fn where_used(client: &PlmClient, part: &str, revision: Option<&str>) -> Result<WhereUsed, PlmError> {
135    let path = match revision {
136        Some(revision) => format!("{}/where-used?levels=0&revision={}", super::identity::part_path(part), super::identity::segment(revision)),
137        None => format!("{}/where-used?levels=0", crate::plm::identity::part_path(&part)),
138    };
139    json(client.call("GET", &path, None).await?, &path)
140}
141
142/// Set one catalog value on `part` (`null` or empty clears it). The key is
143/// the server's (attribute keys are lower-case); the refusal, a released
144/// part's lock included, is the server's sentence.
145pub async fn patch_attribute(client: &PlmClient, part: &str, key: &str, value: &Value) -> Result<(), PlmError> {
146    let body = json!({ "attributes": { attribute_key(key): value } });
147    client
148        .call("PATCH", &crate::plm::identity::part_path(&part), Some(body.to_string().into_bytes()))
149        .await
150        .map(|_| ())
151}
152
153/// The server's key for a BOM column's field: catalog keys are lower-case.
154pub fn attribute_key(field: &str) -> String {
155    field.trim().to_ascii_lowercase()
156}
157
158fn json<T: for<'de> Deserialize<'de>>(response: super::PlmResponse, what: &str) -> Result<T, PlmError> {
159    serde_json::from_slice(&response.body).map_err(|e| PlmError::Malformed(format!("{what}: {e}")))
160}
161
162/// The PLM revision an open document IS, from its name: the store key
163/// `part/<id>/rev/<id>`, or the explorer's path form of it
164/// (`/models/part/<id>/rev/<id>.nbrep`).
165pub fn revision_of_document(name: &str) -> Option<(String, String)> {
166    let key = name.trim_start_matches('/');
167    let key = key.strip_prefix("models/").unwrap_or(key);
168    let key = key.strip_suffix(".nbrep").unwrap_or(key);
169    match DocumentIdentity::parse_revision_key(key)? {
170        DocumentIdentity::Revision { part, revision } => Some((part, revision)),
171        DocumentIdentity::Path(_) => None,
172    }
173}
174
175/// One line of the document's BOM, in the server's terms.
176#[derive(Clone, Debug, PartialEq)]
177pub struct DocumentLine {
178    pub level: usize,
179    /// `1.2.3`, as the server numbers its indented view.
180    pub position: String,
181    pub part_id: String,
182    pub revision_id: String,
183    /// Per ONE parent.
184    pub quantity: f64,
185    /// For ONE of the top: the quantities along the path multiplied.
186    pub total: f64,
187}
188
189/// The document's BOM: each level's occurrences folded into lines the way S5
190/// publishes them ([`super::uses::uses_lines`]), then each line's
191/// sub-assembly descended into through the `partsLibrary` entry's own
192/// document. `Err` names every occurrence, at any depth, that is no PLM
193/// revision: such a document has no server BOM to be compared with.
194pub fn document_bom(document: &Value) -> Result<Vec<DocumentLine>, Vec<String>> {
195    let mut lines = Vec::new();
196    let mut refusals = Vec::new();
197    descend(document, 1, "", 1.0, &mut lines, &mut refusals);
198    if refusals.is_empty() {
199        Ok(lines)
200    } else {
201        Err(refusals)
202    }
203}
204
205fn descend(document: &Value, level: usize, prefix: &str, parent_total: f64, out: &mut Vec<DocumentLine>, refusals: &mut Vec<String>) {
206    let occurrences = super::uses::occurrences(document);
207    let list = super::uses::uses_lines(&occurrences, &super::uses::plm_identity);
208    for refusal in &list.refusals {
209        let at = if prefix.is_empty() { String::new() } else { format!(" (under {prefix})") };
210        refusals.push(format!("{}{at}: {}", refusal.occurrence, refusal.reason));
211    }
212    for (index, placed) in list.lines.iter().enumerate() {
213        let position = if prefix.is_empty() { format!("{}", index + 1) } else { format!("{prefix}.{}", index + 1) };
214        let quantity = f64::from(placed.line.quantity);
215        let total = parent_total * quantity;
216        out.push(DocumentLine {
217            level,
218            position: position.clone(),
219            part_id: placed.line.part.clone(),
220            revision_id: placed.line.revision.clone(),
221            quantity,
222            total,
223        });
224        // The first occurrence of the line names the library entry; every
225        // occurrence of one line places the same part and revision.
226        let entry = placed
227            .occurrences
228            .first()
229            .and_then(|id| occurrences.iter().find(|o| &o.id == id))
230            .and_then(|o| document.get("partsLibrary")?.get(&o.part_name));
231        if let Some(child) = entry.and_then(|entry| entry.get("document")).filter(|d| d.is_object()) {
232            descend(child, level + 1, &position, total, out, refusals);
233        }
234    }
235}
236
237/// Line for line: the document's BOM against the server's indented one.
238/// Empty when they agree on every line's level, position, part, revision,
239/// quantity and total; otherwise one sentence per difference, naming the
240/// position and the server's part number.
241pub fn compare(document: &[DocumentLine], server: &[ServerLine]) -> Vec<String> {
242    let mut differences = Vec::new();
243    let named = |line: &ServerLine| {
244        if line.number.is_empty() {
245            line.part_id.clone()
246        } else {
247            format!("{} rev {}", line.number, line.revision_label)
248        }
249    };
250    for index in 0..document.len().max(server.len()) {
251        match (document.get(index), server.get(index)) {
252            (Some(doc), Some(srv)) => {
253                let mut says = Vec::new();
254                if doc.position != srv.position || doc.level != srv.level {
255                    says.push(format!("the document has position {} (level {}), the PLM {} (level {})", doc.position, doc.level, srv.position, srv.level));
256                }
257                if doc.part_id != srv.part_id || doc.revision_id != srv.revision_id {
258                    says.push(format!("the document places part {} revision {}", doc.part_id, doc.revision_id));
259                }
260                if (doc.quantity - srv.quantity).abs() > 1e-9 {
261                    says.push(format!("quantity {} in the document, {} on the PLM", doc.quantity, srv.quantity));
262                }
263                if (doc.total - srv.total).abs() > 1e-9 {
264                    says.push(format!("total {} in the document, {} on the PLM", doc.total, srv.total));
265                }
266                if !says.is_empty() {
267                    differences.push(format!("line {} ({}): {}", srv.position, named(srv), says.join("; ")));
268                }
269            }
270            (Some(doc), None) => differences.push(format!(
271                "line {}: the document places part {} revision {}, which the PLM's BOM does not list",
272                doc.position, doc.part_id, doc.revision_id
273            )),
274            (None, Some(srv)) => differences.push(format!("line {} ({}): on the PLM's BOM, not in the document", srv.position, named(srv))),
275            (None, None) => {}
276        }
277    }
278    differences
279}
280
281/// Draw a where-used answer: one row per parent revision, indented by level,
282/// current ones plain and history weak. For the BOM panel and for S3's New
283/// revision, which shows it before a new revision starts.
284pub fn show_where_used(ui: &mut eframe::egui::Ui, used: &WhereUsed) {
285    use eframe::egui;
286    if used.lines.is_empty() {
287        ui.label(format!("{} is used by no assembly.", used.number));
288        return;
289    }
290    let current = used.lines.iter().filter(|l| l.current).count();
291    ui.label(format!(
292        "{} is used in {} assembly revision{} ({} current, which a change still reaches):",
293        used.number,
294        used.lines.len(),
295        if used.lines.len() == 1 { "" } else { "s" },
296        current
297    ));
298    egui::Grid::new(("where-used", &used.part_id)).striped(true).show(ui, |ui| {
299        for heading in ["Assembly", "Rev", "State", "Uses", "Qty"] {
300            ui.strong(heading);
301        }
302        ui.end_row();
303        for line in &used.lines {
304            let text = format!("{}{} {}", "  ".repeat(line.level.saturating_sub(1)), line.number, line.name);
305            if line.current {
306                ui.label(text);
307            } else {
308                ui.label(egui::RichText::new(text).weak());
309            }
310            ui.label(&line.revision_label);
311            ui.label(&line.state);
312            ui.label(&line.uses_revision);
313            ui.label(format!("{} {}", line.quantity, line.unit));
314            ui.end_row();
315        }
316    });
317}
318