use crate::store::DocumentIdentity;
use serde_json::Value;
pub const USE_FIELDS: [&str; 7] = [
"part",
"revision",
"quantity",
"unit",
"find_number",
"reference",
"notes",
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Occurrence {
pub id: String,
pub label: String,
pub part_name: String,
pub source_key: Option<String>,
}
pub fn occurrences(document: &Value) -> Vec<Occurrence> {
let library = document.get("partsLibrary");
document
.get("features")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|feature| {
matches!(
feature.get("type").and_then(Value::as_str),
Some("ACOMP") | Some("ASSEMBLY COMPONENT")
)
})
.map(|feature| {
let params = &feature["inputParams"];
let id = params["id"].as_str().unwrap_or_default().to_string();
let designator = params["bom"]["Reference_Designator"]
.as_str()
.map(str::trim)
.unwrap_or_default();
let part_name = params["partName"].as_str().unwrap_or_default().to_string();
let source_key = library
.and_then(|library| library.get(&part_name))
.map(|entry| entry["sourceKey"].as_str().unwrap_or_default().to_string());
Occurrence {
label: if designator.is_empty() { id.clone() } else { designator.to_string() },
id,
part_name,
source_key,
}
})
.collect()
}
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct UseLine {
pub part: String,
pub revision: String,
pub quantity: u32,
pub unit: String,
pub find_number: String,
pub reference: String,
pub notes: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PlacedLine {
pub line: UseLine,
pub occurrences: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Refusal {
pub occurrence: String,
pub reason: String,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct UsesList {
pub lines: Vec<PlacedLine>,
pub refusals: Vec<Refusal>,
}
impl UsesList {
pub fn body(&self) -> Result<Value, String> {
if !self.refusals.is_empty() {
let named: Vec<String> = self
.refusals
.iter()
.map(|refusal| format!("{}: {}", refusal.occurrence, refusal.reason))
.collect();
return Err(format!(
"the uses list was not published — {} occurrence{} name{} no PLM revision: {}",
self.refusals.len(),
if self.refusals.len() == 1 { "" } else { "s" },
if self.refusals.len() == 1 { "s" } else { "" },
named.join("; ")
));
}
let lines: Vec<&UseLine> = self.lines.iter().map(|placed| &placed.line).collect();
Ok(serde_json::json!({ "uses": lines }))
}
}
pub fn uses_lines(
occurrences: &[Occurrence],
resolve: &dyn Fn(&str) -> DocumentIdentity,
) -> UsesList {
let mut list = UsesList::default();
let mut labels: Vec<Vec<String>> = Vec::new();
for occurrence in occurrences {
let refuse = |reason: String| Refusal {
occurrence: occurrence.label.clone(),
reason,
};
let key = match &occurrence.source_key {
None => {
list.refusals.push(refuse(format!(
"the document has no parts-library entry '{}'",
occurrence.part_name
)));
continue;
}
Some(key) if key.is_empty() => {
list.refusals.push(refuse(format!(
"'{}' is embedded in this document and was never a part — insert it from the PLM",
occurrence.part_name
)));
continue;
}
Some(key) => key,
};
let (part, revision) = match resolve(key) {
DocumentIdentity::Revision { part, revision } => (part, revision),
DocumentIdentity::Path(_) => {
list.refusals.push(refuse(format!(
"'{}' is a local component without a PLM revision — import it into PLM first",
occurrence.part_name
)));
continue;
}
};
match list
.lines
.iter()
.position(|placed| placed.line.part == part && placed.line.revision == revision)
{
Some(index) => {
list.lines[index].line.quantity += 1;
list.lines[index].occurrences.push(occurrence.id.clone());
labels[index].push(occurrence.label.clone());
}
None => {
list.lines.push(PlacedLine {
line: UseLine {
part,
revision,
quantity: 1,
unit: "each".into(),
find_number: String::new(),
reference: String::new(),
notes: String::new(),
},
occurrences: vec![occurrence.id.clone()],
});
labels.push(vec![occurrence.label.clone()]);
}
}
}
for (placed, labels) in list.lines.iter_mut().zip(labels) {
placed.line.reference = labels.join(", ");
}
list
}
#[derive(Clone, Debug, PartialEq)]
pub struct ListedUse {
pub part: String,
pub revision: String,
pub quantity: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Repoint {
pub occurrences: Vec<String>,
pub from: (String, String),
pub to: (String, String),
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Disagreement {
pub repoints: Vec<Repoint>,
pub unlisted: Vec<PlacedLine>,
pub unplaced: Vec<ListedUse>,
}
impl Disagreement {
pub fn is_empty(&self) -> bool {
self.repoints.is_empty() && self.unlisted.is_empty() && self.unplaced.is_empty()
}
}
pub fn disagreements(listed: &[ListedUse], document: &UsesList) -> Disagreement {
let mut unplaced: Vec<ListedUse> = listed
.iter()
.filter(|listed| {
!document
.lines
.iter()
.any(|placed| placed.line.part == listed.part && placed.line.revision == listed.revision)
})
.cloned()
.collect();
let mut unlisted: Vec<PlacedLine> = document
.lines
.iter()
.filter(|placed| {
!listed
.iter()
.any(|listed| listed.part == placed.line.part && listed.revision == placed.line.revision)
})
.cloned()
.collect();
let mut repoints = Vec::new();
let repoint = |placed: &PlacedLine, listed: &ListedUse| Repoint {
occurrences: placed.occurrences.clone(),
from: (placed.line.part.clone(), placed.line.revision.clone()),
to: (listed.part.clone(), listed.revision.clone()),
};
let mut index = 0;
while index < unlisted.len() {
let part = unlisted[index].line.part.clone();
let same_part_listed: Vec<usize> = (0..unplaced.len())
.filter(|&i| unplaced[i].part == part)
.collect();
let same_part_placed = unlisted.iter().filter(|placed| placed.line.part == part).count();
if same_part_listed.len() == 1 && same_part_placed == 1 {
let listed = unplaced.remove(same_part_listed[0]);
let placed = unlisted.remove(index);
repoints.push(repoint(&placed, &listed));
} else {
index += 1;
}
}
if unlisted.len() == 1
&& unplaced.len() == 1
&& unplaced[0].quantity == f64::from(unlisted[0].line.quantity)
{
let listed = unplaced.remove(0);
let placed = unlisted.remove(0);
repoints.push(repoint(&placed, &listed));
}
Disagreement {
repoints,
unlisted,
unplaced,
}
}
pub fn repoint(document: &mut Value, repoint: &Repoint, resolve: &dyn Fn(&str) -> DocumentIdentity) -> usize {
let to_key = DocumentIdentity::Revision {
part: repoint.to.0.clone(),
revision: repoint.to.1.clone(),
}
.key();
let Some(library) = document.get_mut("partsLibrary").and_then(Value::as_object_mut) else {
return 0;
};
let mut moved = 0;
for entry in library.values_mut() {
let key = entry.get("sourceKey").and_then(Value::as_str).unwrap_or_default().to_string();
if key.is_empty() {
continue;
}
if let DocumentIdentity::Revision { part, revision } = resolve(&key) {
if (part, revision) == repoint.from {
entry["sourceKey"] = Value::String(to_key.clone());
moved += 1;
}
}
}
moved
}
fn revision_of(key: &str) -> Result<(String, String), String> {
match DocumentIdentity::parse_revision_key(key) {
Some(DocumentIdentity::Revision { part, revision }) => Ok((part, revision)),
_ => Err(format!("'{key}' is not a PLM revision — nothing to publish")),
}
}
pub fn revision_key_in(name: &str) -> Option<String> {
let key = name.strip_prefix("/models/").unwrap_or(name);
if !key.starts_with("part/") {
return None;
}
let key = match key.rsplit_once('.') {
Some((stem, extension)) if !extension.contains('/') => stem,
_ => key,
};
DocumentIdentity::parse_revision_key(key).map(|identity| identity.key())
}
pub fn plm_identity(key: &str) -> DocumentIdentity {
revision_key_in(key)
.and_then(|key| DocumentIdentity::parse_revision_key(&key))
.unwrap_or_else(|| DocumentIdentity::Path(key.to_string()))
}
pub async fn publish_uses(
client: &crate::plm::client::PlmClient,
key: &str,
document: &Value,
) -> Result<UsesList, String> {
let (part, revision) = revision_of(key)?;
let list = uses_lines(&occurrences(document), &plm_identity);
let mut body = list.body()?;
let placements: Vec<Value> = list.lines.iter().flat_map(|placed| placed.occurrences.iter().map(|id| {
let attributes = document["features"].as_array().and_then(|features|features.iter().find(|f|f["inputParams"]["id"].as_str()==Some(id))).map(|f|f["inputParams"]["bom"].clone()).filter(Value::is_object).unwrap_or_else(||serde_json::json!({}));
serde_json::json!({"id":id,"part":placed.line.part,"revision":placed.line.revision,"attributes":attributes})
})).collect();
body["occurrences"] = serde_json::json!(placements);
client
.call(
"PUT",
&format!("{}/uses", crate::plm::identity::revision_path(&part, &revision)),
Some(serde_json::to_vec(&body).unwrap_or_default()),
)
.await
.map_err(|error| error.to_string())?;
Ok(list)
}
pub fn publishes_on_save(document: &Value) -> bool {
!occurrences(document).is_empty() || document.get("partsLibrary").is_some_and(|l| l.as_object().is_some_and(|m| !m.is_empty()))
}
pub(crate) fn publish_on_save() -> crate::plm::backend::SavedHook {
std::rc::Rc::new(|client, key, value| {
Box::pin(async move {
let Ok(document) = serde_json::from_str::<Value>(&value) else {
return Ok(());
};
if !publishes_on_save(&document) {
return Ok(());
}
publish_uses(&client, &key, &document)
.await
.map(|_| ())
.map_err(|refusal| format!("the document was saved, but its parts list was not published: {refusal}"))
})
})
}
pub async fn release(
client: &crate::plm::client::PlmClient,
key: &str,
document: &Value,
) -> Result<Vec<String>, String> {
let (part, revision) = revision_of(key)?;
if !occurrences(document).is_empty() {
publish_uses(client, key, document).await?;
}
client
.call(
"POST",
&format!("{}/checkin", crate::plm::identity::revision_path(&part, &revision)),
Some(b"{}".to_vec()),
)
.await
.map_err(|error| error.to_string())?;
let response = client
.call(
"POST",
&format!("{}/state", crate::plm::identity::revision_path(&part, &revision)),
Some(serde_json::to_vec(&serde_json::json!({ "to": "released" })).unwrap_or_default()),
)
.await
.map_err(|error| error.to_string())?;
let answer: Value = serde_json::from_slice(&response.body).unwrap_or_default();
Ok(answer["warnings"]
.as_array()
.into_iter()
.flatten()
.map(|warning| match warning {
Value::String(text) => text.clone(),
other => other.to_string(),
})
.collect())
}
pub async fn listed_uses(client: &crate::plm::client::PlmClient, key: &str) -> Result<Vec<ListedUse>, String> {
let (part, revision) = revision_of(key)?;
let response = client
.call("GET", &format!("{}/uses", crate::plm::identity::revision_path(&part, &revision)), None)
.await
.map_err(|error| error.to_string())?;
let answer: Value = serde_json::from_slice(&response.body).map_err(|error| error.to_string())?;
Ok(answer["uses"]
.as_array()
.into_iter()
.flatten()
.map(|line| ListedUse {
part: line["part"].as_str().unwrap_or_default().to_string(),
revision: line["revision"].as_str().unwrap_or_default().to_string(),
quantity: line["quantity"].as_f64().unwrap_or(0.0),
})
.collect())
}
pub async fn check_on_open(
client: &crate::plm::client::PlmClient,
key: &str,
document: &Value,
) -> Result<Disagreement, String> {
let listed = listed_uses(client, key).await?;
Ok(disagreements(&listed, &uses_lines(&occurrences(document), &plm_identity)))
}