use std::collections::BTreeMap;
use serde::Deserialize;
use serde_json::{json, Value};
use super::bake::{bake_document, verdict};
use super::client::{PlmClient, PlmError};
use crate::family_table::{
effective_row, member_document, part_number_problem, read_table, row_evaluation_problem, FamilyTable,
GenerateReport, RowOutcome,
};
pub const UNSAVED_FAMILY: &str = "the family has unsaved changes, and the server generates from its saved \
revision: save it first, or use Save and generate";
pub fn key_ids(key: &str) -> Option<(&str, &str)> {
let rest = key.strip_prefix("part/")?;
let (part, revision) = rest.split_once("/rev/")?;
(!part.is_empty() && !revision.is_empty() && !revision.contains('/')).then_some((part, revision))
}
pub fn revision_key(name: &str) -> Option<String> {
let relative = name.trim_start_matches('/');
let relative = relative.strip_prefix("models/").unwrap_or(relative);
let relative = crate::document_class::strip_class_extension(relative);
key_ids(relative).map(|_| relative.to_string())
}
pub fn family_file_name(number: &str) -> String {
format!("{}.fbrep", number.trim())
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct PartHead {
pub id: String,
pub number: String,
pub name: String,
pub document_class: String,
pub member_part_type: String,
pub category: String,
pub revisions: Vec<RevisionHead>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct RevisionHead {
pub id: String,
pub label: String,
pub family: Option<ServerStamp>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct ServerStamp {
pub family_part: String,
pub family_revision: String,
pub values_hash: String,
pub history_hash: String,
pub generated_hash: String,
}
pub async fn part_head(client: &PlmClient, part: &str) -> Result<PartHead, PlmError> {
let response = client.call("GET", &format!("/api/parts/{part}"), None).await?;
serde_json::from_slice(&response.body).map_err(|e| PlmError::Malformed(format!("/api/parts/{part}: {e}")))
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct ServerRow {
number: String,
status: String,
reason: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct ServerReport {
rows: Vec<ServerRow>,
}
pub async fn generate(client: &PlmClient, key: &str, family_json: &str) -> Result<GenerateReport, PlmError> {
let (family_part, family_revision) =
key_ids(key).ok_or_else(|| PlmError::Malformed(format!("`{key}` is not a PLM revision")))?;
let family: Value = serde_json::from_str(family_json)
.map_err(|e| PlmError::Malformed(format!("the family document does not parse: {e}")))?;
let head = part_head(client, family_part).await?;
let family_file = family_file_name(&head.number);
let table = read_table(family_json);
let (mut outcomes, sent) = prepare(&family, &family_file, &table);
if !sent.is_empty() {
let body = json!({ "family_revision": family_revision, "rows": sent });
let response = client
.call("POST", &format!("/api/parts/{family_part}/generate"), serde_json::to_vec(&body).ok())
.await?;
let report: ServerReport = serde_json::from_slice(&response.body)
.map_err(|e| PlmError::Malformed(format!("generate: {e}")))?;
let mut by_number: BTreeMap<String, ServerRow> =
report.rows.into_iter().map(|row| (row.number.trim().to_lowercase(), row)).collect();
for slot in outcomes.iter_mut() {
let Slot::Sent(part_number) = slot else { continue };
let row = by_number.remove(&part_number.trim().to_lowercase());
*slot = Slot::Done(match row {
Some(row) if row.status == "written" => RowOutcome::Written { part_number: part_number.clone() },
Some(row) if row.status == "skipped" => {
RowOutcome::Skipped { part_number: part_number.clone(), reason: row.reason }
}
Some(row) => RowOutcome::Failed { part_number: part_number.clone(), reason: row.reason },
None => RowOutcome::Failed {
part_number: part_number.clone(),
reason: "the server's report does not mention this row".into(),
},
});
}
}
Ok(GenerateReport {
rows: outcomes
.into_iter()
.map(|slot| match slot {
Slot::Done(outcome) => outcome,
Slot::Sent(part_number) => RowOutcome::Failed { part_number, reason: "not generated".into() },
})
.collect(),
})
}
enum Slot {
Done(RowOutcome),
Sent(String),
}
fn prepare(family: &Value, family_file: &str, table: &FamilyTable) -> (Vec<Slot>, Vec<Value>) {
let mut slots = Vec::new();
let mut sent = Vec::new();
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
for (index, raw) in table.rows.iter().enumerate() {
let part_number = raw.part_number.trim().to_string();
let fail = |reason: String| Slot::Done(RowOutcome::Failed { part_number: part_number.clone(), reason });
if let Some(problem) = part_number_problem(&part_number) {
slots.push(fail(problem));
continue;
}
let key = part_number.to_lowercase();
if let Some(first) = seen.get(&key) {
slots.push(fail(format!("the same part number as row {}", first + 1)));
continue;
}
seen.insert(key, index);
let row = effective_row(table, raw);
if let Some(problem) = row_evaluation_problem(family, &row) {
slots.push(fail(problem));
continue;
}
let member = member_document(family, family_file, &row);
let revision = if row.revision.trim().is_empty() { "the first revision" } else { row.revision.trim() };
match bake_document(&member.to_string()) {
Err(why) => {
slots.push(fail(format!("the member does not open: {why}")));
continue;
}
Ok(bake) => {
if let Err(sentence) = verdict(&part_number, revision, &bake) {
slots.push(fail(sentence));
continue;
}
}
}
sent.push(json!({
"number": part_number,
"revision": row.revision.trim(),
"description": row.description.trim(),
"values": row.values,
"document": member,
}));
slots.push(Slot::Sent(part_number));
}
(slots, sent)
}
pub fn stamp_agrees(document: &Value, server: &ServerStamp) -> bool {
crate::family_table::family_source(document)
.is_some_and(|app| app.values_hash == server.values_hash && app.history_hash == server.history_hash)
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct Member {
pub part_id: String,
pub number: String,
pub name: String,
pub revision_id: String,
pub revision_label: String,
pub lifecycle: String,
pub hand_edited: bool,
pub bake: Option<String>,
}
impl Member {
pub fn key(&self) -> Option<String> {
(!self.part_id.is_empty() && !self.revision_id.is_empty())
.then(|| format!("part/{}/rev/{}", self.part_id, self.revision_id))
}
pub fn not_placeable(&self) -> Option<String> {
if self.key().is_none() {
return Some(format!("{} has not been generated yet", self.number));
}
match self.bake.as_deref() {
Some("pending") | Some("claimed") => Some(format!("{} is waiting for its bake", self.number)),
Some("failed") => Some(format!("{} failed its bake", self.number)),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct FamilyViewRow {
part_number: String,
member: Option<Member>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct FamilyView {
pub number: String,
pub member_part_type: String,
pub member_part_type_mode: String,
rows: Vec<FamilyViewRow>,
}
impl FamilyView {
pub fn members(&self) -> Vec<Member> {
self.rows
.iter()
.map(|row| row.member.clone().unwrap_or_else(|| Member { number: row.part_number.clone(), ..Member::default() }))
.collect()
}
}
pub async fn family_view(client: &PlmClient, family_part: &str) -> Result<FamilyView, PlmError> {
let response = client.call("GET", &format!("/api/parts/{family_part}/family"), None).await?;
serde_json::from_slice(&response.body).map_err(|e| PlmError::Malformed(format!("family view: {e}")))
}
pub async fn set_member_part_type(client: &PlmClient, family_part: &str, part_type: &str) -> Result<(), PlmError> {
let body = serde_json::to_vec(&json!({ "member_part_type": part_type.trim() })).ok();
client.call("PATCH", &format!("/api/parts/{family_part}"), body).await.map(|_| ())
}
#[derive(Debug, Clone, PartialEq)]
pub struct SpunOut {
pub part_id: String,
pub number: String,
pub key: String,
}
pub async fn spin_out(
client: &PlmClient,
template_part: &str,
name: &str,
number: &str,
values: &BTreeMap<String, String>,
) -> Result<SpunOut, PlmError> {
let body = serde_json::to_vec(&json!({ "name": name, "number": number, "values": values })).ok();
let response = client.call("POST", &format!("/api/parts/{template_part}/spin-out"), body).await?;
let part: PartHead =
serde_json::from_slice(&response.body).map_err(|e| PlmError::Malformed(format!("spin-out: {e}")))?;
let revision = part.revisions.first().ok_or_else(|| PlmError::Malformed("spin-out: the part has no revision".into()))?;
Ok(SpunOut { key: format!("part/{}/rev/{}", part.id, revision.id), part_id: part.id, number: part.number })
}
pub type Attribute = crate::panels::plm_parts::Attribute;
pub fn keys_to_offer<'a>(schema: &'a [Attribute], table: &FamilyTable) -> Vec<&'a Attribute> {
schema
.iter()
.filter(|attribute| !table.columns.iter().any(|c| c.name.trim().eq_ignore_ascii_case(&attribute.key)))
.collect()
}
pub fn attribute_column_problems(schema: &[Attribute], table: &FamilyTable) -> Vec<String> {
let mut problems = Vec::new();
for column in &table.columns {
let Some(attribute) = schema.iter().find(|a| a.key.eq_ignore_ascii_case(column.name.trim())) else {
continue;
};
for row in &table.rows {
let Some(text) = row.values.get(&column.name).map(|v| v.trim()).filter(|v| !v.is_empty()) else {
continue;
};
let fits = match attribute.kind.as_str() {
"number" => text.parse::<f64>().is_ok_and(f64::is_finite),
"bool" => matches!(text.to_ascii_lowercase().as_str(), "true" | "yes" | "false" | "no"),
"enum" => attribute.values.iter().any(|v| v.eq_ignore_ascii_case(text)),
_ => true,
};
if !fits {
let wanted = match attribute.kind.as_str() {
"number" => "a number".to_string(),
"bool" => "yes or no".to_string(),
_ => format!("one of {}", attribute.values.join(", ")),
};
problems.push(format!(
"{}: `{}` is the catalog attribute {}, which takes {wanted}, not `{text}`; the server will fail this row",
row.part_number, column.name, attribute.name
));
}
}
}
problems
}
#[derive(Debug, Clone, PartialEq)]
pub struct HandEditPrompt {
pub message: String,
pub offer_open_family: bool,
pub family: String,
}
pub fn hand_edit_prompt(document: &Value, may_edit_family: bool) -> Option<HandEditPrompt> {
let stamp = crate::family_table::family_source(document)?;
let mut message = format!(
"{} was generated from the family {}. Saving an edit here makes it differ from its row, and the next \
Generate overwrites it. Save it as a new part instead",
stamp.part_number, stamp.family
);
message.push_str(if may_edit_family { ", or change the family and generate again." } else { "." });
Some(HandEditPrompt { message, offer_open_family: may_edit_family, family: stamp.family })
}