use std::collections::BTreeMap;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::{
ComponentRef, DerivationRef, FacilitySnapshot, LifeStatus, ManufacturerInfo, MaterialEntry,
PassportId,
};
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serial_number: 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub carrier_serial: 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 = "Vec::is_empty")]
pub derived_from: Vec<DerivationRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub component_refs: Vec<ComponentRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub life_status: Option<LifeStatus>,
#[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 responsible_operator: Option<crate::operator::ResponsibleOperatorSnapshot>,
#[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 REMOVED_ENVELOPE_KEYS: &[(&str, &str)] = &[("parentPassportRef", "derivedFrom")];
pub const PASSPORT_WIRE_KEYS: &[&str] = &[
"id",
"batchId",
"serialNumber",
"productName",
"productGroup",
"applicableInstruments",
"granularity",
"manufacturer",
"materials",
"co2ePerUnit",
"repairabilityScore",
"complianceResult",
"lintResult",
"productGroupData",
"status",
"qrCodeUrl",
"carrierSerial",
"jwsSignature",
"publicJwsSignature",
"disclosureSignatures",
"createdAt",
"updatedAt",
"publishedAt",
"placedOnMarketDate",
"schemaVersion",
"retentionLocked",
"version",
"supersedesId",
"derivedFrom",
"componentRefs",
"lifeStatus",
"retentionUntil",
"productId",
"commodityCode",
"operatorIdentifier",
"responsibleOperator",
"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))
}
fn gs1_rejection_reason(
rejection: dpp_rules::common::identifier::Gs1ValueRejection,
ai: &str,
max: usize,
) -> String {
use dpp_rules::common::identifier::Gs1ValueRejection;
match rejection {
Gs1ValueRejection::Empty => "must not be empty".to_owned(),
Gs1ValueRejection::TooLong { chars } => {
format!("has {chars} characters; GS1 AI {ai} allows at most {max}")
}
Gs1ValueRejection::OutsideCset82(c) => {
format!("contains {c:?}, which is outside GS1 CSET 82")
}
}
}
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 _;
if let Some(object) = doc.as_object() {
for &(removed, replacement) in REMOVED_ENVELOPE_KEYS {
if object.contains_key(removed) {
return Err(DppError::RemovedEnvelopeKey {
removed,
replacement,
});
}
}
}
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()))
}
#[must_use]
pub fn effective_carrier_serial(&self) -> std::borrow::Cow<'_, str> {
match &self.carrier_serial {
Some(attributed) => std::borrow::Cow::Borrowed(attributed),
None => std::borrow::Cow::Owned(self.id.default_carrier_serial()),
}
}
#[must_use]
pub fn carrier_qualifier(&self) -> Option<super::CarrierQualifier<'_>> {
use super::CarrierQualifier;
match self.granularity {
Some(Granularity::Model) => Some(CarrierQualifier::Model),
Some(Granularity::Batch) => self
.batch_id
.as_deref()
.map(|batch| CarrierQualifier::Batch(std::borrow::Cow::Borrowed(batch))),
Some(Granularity::Item) | None => {
Some(CarrierQualifier::Serial(self.effective_carrier_serial()))
}
}
}
pub fn validate(&self) -> Result<(), crate::error::dpp::DppError> {
use crate::field_error::{FieldError, ValidationErrors};
let mut errors: Vec<FieldError> = Vec::new();
if let Some(serial) = &self.carrier_serial
&& let Err(rejection) = dpp_rules::common::identifier::check_gs1_serial(serial)
{
errors.push(FieldError {
field: "/carrierSerial".to_owned(),
message: format!(
"carrier_serial {}",
gs1_rejection_reason(
rejection,
"21",
dpp_rules::common::identifier::MAX_GS1_SERIAL_CHARS
)
),
});
}
self.check_granularity_carries_its_identifiers(&mut errors);
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 let Err(code) = self.manufacturer.validate_country() {
errors.push(FieldError {
field: "/manufacturer/country".to_owned(),
message: format!(
"manufacturer.country must be an ISO 3166-1 alpha-2 code, got {code:?}"
),
});
}
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,
}))
}
}
fn check_granularity_carries_its_identifiers(
&self,
errors: &mut Vec<crate::field_error::FieldError>,
) {
use crate::field_error::FieldError;
let finer_than_the_level = |field: &str, level: &str| FieldError {
field: format!("/{field}"),
message: format!(
"{field} names something finer than a {level}-level passport covers; \
it belongs on a finer-grained passport"
),
};
match self.granularity {
Some(Granularity::Model) => {
if self.batch_id.is_some() {
errors.push(finer_than_the_level("batchId", "model"));
}
if self.serial_number.is_some() {
errors.push(finer_than_the_level("serialNumber", "model"));
}
}
Some(Granularity::Batch) => {
match self.batch_id.as_deref() {
None => errors.push(FieldError {
field: "/batchId".to_owned(),
message: "a batch-level passport must carry the batch_id its \
carrier prints in GS1 AI 10"
.to_owned(),
}),
Some(batch) => {
if let Err(rejection) = dpp_rules::common::identifier::check_gs1_lot(batch)
{
errors.push(FieldError {
field: "/batchId".to_owned(),
message: format!(
"batch_id {}",
gs1_rejection_reason(
rejection,
"10",
dpp_rules::common::identifier::MAX_GS1_LOT_CHARS
)
),
});
}
}
}
if self.serial_number.is_some() {
errors.push(finer_than_the_level("serialNumber", "batch"));
}
}
Some(Granularity::Item) | None => {}
}
}
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> {
if self.art_77_1_exempts_this_record() {
return Ok(());
}
self.check_category_content()
}
fn art_77_1_exempts_this_record(&self) -> bool {
use chrono::Datelike;
use dpp_rules::batteries::passport_scope::{
PASSPORT_REQUIRED_FROM, PassportScope, passport_scope,
};
use dpp_rules::common::date::CalendarDate;
let Some(crate::product_group::ProductGroupData::Battery(battery)) =
self.product_group_data.as_ref()
else {
return false;
};
let placed = match (self.placed_on_market_date, battery.placed_on_market_date) {
(Some(envelope), Some(product_group)) if envelope != product_group => return false,
(envelope, product_group) => envelope
.or(product_group)
.map_or(PASSPORT_REQUIRED_FROM, |d| {
CalendarDate::new(d.year(), d.month() as u8, d.day() as u8)
}),
};
match passport_scope(
battery.battery_type.wire_str(),
battery.rated_capacity_kwh,
placed,
) {
PassportScope::NotCovered
| PassportScope::BelowThreshold
| PassportScope::NotYetBinding => true,
_ => false,
}
}
pub fn check_category_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,
}))
}
}
}