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(path) => {
196 list.refusals.push(refuse(format!(
197 "'{}' was inserted from the file '{path}', not a PLM revision — import it 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 body = list.body()?;
432 client
433 .call(
434 "PUT",
435 &format!("/api/parts/{part}/revisions/{revision}/uses"),
436 Some(serde_json::to_vec(&body).unwrap_or_default()),
437 )
438 .await
439 .map_err(|error| error.to_string())?;
440 Ok(list)
441}
442
443pub fn publishes_on_save(document: &Value) -> bool {
448 !occurrences(document).is_empty() || document.get("partsLibrary").is_some_and(|l| l.as_object().is_some_and(|m| !m.is_empty()))
449}
450
451pub(crate) fn publish_on_save() -> crate::plm::backend::SavedHook {
456 std::rc::Rc::new(|client, key, value| {
457 Box::pin(async move {
458 let Ok(document) = serde_json::from_str::<Value>(&value) else {
459 return Ok(());
460 };
461 if !publishes_on_save(&document) {
462 return Ok(());
463 }
464 publish_uses(&client, &key, &document)
465 .await
466 .map(|_| ())
467 .map_err(|refusal| format!("the document was saved, but its parts list was not published: {refusal}"))
468 })
469 })
470}
471
472pub async fn release(
483 client: &crate::plm::client::PlmClient,
484 key: &str,
485 document: &Value,
486) -> Result<Vec<String>, String> {
487 let (part, revision) = revision_of(key)?;
488 if !occurrences(document).is_empty() {
489 publish_uses(client, key, document).await?;
490 }
491 client
492 .call(
493 "POST",
494 &format!("/api/parts/{part}/revisions/{revision}/checkin"),
495 Some(b"{}".to_vec()),
496 )
497 .await
498 .map_err(|error| error.to_string())?;
499 let response = client
500 .call(
501 "POST",
502 &format!("/api/parts/{part}/revisions/{revision}/state"),
503 Some(serde_json::to_vec(&serde_json::json!({ "to": "released" })).unwrap_or_default()),
504 )
505 .await
506 .map_err(|error| error.to_string())?;
507 let answer: Value = serde_json::from_slice(&response.body).unwrap_or_default();
508 Ok(answer["warnings"]
509 .as_array()
510 .into_iter()
511 .flatten()
512 .map(|warning| match warning {
513 Value::String(text) => text.clone(),
514 other => other.to_string(),
515 })
516 .collect())
517}
518
519pub async fn listed_uses(client: &crate::plm::client::PlmClient, key: &str) -> Result<Vec<ListedUse>, String> {
523 let (part, revision) = revision_of(key)?;
524 let response = client
525 .call("GET", &format!("/api/parts/{part}/revisions/{revision}/uses"), None)
526 .await
527 .map_err(|error| error.to_string())?;
528 let answer: Value = serde_json::from_slice(&response.body).map_err(|error| error.to_string())?;
529 Ok(answer["uses"]
530 .as_array()
531 .into_iter()
532 .flatten()
533 .map(|line| ListedUse {
534 part: line["part"].as_str().unwrap_or_default().to_string(),
535 revision: line["revision"].as_str().unwrap_or_default().to_string(),
536 quantity: line["quantity"].as_f64().unwrap_or(0.0),
537 })
538 .collect())
539}
540
541pub async fn check_on_open(
545 client: &crate::plm::client::PlmClient,
546 key: &str,
547 document: &Value,
548) -> Result<Disagreement, String> {
549 let listed = listed_uses(client, key).await?;
550 Ok(disagreements(&listed, &uses_lines(&occurrences(document), &plm_identity)))
551}
552