use std::collections::BTreeMap;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::{FacilitySnapshot, ManufacturerInfo, MaterialEntry, PassportId, PassportRef};
use crate::catalog::Granularity;
use crate::compliance::ComplianceResult;
use crate::instrument::InstrumentRef;
use crate::seal::SealedEnvelope;
use crate::{
lint::LintResult,
product_group::{CarbonFootprint, ProductGroup, ProductGroupData, RepairabilityScore},
status::PassportStatus,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Passport {
pub id: PassportId,
pub batch_id: Option<String>,
pub product_name: String,
pub product_group: ProductGroup,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub applicable_instruments: Vec<InstrumentRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub granularity: Option<Granularity>,
pub manufacturer: ManufacturerInfo,
pub materials: Vec<MaterialEntry>,
pub co2e_per_unit: Option<CarbonFootprint>,
pub repairability_score: Option<RepairabilityScore>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compliance_result: Option<ComplianceResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lint_result: Option<LintResult>,
pub product_group_data: Option<ProductGroupData>,
pub status: PassportStatus,
pub qr_code_url: Option<String>,
pub jws_signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_jws_signature: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub disclosure_signatures: BTreeMap<String, String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub published_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placed_on_market_date: Option<NaiveDate>,
pub schema_version: String,
#[serde(default)]
pub retention_locked: bool,
#[serde(default = "default_version")]
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes_id: Option<PassportId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_passport_ref: Option<PassportRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub component_refs: Vec<PassportRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention_until: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub product_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commodity_code: Option<crate::identifier::CommodityCode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operator_identifier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub facility: Option<FacilitySnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seal: Option<SealedEnvelope>,
}
fn default_version() -> u32 {
1
}
pub const PASSPORT_WIRE_KEYS: &[&str] = &[
"id",
"batchId",
"productName",
"productGroup",
"applicableInstruments",
"granularity",
"manufacturer",
"materials",
"co2ePerUnit",
"repairabilityScore",
"complianceResult",
"lintResult",
"productGroupData",
"status",
"qrCodeUrl",
"jwsSignature",
"publicJwsSignature",
"disclosureSignatures",
"createdAt",
"updatedAt",
"publishedAt",
"placedOnMarketDate",
"schemaVersion",
"retentionLocked",
"version",
"supersedesId",
"parentPassportRef",
"componentRefs",
"retentionUntil",
"productId",
"commodityCode",
"operatorIdentifier",
"facility",
"seal",
];
pub const PASSPORT_PROOF_FIELDS: &[&str] = &[
"jwsSignature",
"publicJwsSignature",
"disclosureSignatures",
"seal",
];
pub const RETENTION_MUTABLE_FIELDS: &[&str] = &[
"status",
"publishedAt",
"retentionLocked",
"updatedAt",
"jwsSignature",
"publicJwsSignature",
"disclosureSignatures",
"seal",
"qrCodeUrl",
"lintResult",
];
fn stored_product_group_version(doc: &serde_json::Value) -> Option<(String, String)> {
let product_group_data = doc.get("productGroupData")?;
let tag = product_group_data.get("productGroup")?.as_str()?;
let product_group_key = ProductGroup::from_wire_tag(tag).catalog_key().to_owned();
let recorded = doc.get("schemaVersion")?.as_str()?.to_owned();
Some((product_group_key, recorded))
}
impl Passport {
pub fn from_stored(
doc: serde_json::Value,
lenses: &crate::schemas::lens::LensRegistry,
catalog: &crate::catalog::ProductGroupCatalog,
) -> Result<Self, crate::error::dpp::DppError> {
use crate::error::dpp::DppError;
use serde::Deserialize as _;
let direct_err = match Self::deserialize(&doc) {
Ok(passport) => return Ok(passport),
Err(e) => e,
};
let Some((product_group_key, recorded)) = stored_product_group_version(&doc) else {
return Err(DppError::Serialisation(direct_err.to_string()));
};
let Some(current) = catalog.current_schema_version(&product_group_key) else {
return Err(DppError::Serialisation(direct_err.to_string()));
};
if recorded == current {
return Err(DppError::Serialisation(direct_err.to_string()));
}
let product_group_data = doc["productGroupData"].clone();
let derived = lenses.upcast_str_toward(
&product_group_key,
&product_group_data,
&recorded,
current,
)?;
let mut doc = doc;
doc["productGroupData"] = derived.data;
serde_json::from_value(doc).map_err(|e| DppError::Serialisation(e.to_string()))
}
pub fn validate(&self) -> Result<(), crate::error::dpp::DppError> {
use crate::field_error::{FieldError, ValidationErrors};
let mut errors: Vec<FieldError> = Vec::new();
if self.product_name.trim().is_empty() {
errors.push(FieldError {
field: "/productName".to_owned(),
message: "product_name must not be empty".to_owned(),
});
}
if self.manufacturer.name.trim().is_empty() {
errors.push(FieldError {
field: "/manufacturer/name".to_owned(),
message: "manufacturer.name must not be empty".to_owned(),
});
}
if self.manufacturer.address.trim().is_empty() {
errors.push(FieldError {
field: "/manufacturer/address".to_owned(),
message: "manufacturer.address must not be empty".to_owned(),
});
}
if self.schema_version.parse::<semver::Version>().is_err() {
errors.push(FieldError {
field: "/schemaVersion".to_owned(),
message: "schema_version must be valid semver (e.g. 1.0.0)".to_owned(),
});
}
if let Some(ref cf) = self.co2e_per_unit
&& cf.value_kg < 0.0
{
errors.push(FieldError {
field: "/co2ePerUnit".to_owned(),
message: "co2e_per_unit must not be negative".to_owned(),
});
}
if let Some(ref rs) = self.repairability_score
&& !(0.0..=10.0).contains(&rs.overall)
{
errors.push(FieldError {
field: "/repairabilityScore".to_owned(),
message: "repairability_score must be between 0.0 and 10.0".to_owned(),
});
}
if let Some(ref data) = self.product_group_data
&& data.product_group() != self.product_group
{
errors.push(FieldError {
field: "/product_group".to_owned(),
message: "product_group must match product_group_data's product_group".to_owned(),
});
}
if let Some(ProductGroupData::Battery(battery)) = &self.product_group_data
&& let (Some(envelope), Some(product_group)) =
(self.placed_on_market_date, battery.placed_on_market_date)
&& envelope != product_group
{
errors.push(FieldError {
field: "/productGroupData/placedOnMarketDate".to_owned(),
message: format!(
"placed_on_market_date disagrees with the passport's own \
({product_group} vs {envelope}); the date fixes which law governs \
this battery, so it cannot have two values"
),
});
}
if self.product_group == ProductGroup::UnsoldGoods
&& let Some(ProductGroupData::UnsoldGoods(report)) = &self.product_group_data
&& report.lines.is_empty()
{
errors.push(FieldError {
field: "/productGroupData/lines".to_owned(),
message: "an unsold-goods disclosure must carry at least one product line \
(Impl. Reg. (EU) 2026/2 Annex I)"
.to_owned(),
});
}
if errors.is_empty() {
Ok(())
} else {
Err(crate::error::dpp::DppError::Validation(ValidationErrors {
errors,
}))
}
}
pub fn transition_to(
&mut self,
next: PassportStatus,
) -> Result<(), crate::error::dpp::DppError> {
if !self.status.can_transition_to(&next) {
return Err(crate::error::dpp::DppError::InvalidTransition {
current: self.status.to_string(),
required: next.to_string(),
});
}
let now = chrono::Utc::now();
if next == PassportStatus::Published && self.published_at.is_none() {
self.check_mandatory_content()?;
self.retention_locked = true;
self.published_at = Some(now);
}
self.status = next;
self.updated_at = now;
Ok(())
}
pub fn check_mandatory_content(&self) -> Result<(), crate::error::dpp::DppError> {
use crate::field_error::{FieldError, ValidationErrors};
if self.product_group != crate::product_group::ProductGroup::Battery {
return Ok(());
}
let Some(data) = self.product_group_data.as_ref() else {
return Err(crate::error::dpp::DppError::Validation(ValidationErrors {
errors: vec![FieldError {
field: "/productGroupData".to_owned(),
message: "a battery passport cannot be published without product_group data"
.to_owned(),
}],
}));
};
let Ok(value) = serde_json::to_value(data) else {
return Ok(());
};
let Some(battery_type) = value.get("batteryType").and_then(serde_json::Value::as_str)
else {
return Ok(());
};
let missing: Vec<FieldError> =
dpp_rules::batteries::passport_content::mandatory_fields(battery_type)
.filter(|f| value.get(*f).is_none_or(serde_json::Value::is_null))
.map(|f| FieldError {
field: format!("/productGroupData/{f}"),
message: format!(
"'{f}' is mandatory for a '{battery_type}' battery and is absent; \
a passport omitting it does not carry the content the Battery \
Regulation requires of this category"
),
})
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(crate::error::dpp::DppError::Validation(ValidationErrors {
errors: missing,
}))
}
}
}