1use crate::store::DocumentIdentity;
25use serde_json::Value;
26
27pub const USE_FIELDS: [&str; 7] = [
30 "part",
31 "revision",
32 "quantity",
33 "unit",
34 "find_number",
35 "reference",
36 "notes",
37];
38
39#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct Occurrence {
42 pub id: String,
44 pub label: String,
48 pub part_name: String,
50 pub source_key: Option<String>,
53}
54
55pub fn occurrences(document: &Value) -> Vec<Occurrence> {
61 let library = document.get("partsLibrary");
62 document
63 .get("features")
64 .and_then(Value::as_array)
65 .into_iter()
66 .flatten()
67 .filter(|feature| {
68 matches!(
69 feature.get("type").and_then(Value::as_str),
70 Some("ACOMP") | Some("ASSEMBLY COMPONENT")
71 )
72 })
73 .map(|feature| {
74 let params = &feature["inputParams"];
75 let id = params["id"].as_str().unwrap_or_default().to_string();
76 let designator = params["bom"]["Reference_Designator"]
77 .as_str()
78 .map(str::trim)
79 .unwrap_or_default();
80 let part_name = params["partName"].as_str().unwrap_or_default().to_string();
81 let source_key = library
82 .and_then(|library| library.get(&part_name))
83 .map(|entry| entry["sourceKey"].as_str().unwrap_or_default().to_string());
84 Occurrence {
85 label: if designator.is_empty() { id.clone() } else { designator.to_string() },
86 id,
87 part_name,
88 source_key,
89 }
90 })
91 .collect()
92}
93
94#[derive(Clone, Debug, PartialEq, serde::Serialize)]
97pub struct UseLine {
98 pub part: String,
100 pub revision: String,
102 pub quantity: u32,
104 pub unit: String,
105 pub find_number: String,
106 pub reference: String,
108 pub notes: String,
109}
110
111#[derive(Clone, Debug, PartialEq)]
113pub struct PlacedLine {
114 pub line: UseLine,
115 pub occurrences: Vec<String>,
116}
117
118#[derive(Clone, Debug, PartialEq, Eq)]
121pub struct Refusal {
122 pub occurrence: String,
123 pub reason: String,
124}
125
126#[derive(Clone, Debug, Default, PartialEq)]
129pub struct UsesList {
130 pub lines: Vec<PlacedLine>,
131 pub refusals: Vec<Refusal>,
132}
133
134impl UsesList {
135 pub fn body(&self) -> Result<Value, String> {
138 if !self.refusals.is_empty() {
139 let named: Vec<String> = self
140 .refusals
141 .iter()
142 .map(|refusal| format!("{}: {}", refusal.occurrence, refusal.reason))
143 .collect();
144 return Err(format!(
145 "the uses list was not published — {} occurrence{} name{} no PLM revision: {}",
146 self.refusals.len(),
147 if self.refusals.len() == 1 { "" } else { "s" },
148 if self.refusals.len() == 1 { "s" } else { "" },
149 named.join("; ")
150 ));
151 }
152 let lines: Vec<&UseLine> = self.lines.iter().map(|placed| &placed.line).collect();
153 Ok(serde_json::json!({ "uses": lines }))
154 }
155}
156
157pub fn uses_lines(
166 occurrences: &[Occurrence],
167 resolve: &dyn Fn(&str) -> DocumentIdentity,
168) -> UsesList {
169 let mut list = UsesList::default();
170 let mut labels: Vec<Vec<String>> = Vec::new();
171 for occurrence in occurrences {
172 let refuse = |reason: String| Refusal {
173 occurrence: occurrence.label.clone(),
174 reason,
175 };
176 let key = match &occurrence.source_key {
177 None => {
178 list.refusals.push(refuse(format!(
179 "the document has no parts-library entry '{}'",
180 occurrence.part_name
181 )));
182 continue;
183 }
184 Some(key) if key.is_empty() => {
185 list.refusals.push(refuse(format!(
186 "'{}' is embedded in this document and was never a part — insert it from the PLM",
187 occurrence.part_name
188 )));
189 continue;
190 }
191 Some(key) => key,
192 };
193 let (part, revision) = match resolve(key) {
194 DocumentIdentity::Revision { part, revision } => (part, revision),
195 DocumentIdentity::Path(_) => {
196 list.refusals.push(refuse(format!(
197 "'{}' is a local component without a PLM revision — import it into PLM first",
198 occurrence.part_name
199 )));
200 continue;
201 }
202 };
203 match list
204 .lines
205 .iter()
206 .position(|placed| placed.line.part == part && placed.line.revision == revision)
207 {
208 Some(index) => {
209 list.lines[index].line.quantity += 1;
210 list.lines[index].occurrences.push(occurrence.id.clone());
211 labels[index].push(occurrence.label.clone());
212 }
213 None => {
214 list.lines.push(PlacedLine {
215 line: UseLine {
216 part,
217 revision,
218 quantity: 1,
219 unit: "each".into(),
220 find_number: String::new(),
221 reference: String::new(),
222 notes: String::new(),
223 },
224 occurrences: vec![occurrence.id.clone()],
225 });
226 labels.push(vec![occurrence.label.clone()]);
227 }
228 }
229 }
230 for (placed, labels) in list.lines.iter_mut().zip(labels) {
231 placed.line.reference = labels.join(", ");
232 }
233 list
234}
235
236#[derive(Clone, Debug, PartialEq)]
241pub struct ListedUse {
242 pub part: String,
243 pub revision: String,
244 pub quantity: f64,
245}
246
247#[derive(Clone, Debug, PartialEq)]
250pub struct Repoint {
251 pub occurrences: Vec<String>,
253 pub from: (String, String),
255 pub to: (String, String),
257}
258
259#[derive(Clone, Debug, Default, PartialEq)]
261pub struct Disagreement {
262 pub repoints: Vec<Repoint>,
265 pub unlisted: Vec<PlacedLine>,
267 pub unplaced: Vec<ListedUse>,
269}
270
271impl Disagreement {
272 pub fn is_empty(&self) -> bool {
273 self.repoints.is_empty() && self.unlisted.is_empty() && self.unplaced.is_empty()
274 }
275}
276
277pub fn disagreements(listed: &[ListedUse], document: &UsesList) -> Disagreement {
289 let mut unplaced: Vec<ListedUse> = listed
290 .iter()
291 .filter(|listed| {
292 !document
293 .lines
294 .iter()
295 .any(|placed| placed.line.part == listed.part && placed.line.revision == listed.revision)
296 })
297 .cloned()
298 .collect();
299 let mut unlisted: Vec<PlacedLine> = document
300 .lines
301 .iter()
302 .filter(|placed| {
303 !listed
304 .iter()
305 .any(|listed| listed.part == placed.line.part && listed.revision == placed.line.revision)
306 })
307 .cloned()
308 .collect();
309 let mut repoints = Vec::new();
310
311 let repoint = |placed: &PlacedLine, listed: &ListedUse| Repoint {
312 occurrences: placed.occurrences.clone(),
313 from: (placed.line.part.clone(), placed.line.revision.clone()),
314 to: (listed.part.clone(), listed.revision.clone()),
315 };
316
317 let mut index = 0;
319 while index < unlisted.len() {
320 let part = unlisted[index].line.part.clone();
321 let same_part_listed: Vec<usize> = (0..unplaced.len())
322 .filter(|&i| unplaced[i].part == part)
323 .collect();
324 let same_part_placed = unlisted.iter().filter(|placed| placed.line.part == part).count();
325 if same_part_listed.len() == 1 && same_part_placed == 1 {
326 let listed = unplaced.remove(same_part_listed[0]);
327 let placed = unlisted.remove(index);
328 repoints.push(repoint(&placed, &listed));
329 } else {
330 index += 1;
331 }
332 }
333 if unlisted.len() == 1
335 && unplaced.len() == 1
336 && unplaced[0].quantity == f64::from(unlisted[0].line.quantity)
337 {
338 let listed = unplaced.remove(0);
339 let placed = unlisted.remove(0);
340 repoints.push(repoint(&placed, &listed));
341 }
342 Disagreement {
343 repoints,
344 unlisted,
345 unplaced,
346 }
347}
348
349pub fn repoint(document: &mut Value, repoint: &Repoint, resolve: &dyn Fn(&str) -> DocumentIdentity) -> usize {
359 let to_key = DocumentIdentity::Revision {
360 part: repoint.to.0.clone(),
361 revision: repoint.to.1.clone(),
362 }
363 .key();
364 let Some(library) = document.get_mut("partsLibrary").and_then(Value::as_object_mut) else {
365 return 0;
366 };
367 let mut moved = 0;
368 for entry in library.values_mut() {
369 let key = entry.get("sourceKey").and_then(Value::as_str).unwrap_or_default().to_string();
370 if key.is_empty() {
371 continue;
372 }
373 if let DocumentIdentity::Revision { part, revision } = resolve(&key) {
374 if (part, revision) == repoint.from {
375 entry["sourceKey"] = Value::String(to_key.clone());
376 moved += 1;
377 }
378 }
379 }
380 moved
381}
382
383fn revision_of(key: &str) -> Result<(String, String), String> {
388 match DocumentIdentity::parse_revision_key(key) {
389 Some(DocumentIdentity::Revision { part, revision }) => Ok((part, revision)),
390 _ => Err(format!("'{key}' is not a PLM revision — nothing to publish")),
391 }
392}
393
394pub fn revision_key_in(name: &str) -> Option<String> {
399 let key = name.strip_prefix("/models/").unwrap_or(name);
400 if !key.starts_with("part/") {
401 return None;
402 }
403 let key = match key.rsplit_once('.') {
404 Some((stem, extension)) if !extension.contains('/') => stem,
405 _ => key,
406 };
407 DocumentIdentity::parse_revision_key(key).map(|identity| identity.key())
408}
409
410pub fn plm_identity(key: &str) -> DocumentIdentity {
414 revision_key_in(key)
415 .and_then(|key| DocumentIdentity::parse_revision_key(&key))
416 .unwrap_or_else(|| DocumentIdentity::Path(key.to_string()))
417}
418
419pub async fn publish_uses(
425 client: &crate::plm::client::PlmClient,
426 key: &str,
427 document: &Value,
428) -> Result<UsesList, String> {
429 let (part, revision) = revision_of(key)?;
430 let list = uses_lines(&occurrences(document), &plm_identity);
431 let mut body = list.body()?;
432 let placements: Vec<Value> = list.lines.iter().flat_map(|placed| placed.occurrences.iter().map(|id| {
433 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!({}));
434 serde_json::json!({"id":id,"part":placed.line.part,"revision":placed.line.revision,"attributes":attributes})
435 })).collect();
436 body["occurrences"] = serde_json::json!(placements);
437 client
438 .call(
439 "PUT",
440 &format!("{}/uses", crate::plm::identity::revision_path(&part, &revision)),
441 Some(serde_json::to_vec(&body).unwrap_or_default()),
442 )
443 .await
444 .map_err(|error| error.to_string())?;
445 Ok(list)
446}
447
448pub fn publishes_on_save(document: &Value) -> bool {
453 !occurrences(document).is_empty() || document.get("partsLibrary").is_some_and(|l| l.as_object().is_some_and(|m| !m.is_empty()))
454}
455
456pub(crate) fn publish_on_save() -> crate::plm::backend::SavedHook {
461 std::rc::Rc::new(|client, key, value| {
462 Box::pin(async move {
463 let Ok(document) = serde_json::from_str::<Value>(&value) else {
464 return Ok(());
465 };
466 if !publishes_on_save(&document) {
467 return Ok(());
468 }
469 if client.me().is_some_and(|me| me.has("derived-uses")) {
472 return Ok(());
473 }
474 publish_uses(&client, &key, &document)
475 .await
476 .map(|_| ())
477 .map_err(|refusal| format!("the document was saved, but its parts list was not published: {refusal}"))
478 })
479 })
480}
481
482pub async fn release(
493 client: &crate::plm::client::PlmClient,
494 key: &str,
495 document: &Value,
496) -> Result<Vec<String>, String> {
497 let (part, revision) = revision_of(key)?;
498 if !occurrences(document).is_empty() {
499 publish_uses(client, key, document).await?;
500 }
501 client
502 .call(
503 "POST",
504 &format!("{}/checkin", crate::plm::identity::revision_path(&part, &revision)),
505 Some(b"{}".to_vec()),
506 )
507 .await
508 .map_err(|error| error.to_string())?;
509 let response = client
510 .call(
511 "POST",
512 &format!("{}/state", crate::plm::identity::revision_path(&part, &revision)),
513 Some(serde_json::to_vec(&serde_json::json!({ "to": "released" })).unwrap_or_default()),
514 )
515 .await
516 .map_err(|error| error.to_string())?;
517 let answer: Value = serde_json::from_slice(&response.body).unwrap_or_default();
518 Ok(answer["warnings"]
519 .as_array()
520 .into_iter()
521 .flatten()
522 .map(|warning| match warning {
523 Value::String(text) => text.clone(),
524 other => other.to_string(),
525 })
526 .collect())
527}
528
529pub async fn listed_uses(client: &crate::plm::client::PlmClient, key: &str) -> Result<Vec<ListedUse>, String> {
533 let (part, revision) = revision_of(key)?;
534 let response = client
535 .call("GET", &format!("{}/uses", crate::plm::identity::revision_path(&part, &revision)), None)
536 .await
537 .map_err(|error| error.to_string())?;
538 let answer: Value = serde_json::from_slice(&response.body).map_err(|error| error.to_string())?;
539 Ok(answer["uses"]
540 .as_array()
541 .into_iter()
542 .flatten()
543 .map(|line| ListedUse {
544 part: line["part"].as_str().unwrap_or_default().to_string(),
545 revision: line["revision"].as_str().unwrap_or_default().to_string(),
546 quantity: line["quantity"].as_f64().unwrap_or(0.0),
547 })
548 .collect())
549}
550
551pub async fn check_on_open(
555 client: &crate::plm::client::PlmClient,
556 key: &str,
557 document: &Value,
558) -> Result<Disagreement, String> {
559 let listed = listed_uses(client, key).await?;
560 Ok(disagreements(&listed, &uses_lines(&occurrences(document), &plm_identity)))
561}
562