Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,76 @@ This file was started retroactively on 2026-07-03 at v0.4.0; entries for

## [Unreleased]

### Breaking

- **`UnsoldGoodsReport` is rebuilt to the format its implementing act
prescribes, and unsold-goods schema `v1.0.0` is removed.**

Two acts adopted on 9 February 2026 govern this disclosure, and the previous
model predated both. **Commission Implementing Regulation (EU) 2026/2** (CELEX
`32026R0002`), made under ESPR Art. 24(3), binds the disclosure's visual
presentation and content to its **Annex I** (Art. 2(1)) and delimits categories
by **CN code** — first two digits, or four for the products of its Annex II
(Art. 3). **Commission Delegated Regulation (EU) 2026/296** (CELEX
`32026R0296`), made under Art. 25(5), sets out the closed list of ten
derogations from the destruction prohibition, and Annex I note (h) makes that
list the disclosure's reason vocabulary.

Almost nothing survived. The period is a **financial year** with both endpoints
rather than a free-text quarter, because Art. 1 scopes the duty to the
undertaking's own financial year and gives it 12 months from the year's end.
Categories are CN chapters or headings rather than words like `"apparel"`. The
disclosure gained the Annex I header — legal-entity name, EUID or another
officially recognised identifier, and standalone versus consolidated with its
undertakings listed — a repeating body of lines with unit counts, a
packaging-included flag and per-figure estimate marking, a six-way waste
treatment split, and the two narrative rows for measures taken and planned.

`UnsoldGoodsReason` and `UnsoldGoodsDestination` are gone. The reason list is
now `DiscardReason`, whose ten variants are Art. 2 points (a) to (j); the old
variants were ours, and two of them — `EndOfSeason` and `OverProduction` —
named commercial circumstances that are not derogations at all, so a
disclosure using them asserted a lawful destruction the act does not permit.
The single destination is replaced by `WasteTreatmentSplit`, in which **total
destruction is derived and never stored**: Annex I note (i) defines it as
recycling plus other recovery plus disposal, which leaves preparing-for-reuse
and unknown outside it.

**Schema `unsold-goods/v1.0.0` is deleted rather than migrated.** No lens can
carry a document forward from it — a financial year is not derivable from
`"2026-Q2"`, a CN code is not derivable from `"apparel"`, a percentage split is
not derivable from one destination, and the reason lists share no member. Every
field would have had to be invented. This is safe only because nothing has ever
been stored under it.

Also changed as a consequence: a passport in the `unsold-goods` group no longer
requires an envelope `commodity_code` within ESPR Annex VII scope. **Art. 24's
disclosure duty and Art. 25's destruction ban have different scopes** — the ban
reaches Annex VII's apparel and footwear, the disclosure reaches discarded
unsold consumer products generally, as 2026/2's own Annex II shows across 45 CN
headings. The old check rejected every lawful disclosure outside those two.

### Added

- **`InstrumentKind::Implementing`.** The catalog could name a delegated act but
not an implementing one, and Impl. Reg. (EU) 2026/2 is the second kind. The
Treaty distinction is real — a delegated act may supplement or amend
non-essential elements of the basic act, an implementing act only lays down
uniform conditions for implementing it — so recording one as the other would
assert a power it does not have. Both kinds carry a `parent`.

- **`CnCategory`**, the combined-nomenclature chapter (2 digits) or heading (4)
a disclosure line is filed under. Deliberately *not* `CommodityCode`, which is
a product's own 6/8/10-digit classification: substituting one for the other
files a whole chapter's goods under a single article.

- **Impl. Reg. (EU) 2026/2 and Del. Reg. (EU) 2026/296 in the instrument
catalog**, both bound to `unsold-goods`, both `notRequired` — neither creates a
passport. 2026/296 carries a **five-year** retention figure that is *not* a
passport availability period: Art. 3 requires per-derogation documentation to
be kept for five years after destruction and produced to a competent authority
within 30 days.

- **A catalog of the legal instruments themselves, so a product group can be
governed by more than one.** `InstrumentCatalog` holds one manifest per act —
ten today, from ESPR and the Batteries Regulation to the two horizontal
Expand Down
92 changes: 78 additions & 14 deletions crates/dpp-aas/src/product_groups/unsold_goods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,29 +22,93 @@
//! `globalAssetId` to make the door respond would put a fabricated trade-item
//! identifier into a document an integrator's toolchain treats as authoritative.

use dpp_domain::domain::product_group::UnsoldGoodsReport;
use dpp_domain::domain::product_group::{DisclosureScope, UnsoldGoodsReport};

use crate::model::{AasSemId, AasSubmodel};
use crate::property::{double_property, enum_wire_str, string_property};
use crate::semantic_ids;

/// One submodel per disclosure, carrying the Annex I header and a flattened
/// element per line.
///
/// AAS `SubmodelElement`s are a flat list here rather than a
/// `SubmodelElementCollection` per line — the disclosure has a repeating body
/// (Annex I: "additional lines may be added as necessary") and this projection
/// indexes it. A reader wanting the structured record should take the JSON;
/// this exists for toolchains that only speak AAS.
pub(super) fn build_unsold_goods_submodel(r: &UnsoldGoodsReport, passport_id: &str) -> AasSubmodel {
let reason_str = enum_wire_str(&r.reason);
let destination_str = enum_wire_str(&r.destination);
let mut elements = vec![
string_property("reportingPeriod", &r.reporting_period, None),
double_property("volumeKg", r.volume_kg, None),
string_property("productCategory", &r.product_category, None),
string_property("reason", &reason_str, None),
string_property("destination", &destination_str, None),
string_property("countryOfDisposal", &r.country_of_disposal, None),
string_property("entityName", &r.entity.name, None),
string_property("entityIdentifier", r.entity.identifier.value(), None),
string_property(
"disclosureScope",
match r.entity.scope {
DisclosureScope::Standalone => "standalone",
DisclosureScope::Consolidated { .. } => "consolidated",
// `DisclosureScope` is `#[non_exhaustive]`: a scope this build
// has no name for is projected as unknown rather than guessed.
_ => "unknown",
},
None,
),
string_property(
"financialYearStart",
&r.financial_year.start.to_string(),
None,
),
string_property("financialYearEnd", &r.financial_year.end.to_string(), None),
double_property("totalWeightKg", r.total_weight_kg() as f64, None),
double_property("totalUnits", r.total_units() as f64, None),
];
if let Some(ref v) = r.destruction_justification {
elements.push(string_property("destructionJustification", v, None));
}
if let Some(ref v) = r.operator_name {
elements.push(string_property("operatorName", v, None));

for (i, line) in r.lines.iter().enumerate() {
let reason_str = enum_wire_str(&line.reason);
elements.push(string_property(
&format!("line{i}CnCategories"),
&line
.cn_categories
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(","),
None,
));
elements.push(string_property(
&format!("line{i}Description"),
&line.description,
None,
));
elements.push(double_property(
&format!("line{i}WeightKg"),
line.weight_kg.value as f64,
None,
));
elements.push(double_property(
&format!("line{i}Units"),
line.units_discarded.value as f64,
None,
));
elements.push(string_property(
&format!("line{i}Reason"),
&reason_str,
None,
));
// Derived, per Annex I note (i) — never a stored field, but a reader of
// the projection needs it without recomputing.
elements.push(double_property(
&format!("line{i}TotalDestructionPct"),
f64::from(line.treatment.total_destruction_pct()),
None,
));
}

elements.push(string_property("measuresTaken", &r.measures_taken, None));
elements.push(string_property(
"measuresPlanned",
&r.measures_planned,
None,
));

AasSubmodel {
id: format!("urn:odal-node:dpp:{passport_id}:unsold-goods"),
id_short: "UnsoldGoods".into(),
Expand Down
86 changes: 70 additions & 16 deletions crates/dpp-aas/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,51 @@ use dpp_domain::{
BatteryChemistry, BatteryData, BatteryType, CarbonFootprint, CarbonFootprintClass, FibreEntry,
Gtin, HazardSymbol, ManufacturerInfo, MaterialComposition, MaterialEntry, Passport, PassportId,
PassportStatus, ProductGroup, ProductGroupData, RepairabilityScore, TextileData,
UnsoldGoodsDestination, UnsoldGoodsReason, UnsoldGoodsReport,
UnsoldGoodsReport,
};
use serde_json::json;

/// A minimal Annex I disclosure — one line, split totalling 100.
fn sample_unsold_goods_report() -> UnsoldGoodsReport {
use chrono::NaiveDate;
use dpp_domain::{
CnCategory, DiscardReason, DiscardedProductLine, DiscardedQuantity, DisclosingEntity,
DisclosureScope, FinancialYear, LegalEntityIdentifier, WasteTreatmentSplit,
};

UnsoldGoodsReport {
entity: DisclosingEntity {
name: "Example Retail Group SA".into(),
identifier: LegalEntityIdentifier::Euid {
value: "LUB123456789".into(),
},
scope: DisclosureScope::Standalone,
},
financial_year: FinancialYear {
start: NaiveDate::from_ymd_opt(2027, 1, 1).expect("valid date"),
end: NaiveDate::from_ymd_opt(2027, 12, 31).expect("valid date"),
},
lines: vec![DiscardedProductLine {
cn_categories: vec![CnCategory::parse("6203").expect("valid CN heading")],
description: "Men's suits and trousers".into(),
units_discarded: DiscardedQuantity::measured(1_200),
weight_kg: DiscardedQuantity::estimated(430),
packaging_included: false,
reason: DiscardReason::DamagedOrContaminated,
reason_detail: None,
treatment: WasteTreatmentSplit {
preparing_for_reuse_pct: 20,
recycling_pct: 50,
other_recovery_pct: 20,
disposal_pct: 5,
unknown_pct: 5,
},
}],
measures_taken: "Introduced pre-season demand forecasting.".into(),
measures_planned: "Extending the donation window to twelve weeks.".into(),
}
}

fn minimal_passport(product_group: ProductGroup) -> Passport {
let schema_version = dpp_domain::ProductGroupCatalog::new()
.get(product_group.catalog_key())
Expand Down Expand Up @@ -605,25 +646,38 @@ fn non_object_input_produces_empty_submodel() {
#[test]
fn build_aas_unsold_goods_produces_product_group_submodel() {
let mut passport = minimal_passport(ProductGroup::UnsoldGoods);
passport.product_group_data = Some(ProductGroupData::UnsoldGoods(UnsoldGoodsReport {
reporting_period: "2026-Q2".into(),
volume_kg: 1500.0,
product_category: "apparel".into(),
reason: UnsoldGoodsReason::EndOfSeason,
destination: UnsoldGoodsDestination::Donation,
destruction_justification: None,
country_of_disposal: "DE".into(),
operator_name: Some("GoodWill e.V.".into()),
}));
passport.product_group_data = Some(ProductGroupData::UnsoldGoods(sample_unsold_goods_report()));
let (_, submodels) =
build_aas_from_passport(&passport, "09506000134352", Audience::Public).expect("masking");
let sub = submodels.iter().find(|s| s.id_short == "UnsoldGoods");
assert!(sub.is_some(), "UnsoldGoods submodel missing");
let has_volume = sub.unwrap().submodel_elements.iter().any(|e| match e {
AasSubmodelElement::Property(p) => p.id_short == "volumeKg",
_ => false,
});
assert!(has_volume, "volumeKg property missing");

let id_shorts: Vec<&str> = sub
.unwrap()
.submodel_elements
.iter()
.filter_map(|e| match e {
AasSubmodelElement::Property(p) => Some(p.id_short.as_str()),
_ => None,
})
.collect();

// The Annex I header, the flattened line, and the two narrative rows.
for expected in [
"entityName",
"financialYearStart",
"financialYearEnd",
"totalWeightKg",
"line0CnCategories",
"line0TotalDestructionPct",
"measuresTaken",
"measuresPlanned",
] {
assert!(
id_shorts.contains(&expected),
"{expected} missing from {id_shorts:?}"
);
}
}

/// A product group whose typed mapper was removed still ships its data — the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"id": "unsold-goods-derogations-2026-296",
"title": "Derogations from the prohibition of destruction of unsold consumer products — Commission Delegated Regulation (EU) 2026/296",
"celex": "32026R0296",
"kind": "delegated",
"status": "adopted",
"parent": "espr",
"passport": { "obligation": "notRequired" },
"retentionYears": 5,
"retentionYearsBasis": "sourced",
"productGroups": [
{
"productGroup": "unsold-goods",
"status": "in_force",
"legalBasis": [
"Commission Delegated Regulation (EU) 2026/296 Art. 2",
"Commission Delegated Regulation (EU) 2026/296 Art. 3",
"Regulation (EU) 2024/1781 Art. 25(5)"
],
"notes": "Adopted 2026-02-09, OJ 2026-04-22. Art. 2 is a CLOSED list of ten derogations, points (a) to (j), under which an Annex VII good may lawfully be destroyed. Point (h) — offered for donation and not accepted — is subordinate: it applies 'only where none of the circumstances referred to in points (a) to (g) are applicable', which is a condition over the whole set of reasons claimed for a category and cannot be checked on one line alone. Impl. Reg. (EU) 2026/2 Annex I note (h) makes this list the reason vocabulary of the disclosure, so the two acts interlock."
}
],
"notes": "retentionYears is sourced and is NOT a passport retention figure: Art. 3 requires the economic operator to keep per-derogation documentation for five years after destruction, in electronic form, and produce it to a competent authority within 30 days of a request. It is evidence of a lawful destruction, not availability of a product record, and must not be folded into a passport availability period. Neither this act nor its implementing sibling creates a passport."
}
23 changes: 23 additions & 0 deletions crates/dpp-domain/instruments/unsold-goods-format-2026-2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"id": "unsold-goods-format-2026-2",
"title": "Format for the disclosure of information on discarded unsold consumer products — Commission Implementing Regulation (EU) 2026/2",
"celex": "32026R0002",
"kind": "implementing",
"status": "adopted",
"parent": "espr",
"passport": { "obligation": "notRequired" },
"productGroups": [
{
"productGroup": "unsold-goods",
"status": "in_force",
"legalBasis": [
"Commission Implementing Regulation (EU) 2026/2 Art. 2(1)",
"Commission Implementing Regulation (EU) 2026/2 Art. 3",
"Commission Implementing Regulation (EU) 2026/2 Annex I",
"Regulation (EU) 2024/1781 Art. 24(3)"
],
"notes": "Adopted 2026-02-09, OJ 2026-02-10. Creates no duty of its own — it prescribes the format of the disclosure ESPR Art. 24 already requires, which is why it is an implementing act and carries no passport obligation. Art. 2(1) binds the visual presentation and content to Annex I. Art. 3 delimits categories on the first two digits of the CN code, except for the products of its Annex II, which take four. Art. 1 scopes the duty to each financial year from the first full financial year after the date of application, disclosed within 12 months of that year's end; Art. 2(2) lets an operator publishing sustainability reporting under Directive 2013/34/EU Arts. 19a or 29a link to it instead of disclosing on its website directly. Annex I note (h) points the reason vocabulary at the derogations of the delegated act under Art. 25(5), which is Del. Reg. (EU) 2026/296 — the two acts interlock and neither is complete alone."
}
],
"notes": "Scope worth keeping straight: this implements Art. 24's DISCLOSURE duty, which reaches discarded unsold consumer products generally, and is therefore much wider than Art. 25's destruction prohibition over the Annex VII goods. Its own Annex II runs to 45 CN headings from soap and tyres to refrigerators and toys, none of which are in ESPR Annex VII. Treating Annex VII as the scope of the disclosure drops every category outside apparel and footwear from a report required to carry them."
}
19 changes: 6 additions & 13 deletions crates/dpp-domain/product-groups/unsold-goods.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
{
"key": "unsold-goods",
"title": "Unsold Textiles (destruction ban)",
"title": "Discarded unsold consumer products (ESPR Arts. 24-25)",
"schemaVersions": [
"1.0.0"
"2.0.0"
],
"currentSchemaVersion": "1.0.0",
"productCategories": [
"apparel",
"footwear",
"accessories"
],
"disclosure": {
"operatorName": "restricted",
"destructionJustification": "restricted"
},
"currentSchemaVersion": "2.0.0",
"productCategories": [],
"disclosure": {},
"plugin": "product-group-textile",
"notes": "Currently handled by the product-group-textile plugin via in-payload dispatch; candidate to split into its own crate (see DATA-MODEL.md §3.4)."
"notes": "Not a product group. It occupies a slot for implementation convenience and borrows the product-group-textile plugin; the obligation is horizontal, on an economic operator over a financial year, and imposes no passport. v2.0.0 is the format prescribed by Impl. Reg. (EU) 2026/2 Annex I. There is no v1.0.0: it predated that act and nothing could carry a document forward from it, so it was removed rather than migrated behind an invented lens. productCategories is empty because Art. 3 delimits by CN code, not by a name from a list of ours; disclosure is empty because the whole document is published on the operator's own website, so there is nothing in it to withhold from any audience."
}
Loading
Loading