use super::client::{PlmClient, PlmError};
use crate::store::DocumentIdentity;
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct ServerBom {
pub part_id: String,
pub number: String,
pub name: String,
pub revision_id: String,
pub revision_label: String,
pub state: String,
pub flat: bool,
pub levels: usize,
pub lines: Vec<ServerLine>,
pub totals: Vec<CurrencyTotal>,
pub unpriced: usize,
pub warnings: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct ServerLine {
pub level: usize,
pub position: String,
pub find_number: String,
pub reference: String,
pub part_id: String,
pub number: String,
pub name: String,
pub revision_id: String,
pub revision_label: String,
pub state: String,
pub floating: bool,
pub category_path: String,
pub attributes: Vec<String>,
pub quantity: f64,
pub unit: String,
pub total: f64,
pub assembly: bool,
pub mpn: String,
pub manufacturer: String,
pub supplier: String,
pub spn: String,
pub currency: String,
pub unit_price: Option<f64>,
pub extended: Option<f64>,
pub costed: bool,
pub warnings: Vec<String>,
pub flags: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct CurrencyTotal {
pub currency: String,
pub total: f64,
pub lines: usize,
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct WhereUsed {
pub part_id: String,
pub number: String,
pub revision_label: String,
pub levels: usize,
pub lines: Vec<WhereUsedLine>,
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct WhereUsedLine {
pub level: usize,
pub part_id: String,
pub number: String,
pub name: String,
pub revision_id: String,
pub revision_label: String,
pub state: String,
pub current: bool,
pub uses_revision: String,
pub floating: bool,
pub quantity: f64,
pub unit: String,
pub find_number: String,
pub top: bool,
}
pub async fn fetch_bom(client: &PlmClient, part: &str, revision: &str, flat: bool) -> Result<ServerBom, PlmError> {
let path = format!("/api/parts/{part}/revisions/{revision}/bom?levels=0{}", if flat { "&flat=true" } else { "" });
json(client.call("GET", &path, None).await?, &path)
}
pub async fn where_used(client: &PlmClient, part: &str, revision: Option<&str>) -> Result<WhereUsed, PlmError> {
let path = match revision {
Some(revision) => format!("/api/parts/{part}/where-used?levels=0&revision={revision}"),
None => format!("/api/parts/{part}/where-used?levels=0"),
};
json(client.call("GET", &path, None).await?, &path)
}
pub async fn patch_attribute(client: &PlmClient, part: &str, key: &str, value: &Value) -> Result<(), PlmError> {
let body = json!({ "attributes": { attribute_key(key): value } });
client
.call("PATCH", &format!("/api/parts/{part}"), Some(body.to_string().into_bytes()))
.await
.map(|_| ())
}
pub fn attribute_key(field: &str) -> String {
field.trim().to_ascii_lowercase()
}
fn json<T: for<'de> Deserialize<'de>>(response: super::PlmResponse, what: &str) -> Result<T, PlmError> {
serde_json::from_slice(&response.body).map_err(|e| PlmError::Malformed(format!("{what}: {e}")))
}
pub fn revision_of_document(name: &str) -> Option<(String, String)> {
let key = name.trim_start_matches('/');
let key = key.strip_prefix("models/").unwrap_or(key);
let key = key.strip_suffix(".nbrep").unwrap_or(key);
match DocumentIdentity::parse_revision_key(key)? {
DocumentIdentity::Revision { part, revision } => Some((part, revision)),
DocumentIdentity::Path(_) => None,
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct DocumentLine {
pub level: usize,
pub position: String,
pub part_id: String,
pub revision_id: String,
pub quantity: f64,
pub total: f64,
}
pub fn document_bom(document: &Value) -> Result<Vec<DocumentLine>, Vec<String>> {
let mut lines = Vec::new();
let mut refusals = Vec::new();
descend(document, 1, "", 1.0, &mut lines, &mut refusals);
if refusals.is_empty() {
Ok(lines)
} else {
Err(refusals)
}
}
fn descend(document: &Value, level: usize, prefix: &str, parent_total: f64, out: &mut Vec<DocumentLine>, refusals: &mut Vec<String>) {
let occurrences = super::uses::occurrences(document);
let list = super::uses::uses_lines(&occurrences, &super::uses::plm_identity);
for refusal in &list.refusals {
let at = if prefix.is_empty() { String::new() } else { format!(" (under {prefix})") };
refusals.push(format!("{}{at}: {}", refusal.occurrence, refusal.reason));
}
for (index, placed) in list.lines.iter().enumerate() {
let position = if prefix.is_empty() { format!("{}", index + 1) } else { format!("{prefix}.{}", index + 1) };
let quantity = f64::from(placed.line.quantity);
let total = parent_total * quantity;
out.push(DocumentLine {
level,
position: position.clone(),
part_id: placed.line.part.clone(),
revision_id: placed.line.revision.clone(),
quantity,
total,
});
let entry = placed
.occurrences
.first()
.and_then(|id| occurrences.iter().find(|o| &o.id == id))
.and_then(|o| document.get("partsLibrary")?.get(&o.part_name));
if let Some(child) = entry.and_then(|entry| entry.get("document")).filter(|d| d.is_object()) {
descend(child, level + 1, &position, total, out, refusals);
}
}
}
pub fn compare(document: &[DocumentLine], server: &[ServerLine]) -> Vec<String> {
let mut differences = Vec::new();
let named = |line: &ServerLine| {
if line.number.is_empty() {
line.part_id.clone()
} else {
format!("{} rev {}", line.number, line.revision_label)
}
};
for index in 0..document.len().max(server.len()) {
match (document.get(index), server.get(index)) {
(Some(doc), Some(srv)) => {
let mut says = Vec::new();
if doc.position != srv.position || doc.level != srv.level {
says.push(format!("the document has position {} (level {}), the PLM {} (level {})", doc.position, doc.level, srv.position, srv.level));
}
if doc.part_id != srv.part_id || doc.revision_id != srv.revision_id {
says.push(format!("the document places part {} revision {}", doc.part_id, doc.revision_id));
}
if (doc.quantity - srv.quantity).abs() > 1e-9 {
says.push(format!("quantity {} in the document, {} on the PLM", doc.quantity, srv.quantity));
}
if (doc.total - srv.total).abs() > 1e-9 {
says.push(format!("total {} in the document, {} on the PLM", doc.total, srv.total));
}
if !says.is_empty() {
differences.push(format!("line {} ({}): {}", srv.position, named(srv), says.join("; ")));
}
}
(Some(doc), None) => differences.push(format!(
"line {}: the document places part {} revision {}, which the PLM's BOM does not list",
doc.position, doc.part_id, doc.revision_id
)),
(None, Some(srv)) => differences.push(format!("line {} ({}): on the PLM's BOM, not in the document", srv.position, named(srv))),
(None, None) => {}
}
}
differences
}
pub fn show_where_used(ui: &mut eframe::egui::Ui, used: &WhereUsed) {
use eframe::egui;
if used.lines.is_empty() {
ui.label(format!("{} is used by no assembly.", used.number));
return;
}
let current = used.lines.iter().filter(|l| l.current).count();
ui.label(format!(
"{} is used in {} assembly revision{} ({} current, which a change still reaches):",
used.number,
used.lines.len(),
if used.lines.len() == 1 { "" } else { "s" },
current
));
egui::Grid::new(("where-used", &used.part_id)).striped(true).show(ui, |ui| {
for heading in ["Assembly", "Rev", "State", "Uses", "Qty"] {
ui.strong(heading);
}
ui.end_row();
for line in &used.lines {
let text = format!("{}{} {}", " ".repeat(line.level.saturating_sub(1)), line.number, line.name);
if line.current {
ui.label(text);
} else {
ui.label(egui::RichText::new(text).weak());
}
ui.label(&line.revision_label);
ui.label(&line.state);
ui.label(&line.uses_revision);
ui.label(format!("{} {}", line.quantity, line.unit));
ui.end_row();
}
});
}