diff --git a/CHANGELOG.md b/CHANGELOG.md index 706a11c6..af06a6c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/dpp-aas/src/product_groups/unsold_goods.rs b/crates/dpp-aas/src/product_groups/unsold_goods.rs index 215fc864..cac4e74e 100644 --- a/crates/dpp-aas/src/product_groups/unsold_goods.rs +++ b/crates/dpp-aas/src/product_groups/unsold_goods.rs @@ -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::>() + .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(), diff --git a/crates/dpp-aas/src/tests.rs b/crates/dpp-aas/src/tests.rs index 8b9e58e3..faa8d780 100644 --- a/crates/dpp-aas/src/tests.rs +++ b/crates/dpp-aas/src/tests.rs @@ -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()) @@ -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 diff --git a/crates/dpp-domain/instruments/unsold-goods-derogations-2026-296.json b/crates/dpp-domain/instruments/unsold-goods-derogations-2026-296.json new file mode 100644 index 00000000..060011b9 --- /dev/null +++ b/crates/dpp-domain/instruments/unsold-goods-derogations-2026-296.json @@ -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." +} diff --git a/crates/dpp-domain/instruments/unsold-goods-format-2026-2.json b/crates/dpp-domain/instruments/unsold-goods-format-2026-2.json new file mode 100644 index 00000000..508ece85 --- /dev/null +++ b/crates/dpp-domain/instruments/unsold-goods-format-2026-2.json @@ -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." +} diff --git a/crates/dpp-domain/product-groups/unsold-goods.json b/crates/dpp-domain/product-groups/unsold-goods.json index f046b245..d45d89dc 100644 --- a/crates/dpp-domain/product-groups/unsold-goods.json +++ b/crates/dpp-domain/product-groups/unsold-goods.json @@ -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." } diff --git a/crates/dpp-domain/schemas/unsold-goods/v1.0.0.json b/crates/dpp-domain/schemas/unsold-goods/v1.0.0.json deleted file mode 100644 index 58ed9257..00000000 --- a/crates/dpp-domain/schemas/unsold-goods/v1.0.0.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://schema.odal-node.io/dpp/unsold-goods-v1.0.0.json", - "title": "Odal Node — Unsold Goods Report (v1.0.0)", - "description": "Unsold goods disposal report required by EU ESPR Article 25 (Annex VII) — destruction ban effective July 19, 2026.", - "type": "object", - "required": [ - "reportingPeriod", - "volumeKg", - "productCategory", - "reason", - "destination", - "countryOfDisposal" - ], - "properties": { - "reportingPeriod": { - "type": "string", - "minLength": 1, - "description": "Reference period covered by this report (e.g. '2026-Q2', '2026-07').", - "x-disclosure": "public" - }, - "volumeKg": { - "type": "number", - "exclusiveMinimum": 0, - "description": "Total volume of unsold goods disposed in this report, in kilograms.", - "x-disclosure": "public" - }, - "productCategory": { - "type": "string", - "enum": [ - "apparel", - "footwear", - "home-textile", - "accessories", - "other" - ], - "description": "High-level product category.", - "x-disclosure": "public" - }, - "reason": { - "type": "string", - "enum": [ - "end_of_season", - "quality_defect", - "packaging_defect", - "over_production", - "customer_return", - "other" - ], - "description": "Reason the goods were unsold.", - "x-disclosure": "public" - }, - "destination": { - "type": "string", - "enum": [ - "donation", - "recycling", - "repurposing", - "supplier_return", - "exempt_destruction" - ], - "description": "Disposal destination. 'exempt_destruction' requires destructionJustification.", - "x-disclosure": "public" - }, - "destructionJustification": { - "type": [ - "string", - "null" - ], - "minLength": 10, - "description": "Mandatory justification text if destination is 'exempt_destruction'.", - "x-disclosure": "restricted" - }, - "countryOfDisposal": { - "type": "string", - "pattern": "^[A-Z]{2}$", - "description": "ISO 3166-1 alpha-2 country where disposal took place.", - "x-disclosure": "public" - }, - "operatorName": { - "type": [ - "string", - "null" - ], - "minLength": 1, - "description": "Name of the disposal operator, charity, or recycler — required for audit trail.", - "x-disclosure": "restricted" - } - }, - "if": { - "properties": { - "destination": { - "const": "exempt_destruction" - } - } - }, - "then": { - "required": [ - "destructionJustification" - ], - "properties": { - "destructionJustification": { - "type": "string", - "minLength": 10 - } - } - }, - "additionalProperties": false, - "$comment": "NO PASSPORT OBLIGATION. An act binds this product group today, but no act requires a digital product passport for it — the duty is either absent from the instrument entirely or discharged through another system under ESPR Art. 9(4)(b). This schema therefore describes data we model, not a passport the law asks for. Structural validation against it is not evidence of compliance with any passport obligation, and no binding passport determination may be emitted here. Removing this marker takes an act that actually imposes a passport; no status change can do it." -} diff --git a/crates/dpp-domain/schemas/unsold-goods/v2.0.0.json b/crates/dpp-domain/schemas/unsold-goods/v2.0.0.json new file mode 100644 index 00000000..1e8ec174 --- /dev/null +++ b/crates/dpp-domain/schemas/unsold-goods/v2.0.0.json @@ -0,0 +1,239 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://schema.odal-node.io/dpp/unsold-goods-v2.0.0.json", + "title": "Odal Node — Discarded Unsold Consumer Products Disclosure (v2.0.0)", + "description": "The disclosure required by EU ESPR Article 24, in the format prescribed by Commission Implementing Regulation (EU) 2026/2 (CELEX 32026R0002), Article 2(1) and Annex I. Reason vocabulary is the derogation list of Commission Delegated Regulation (EU) 2026/296 (CELEX 32026R0296), Article 2. Numbers carry no separators and are rounded to the nearest whole number (Annex I, Section 2); an estimated figure is shown with a leading '±' by the renderer, which is what the `estimated` flag drives.", + "type": "object", + "required": [ + "entity", + "financialYear", + "lines", + "measuresTaken", + "measuresPlanned" + ], + "properties": { + "entity": { + "type": "object", + "description": "Annex I header rows: who is disclosing, and for whom.", + "required": ["name", "identifier", "scope"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Annex I note (a). For a subsidiary in a consolidated disclosure this is the PARENT undertaking's name, not the subsidiary's.", + "x-disclosure": "public" + }, + "identifier": { + "type": "object", + "description": "Annex I note (b): the EUID established by Directive (EU) 2017/1132, or — only where no EUID is available — an identifier from an officially recognised Member State scheme.", + "oneOf": [ + { + "required": ["type", "value"], + "properties": { + "type": { "const": "euid", "x-disclosure": "public" }, + "value": { "type": "string", "minLength": 1, "x-disclosure": "public" } + }, + "additionalProperties": false + }, + { + "required": ["type", "scheme", "value"], + "properties": { + "type": { "const": "other", "x-disclosure": "public" }, + "scheme": { + "type": "string", + "minLength": 1, + "description": "Annex I's 'Other, namely:' blank. Without it the value cannot be resolved.", + "x-disclosure": "public" + }, + "value": { "type": "string", "minLength": 1, "x-disclosure": "public" } + }, + "additionalProperties": false + } + ], + "x-disclosure": "public" + }, + "scope": { + "type": "object", + "description": "Annex I note (c). A consolidated disclosure must list the subsidiaries or member undertakings it covers.", + "oneOf": [ + { + "required": ["type"], + "properties": { + "type": { "const": "standalone", "x-disclosure": "public" } + }, + "additionalProperties": false + }, + { + "required": ["type", "undertakings"], + "properties": { + "type": { "const": "consolidated", "x-disclosure": "public" }, + "undertakings": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 }, + "x-disclosure": "public" + } + }, + "additionalProperties": false + } + ], + "x-disclosure": "public" + } + }, + "additionalProperties": false, + "x-disclosure": "public" + }, + "financialYear": { + "type": "object", + "description": "Art. 1: the disclosure covers a financial year, which is the undertaking's own and need not be a calendar year. Due within 12 months of its end.", + "required": ["start", "end"], + "properties": { + "start": { "type": "string", "format": "date", "x-disclosure": "public" }, + "end": { "type": "string", "format": "date", "x-disclosure": "public" } + }, + "additionalProperties": false, + "x-disclosure": "public" + }, + "lines": { + "type": "array", + "minItems": 1, + "description": "The body of the Annex I table. 'Additional lines may be added as necessary'; note (h) requires a separate line per reason within a category.", + "items": { + "type": "object", + "required": [ + "cnCategories", + "description", + "unitsDiscarded", + "weightKg", + "packagingIncluded", + "reason", + "treatment" + ], + "properties": { + "cnCategories": { + "type": "array", + "minItems": 1, + "description": "Note (d) and Art. 3: the CN chapter (2 digits) or heading (4 digits) this line is filed under. Four digits are required for the products of Annex II. Note (f) allows more than one code where items sold together count as one unit.", + "items": { + "type": "string", + "pattern": "^([0-9]{2}|[0-9]{4})$" + }, + "x-disclosure": "public" + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Note (e): established on the basis of the combined nomenclature, or a more detailed description.", + "x-disclosure": "public" + }, + "unitsDiscarded": { + "type": "object", + "description": "Note (f): total units discarded in the period for this category. May be estimated from an accurately determined weight, in which case `estimated` is true and the renderer shows '±'.", + "required": ["value"], + "properties": { + "value": { + "type": "integer", + "minimum": 0, + "description": "Section 2: no separators, rounded to the nearest whole number.", + "x-disclosure": "public" + }, + "estimated": { "type": "boolean", "default": false, "x-disclosure": "public" } + }, + "additionalProperties": false, + "x-disclosure": "public" + }, + "weightKg": { + "type": "object", + "description": "Note (g): combined weight of the units discarded, in kilogrammes. May be estimated from an accurate count, in which case `estimated` is true.", + "required": ["value"], + "properties": { + "value": { + "type": "integer", + "minimum": 0, + "description": "Section 2: no separators, rounded to the nearest whole number.", + "x-disclosure": "public" + }, + "estimated": { "type": "boolean", "default": false, "x-disclosure": "public" } + }, + "additionalProperties": false, + "x-disclosure": "public" + }, + "packagingIncluded": { + "type": "boolean", + "description": "Whether packaging is included in weightKg — its own Annex I column, because the answer changes what the weight means.", + "x-disclosure": "public" + }, + "reason": { + "type": "string", + "description": "Note (h): the reason must refer to the derogations of Del. Reg. (EU) 2026/296 Art. 2, points (a) to (j). 'offeredForDonationNotAccepted' is point (h) and applies ONLY where none of (a) to (g) does.", + "enum": [ + "dangerousProduct", + "nonCompliantWithLaw", + "intellectualPropertyInfringement", + "licensedPeriodExpired", + "markingsCannotBeRemoved", + "damagedOrContaminated", + "defectiveBeyondRepair", + "offeredForDonationNotAccepted", + "donatedButNoRecipientFound", + "reusedButNoRecipientFound" + ], + "x-disclosure": "public" + }, + "reasonDetail": { + "type": ["string", "null"], + "minLength": 1, + "description": "Note (h): 'A more detailed explanation may be added.'", + "x-disclosure": "public" + }, + "treatment": { + "type": "object", + "description": "Note (i): the proportion of the line delivered to each waste treatment operation, as percentages OF WEIGHT. Total destruction is NOT a field — the act defines it as recycling + other recovery + disposal, so it is derived. 'unknown' is a real answer, for the share whose treatment could not be obtained from the waste treatment operator.", + "required": [ + "preparingForReusePct", + "recyclingPct", + "otherRecoveryPct", + "disposalPct", + "unknownPct" + ], + "properties": { + "preparingForReusePct": { + "type": "integer", "minimum": 0, "maximum": 100, "x-disclosure": "public" + }, + "recyclingPct": { + "type": "integer", "minimum": 0, "maximum": 100, "x-disclosure": "public" + }, + "otherRecoveryPct": { + "type": "integer", "minimum": 0, "maximum": 100, "x-disclosure": "public" + }, + "disposalPct": { + "type": "integer", "minimum": 0, "maximum": 100, "x-disclosure": "public" + }, + "unknownPct": { + "type": "integer", "minimum": 0, "maximum": 100, "x-disclosure": "public" + } + }, + "additionalProperties": false, + "x-disclosure": "public" + } + }, + "additionalProperties": false + }, + "x-disclosure": "public" + }, + "measuresTaken": { + "type": "string", + "minLength": 1, + "description": "Annex I note (i): measures taken to prevent destruction, including those taken in the PRECEDING financial year, based where relevant on what was destroyed in the past.", + "x-disclosure": "public" + }, + "measuresPlanned": { + "type": "string", + "minLength": 1, + "description": "Annex I note (j): measures planned for the future, in particular those necessary to prevent destruction of the categories destroyed in the preceding financial year for the same reasons, and how they are expected to achieve that.", + "x-disclosure": "public" + } + }, + "additionalProperties": false, + "$comment": "NO PASSPORT OBLIGATION. ESPR Arts. 24-25 bind an economic operator over a financial year and require no digital product passport at all — the disclosure is published on the operator's own website, or by a link to its sustainability reporting under Directive 2013/34/EU Arts. 19a/29a (Impl. Reg. (EU) 2026/2 Art. 2(2)). This schema therefore describes a disclosure we model, not a passport the law asks for. Structural validation against it is not evidence of compliance with any passport obligation, and no binding passport determination may be emitted here. Note also that the ban's scope and the disclosure's scope differ: Art. 25 prohibits destroying the Annex VII products (apparel, clothing accessories, footwear), while Art. 24's disclosure reaches discarded unsold consumer products generally." +} diff --git a/crates/dpp-domain/src/catalog/instrument_catalog.rs b/crates/dpp-domain/src/catalog/instrument_catalog.rs index b8261980..e52f1a6d 100644 --- a/crates/dpp-domain/src/catalog/instrument_catalog.rs +++ b/crates/dpp-domain/src/catalog/instrument_catalog.rs @@ -56,8 +56,22 @@ const EMBEDDED: &[EmbeddedInstrument] = &[ id: "ppwr-2025-40", json: include_str!("../../instruments/ppwr-2025-40.json"), }, + EmbeddedInstrument { + id: "unsold-goods-format-2026-2", + json: include_str!("../../instruments/unsold-goods-format-2026-2.json"), + }, + EmbeddedInstrument { + id: "unsold-goods-derogations-2026-296", + json: include_str!("../../instruments/unsold-goods-derogations-2026-296.json"), + }, ]; +/// How many instrument manifests ship embedded in this build. +/// +/// Exposed so a test can assert the catalog loaded all of them without writing +/// the number down twice. +pub const EMBEDDED_COUNT: usize = EMBEDDED.len(); + /// Open, data-driven catalog of the legal instruments that reach our product /// groups, pre-loaded from embedded manifests and extensible at runtime. /// diff --git a/crates/dpp-domain/src/catalog/instrument_kind.rs b/crates/dpp-domain/src/catalog/instrument_kind.rs index d4cd0811..91300866 100644 --- a/crates/dpp-domain/src/catalog/instrument_kind.rs +++ b/crates/dpp-domain/src/catalog/instrument_kind.rs @@ -57,6 +57,23 @@ pub enum InstrumentKind { /// `electronics` defect lived: an adjacent act was recorded as though it /// created a passport obligation of its own. Adjacent, + /// An act adopted **under** a framework that fixes the *procedure or format* + /// by which an obligation is met, rather than the obligation itself — an EU + /// implementing act. Names its framework in + /// [`Instrument::parent`](crate::catalog::Instrument::parent). + /// + /// Distinct from [`Self::Delegated`] because the Treaty distinction is real + /// and the two do different work: a delegated act may supplement or amend + /// non-essential elements of the basic act, while an implementing act only + /// lays down uniform conditions for implementing it. Impl. Reg. (EU) 2026/2 + /// is the clearest case in this catalog — it creates no duty at all, it + /// prescribes the format of a disclosure ESPR Art. 24 already required. + /// + /// Recorded as its own kind for the same reason `Delegated` was: forcing an + /// implementing act into `Delegated` would assert it can do something it + /// cannot, and forcing it into `Other` would drop a distinction the law + /// draws. + Implementing, /// A kind this build does not model, holding its manifest spelling verbatim. Other(String), } @@ -71,6 +88,7 @@ impl InstrumentKind { Self::Delegated => "delegated", Self::Direct => "direct", Self::Adjacent => "adjacent", + Self::Implementing => "implementing", Self::Other(_) => return None, }) } @@ -83,6 +101,7 @@ impl From for InstrumentKind { "delegated" => Self::Delegated, "direct" => Self::Direct, "adjacent" => Self::Adjacent, + "implementing" => Self::Implementing, _ => Self::Other(s), } } @@ -111,6 +130,7 @@ mod tests { InstrumentKind::Delegated, InstrumentKind::Direct, InstrumentKind::Adjacent, + InstrumentKind::Implementing, ] { let json = serde_json::to_string(&kind).expect("serialise"); assert!( diff --git a/crates/dpp-domain/src/catalog/instrument_tests.rs b/crates/dpp-domain/src/catalog/instrument_tests.rs index 926dda02..81d3a6dd 100644 --- a/crates/dpp-domain/src/catalog/instrument_tests.rs +++ b/crates/dpp-domain/src/catalog/instrument_tests.rs @@ -2,10 +2,15 @@ use super::*; +/// Every embedded manifest parses and lands in the catalog. +/// +/// Asserted against the embedded table rather than a literal, which went stale +/// the first time an act was added — the same defect this file exists to catch +/// in the manifests themselves. #[test] fn loads_all_embedded_manifests() { let catalog = InstrumentCatalog::new(); - assert_eq!(catalog.len(), 10); + assert_eq!(catalog.len(), instrument_catalog::EMBEDDED_COUNT); } /// Every instrument claiming a text must name it. An `Adopted` record with no @@ -26,12 +31,19 @@ fn a_claim_to_have_a_text_is_backed_by_a_celex() { } } -/// A delegated act must say what it was adopted under; a framework or a direct -/// instrument has nothing above it to name. +/// An act adopted **under** a framework must say which one; a framework or a +/// direct instrument has nothing above it to name. +/// +/// Both delegated and implementing acts are adopted under a basic act, so both +/// carry a parent. The Treaty distinction between them is about what they may +/// do, not about whether they have one. #[test] -fn only_delegated_acts_carry_a_parent() { +fn acts_adopted_under_a_framework_carry_a_parent() { for instrument in InstrumentCatalog::new().all() { - let expects_parent = instrument.kind == InstrumentKind::Delegated; + let expects_parent = matches!( + instrument.kind, + InstrumentKind::Delegated | InstrumentKind::Implementing + ); assert_eq!( instrument.parent.is_some(), expects_parent, diff --git a/crates/dpp-domain/src/catalog/tests.rs b/crates/dpp-domain/src/catalog/tests.rs index 7c91f054..26fe687b 100644 --- a/crates/dpp-domain/src/catalog/tests.rs +++ b/crates/dpp-domain/src/catalog/tests.rs @@ -496,7 +496,10 @@ const CATEGORY_ENUM_PROPERTY: &[(&str, &str)] = &[ ("furniture", "productType"), ("steel", "productCategory"), ("tyre", "tyreClass"), - ("unsold-goods", "productCategory"), + // `unsold-goods` is deliberately absent. Impl. Reg. (EU) 2026/2 Art. 3 + // delimits a disclosure by CN code, not by a category name, so v2.0.0 has + // no category enum for a catalog row to be checked against — and the + // descriptor's `productCategories` is empty for the same reason. ]; /// Drift guard: a catalog product category that is not a legal value of the diff --git a/crates/dpp-domain/src/domain/lint.rs b/crates/dpp-domain/src/domain/lint.rs index ce1ce426..4bb88532 100644 --- a/crates/dpp-domain/src/domain/lint.rs +++ b/crates/dpp-domain/src/domain/lint.rs @@ -5,10 +5,10 @@ //! Unlike [`crate::ports::compliance`], there is no pluggable strategy here: //! the lint pack ships directly in `dpp-rules` and is not an extension seam. -use chrono::{DateTime, Datelike, Utc}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use super::product_group::{ProductGroupData, UnsoldGoodsDestination}; +use super::product_group::{DisclosureScope, ProductGroupData}; /// How strongly a lint finding should be read. Neither variant blocks /// publish — the distinction is tone, not gating. Mirrors @@ -70,16 +70,6 @@ fn convert(f: dpp_rules::lint::LintFinding) -> LintFinding { } } -fn unsold_goods_destination_code(d: &UnsoldGoodsDestination) -> &'static str { - match d { - UnsoldGoodsDestination::Donation => "donation", - UnsoldGoodsDestination::Recycling => "recycling", - UnsoldGoodsDestination::Repurposing => "repurposing", - UnsoldGoodsDestination::SupplierReturn => "supplier_return", - UnsoldGoodsDestination::ExemptDestruction => "exempt_destruction", - } -} - /// Dispatch to the product group-specific lint pack. ProductGroups with no lint pack yet /// (everything but battery/textile/unsold-goods in the first ruleset) /// produce no findings. @@ -151,14 +141,35 @@ pub fn lint_product_group_data(data: &ProductGroupData, as_of: DateTime) -> .collect() } ProductGroupData::UnsoldGoods(u) => { + let lines: Vec> = u + .lines + .iter() + .map(|l| dpp_rules::lint::unsold_goods::DisclosureLineInput { + // A line may carry several CN codes (Annex I note (f)); the + // depth rule is about the first, which is the one the line + // is filed under. + cn_category: l + .cn_categories + .first() + .map_or("", super::product_group::CnCategory::as_str), + reason_point: l.reason.article_2_point(), + units: l.units_discarded.value, + weight_kg: l.weight_kg.value, + preparing_for_reuse_pct: l.treatment.preparing_for_reuse_pct, + recycling_pct: l.treatment.recycling_pct, + other_recovery_pct: l.treatment.other_recovery_pct, + disposal_pct: l.treatment.disposal_pct, + unknown_pct: l.treatment.unknown_pct, + }) + .collect(); let input = dpp_rules::lint::unsold_goods::UnsoldGoodsLintInput { - reporting_period: &u.reporting_period, - volume_kg: u.volume_kg, - destination: unsold_goods_destination_code(&u.destination), - operator_name: u.operator_name.as_deref(), - destruction_justification: u.destruction_justification.as_deref(), - as_of_year: as_of.year().max(0) as u32, - as_of_month: as_of.month(), + lines: &lines, + consolidated_undertaking_count: match &u.entity.scope { + DisclosureScope::Consolidated { undertakings } => Some(undertakings.len()), + DisclosureScope::Standalone => None, + }, + measures_taken_len: u.measures_taken.trim().chars().count(), + measures_planned_len: u.measures_planned.trim().chars().count(), }; dpp_rules::lint::unsold_goods::lint_unsold_goods(&input) .into_iter() @@ -172,7 +183,8 @@ pub fn lint_product_group_data(data: &ProductGroupData, as_of: DateTime) -> #[cfg(test)] mod tests { use super::*; - use crate::domain::product_group::{BatteryData, UnsoldGoodsReason, UnsoldGoodsReport}; + use crate::domain::product_group::BatteryData; + use crate::domain::product_group::data::unsold_goods::{CnCategory, DiscardReason}; fn battery() -> BatteryData { BatteryData { @@ -203,23 +215,60 @@ mod tests { } #[test] - fn unsold_goods_third_party_without_operator_name_triggers() { - let report = UnsoldGoodsReport { - reporting_period: "2026-Q2".into(), - volume_kg: 500.0, - product_category: "apparel".into(), - reason: UnsoldGoodsReason::EndOfSeason, - destination: UnsoldGoodsDestination::Donation, - destruction_justification: None, - country_of_disposal: "MK".into(), - operator_name: None, - }; + fn a_well_formed_disclosure_produces_no_findings() { + let data = ProductGroupData::UnsoldGoods(crate::test_support::sample_unsold_goods_report()); + assert_eq!(lint_product_group_data(&data, Utc::now()), Vec::new()); + } + + /// Annex I note (i) provides `unknown` for the share that could not be + /// established, so a split that does not reach 100 has lost weight rather + /// than being unsure about it. + #[test] + fn a_treatment_split_that_misses_100_is_flagged() { + let mut report = crate::test_support::sample_unsold_goods_report(); + report.lines[0].treatment.disposal_pct = 1; + let data = ProductGroupData::UnsoldGoods(report); + let findings = lint_product_group_data(&data, Utc::now()); + assert!( + findings + .iter() + .any(|f| f.code == "unsold_goods.treatment_split_does_not_total_100"), + "{findings:?}" + ); + } + + /// Art. 3 requires four digits for Annex II products, and chapter 85 holds + /// several — so a chapter-level line there hides which heading applied. + #[test] + fn a_chapter_holding_annex_ii_headings_is_flagged() { + let mut report = crate::test_support::sample_unsold_goods_report(); + report.lines[0].cn_categories = vec![CnCategory::parse("85").expect("valid chapter")]; + let data = ProductGroupData::UnsoldGoods(report); + let findings = lint_product_group_data(&data, Utc::now()); + assert!( + findings + .iter() + .any(|f| f.code == "unsold_goods.cn_category_needs_four_digits"), + "{findings:?}" + ); + } + + /// Point (h) applies "only where none of the circumstances referred to in + /// points (a) to (g) are applicable", so it cannot sit beside one of them + /// for the same category. + #[test] + fn donation_claimed_beside_a_stronger_reason_is_flagged() { + let mut report = crate::test_support::sample_unsold_goods_report(); + let mut second = report.lines[0].clone(); + second.reason = DiscardReason::OfferedForDonationNotAccepted; + report.lines.push(second); let data = ProductGroupData::UnsoldGoods(report); let findings = lint_product_group_data(&data, Utc::now()); assert!( findings .iter() - .any(|f| f.code == "unsold_goods.operator_name_missing_for_third_party_destination") + .any(|f| f.code == "unsold_goods.donation_reason_alongside_stronger_reason"), + "{findings:?}" ); } diff --git a/crates/dpp-domain/src/domain/passport/passport.rs b/crates/dpp-domain/src/domain/passport/passport.rs index f37b311d..7a0a0b49 100644 --- a/crates/dpp-domain/src/domain/passport/passport.rs +++ b/crates/dpp-domain/src/domain/passport/passport.rs @@ -444,10 +444,9 @@ impl Passport { /// - `co2e_per_unit` is non-negative if present /// - `repairability_score` is in range [0.0, 10.0] if present /// - `product_group_data.product group()` matches `self.product_group` if present - /// - for `ProductGroup::UnsoldGoods`, `commodity_code` is present and within - /// ESPR Annex VII scope (apparel & clothing accessories, or footwear), - /// and `product_group_data.product_category` (when present) agrees with the - /// Annex VII heading the commodity code falls under + /// - for `ProductGroup::UnsoldGoods`, the disclosure carries at least one + /// product line (Impl. Reg. (EU) 2026/2 Annex I). No Annex VII scope check: + /// that is Art. 25's destruction ban, not Art. 24's disclosure duty /// - `product_group_data` passes JSON Schema + cross-field rules via /// [`crate::domain::validation::validate_product_group_data`] (non-wasm32 only) pub fn validate(&self) -> Result<(), crate::domain::error::DppError> { @@ -540,39 +539,28 @@ impl Passport { }); } - // ESPR Annex VII eligibility: an unsold-goods passport must declare a - // commodity code within Annex VII's two headings (apparel & clothing - // accessories, or footwear) — a passport cannot claim this product group for - // a product the destruction ban does not cover. When product_group_data is - // also present, its own product_category word must agree with the - // heading the commodity code actually falls under — two fields - // describing the same product must not contradict each other. - if self.product_group == ProductGroup::UnsoldGoods { - match &self.commodity_code { - None => errors.push(FieldError { - field: "/commodityCode".to_owned(), - message: "commodity_code is required for product_group unsoldGoods (ESPR Annex VII scope check)".to_owned(), - }), - Some(code) => match crate::domain::product_group::unsold_goods_annex_vii_heading(code.as_str()) { - None => errors.push(FieldError { - field: "/commodityCode".to_owned(), - message: "commodity_code is not within ESPR Annex VII scope (apparel/clothing accessories or footwear)".to_owned(), - }), - Some(heading) => { - if let Some(ProductGroupData::UnsoldGoods(report)) = &self.product_group_data - && !crate::domain::product_group::unsold_goods_category_matches_heading( - &report.product_category, - heading, - ) - { - errors.push(FieldError { - field: "/productGroupData/productCategory".to_owned(), - message: "product_category does not match the Annex VII heading commodity_code falls under".to_owned(), - }); - } - } - }, - } + // An unsold-goods record is a disclosure by an undertaking over a + // financial year, not a product placed on the market, so the envelope's + // `commodity_code` has nothing to describe: the categories are on the + // lines, and there are many of them. + // + // This deliberately no longer requires Annex VII scope. **Art. 24 + // (disclosure) and Art. 25 (destruction ban) have different scopes** — + // the ban reaches Annex VII's apparel and footwear, while the disclosure + // reaches discarded unsold *consumer products* generally, which Impl. + // Reg. (EU) 2026/2 Annex II illustrates across 45 CN headings from soap + // to refrigerators. Requiring Annex VII here rejected every lawful + // disclosure outside apparel and footwear. + 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(), + }); } // ProductGroup-data validation: JSON Schema + cross-field rules (fibre sum, SVHC, etc.). diff --git a/crates/dpp-domain/src/domain/passport/tests.rs b/crates/dpp-domain/src/domain/passport/tests.rs index 0fd177c3..9041db99 100644 --- a/crates/dpp-domain/src/domain/passport/tests.rs +++ b/crates/dpp-domain/src/domain/passport/tests.rs @@ -6,7 +6,7 @@ use crate::domain::error::DppError; use crate::domain::identity::Audience; use crate::domain::product_group::{ BatteryChemistry, BatteryData, CarbonFootprint, ProductGroup, ProductGroupData, - RepairabilityScore, UnsoldGoodsDestination, UnsoldGoodsReason, UnsoldGoodsReport, + RepairabilityScore, UnsoldGoodsReport, }; use crate::domain::status::PassportStatus; use crate::schemas::lens::LensRegistry; @@ -65,40 +65,6 @@ fn product_group_data_mismatch_fails_validation() { assert!(err.contains("product_group must match"), "got: {err}"); } -#[test] -fn unsold_goods_without_commodity_code_fails_validation() { - let mut p = make_passport(); - p.product_group = ProductGroup::UnsoldGoods; - p.product_group_data = None; - p.commodity_code = None; - let err = p.validate().unwrap_err().to_string(); - assert!(err.contains("commodity_code is required"), "got: {err}"); -} - -#[test] -fn unsold_goods_with_out_of_scope_commodity_code_fails_validation() { - let mut p = make_passport(); - p.product_group = ProductGroup::UnsoldGoods; - p.product_group_data = None; - p.commodity_code = - Some(crate::domain::commodity_code::CommodityCode::parse("851712").expect("valid code")); - let err = p.validate().unwrap_err().to_string(); - assert!( - err.contains("not within ESPR Annex VII scope"), - "got: {err}" - ); -} - -#[test] -fn unsold_goods_with_annex_vii_commodity_code_passes_the_scope_check() { - let mut p = make_passport(); - p.product_group = ProductGroup::UnsoldGoods; - p.product_group_data = None; - p.commodity_code = - Some(crate::domain::commodity_code::CommodityCode::parse("620342").expect("valid code")); - assert!(p.validate().is_ok(), "{:?}", p.validate()); -} - #[test] fn missing_commodity_code_is_fine_outside_unsold_goods() { let mut p = make_passport(); // product_group = Electronics @@ -106,80 +72,55 @@ fn missing_commodity_code_is_fine_outside_unsold_goods() { assert!(p.validate().is_ok(), "{:?}", p.validate()); } -fn unsold_goods_report(product_category: &str) -> UnsoldGoodsReport { - UnsoldGoodsReport { - reporting_period: "2026-Q3".to_owned(), - volume_kg: 120.0, - product_category: product_category.to_owned(), - reason: UnsoldGoodsReason::EndOfSeason, - destination: UnsoldGoodsDestination::Donation, - destruction_justification: None, - country_of_disposal: "DE".to_owned(), - operator_name: Some("Caritas Berlin".to_owned()), - } +fn unsold_goods_report() -> UnsoldGoodsReport { + crate::test_support::sample_unsold_goods_report() } #[test] -fn unsold_goods_category_matching_the_commodity_code_heading_passes() { +fn an_unsold_goods_disclosure_with_lines_validates() { let mut p = make_passport(); p.product_group = ProductGroup::UnsoldGoods; - p.product_group_data = Some(ProductGroupData::UnsoldGoods(unsold_goods_report( - "apparel", - ))); - p.commodity_code = - Some(crate::domain::commodity_code::CommodityCode::parse("620342").expect("valid code")); + p.product_group_data = Some(ProductGroupData::UnsoldGoods(unsold_goods_report())); assert!(p.validate().is_ok(), "{:?}", p.validate()); } +/// Art. 24's disclosure duty reaches discarded unsold **consumer products** +/// generally; Art. 25's destruction ban reaches Annex VII's apparel and +/// footwear. An earlier check required Annex VII scope here and so rejected +/// every lawful disclosure outside those two, which is most of Annex II's 45 +/// headings. #[test] -fn unsold_goods_accessories_matches_the_apparel_heading_too() { - // Annex VII has one heading for apparel & clothing accessories, not two — - // "accessories" must be accepted alongside "apparel" for the same code. +fn a_disclosure_outside_annex_vii_scope_is_not_rejected() { let mut p = make_passport(); p.product_group = ProductGroup::UnsoldGoods; - p.product_group_data = Some(ProductGroupData::UnsoldGoods(unsold_goods_report( - "accessories", - ))); - p.commodity_code = - Some(crate::domain::commodity_code::CommodityCode::parse("650400").expect("valid code")); + let mut report = unsold_goods_report(); + // Refrigerators — Annex II heading 8418, nowhere near Annex VII. + report.lines[0].cn_categories = + vec![crate::domain::product_group::CnCategory::parse("8418").expect("valid heading")]; + p.product_group_data = Some(ProductGroupData::UnsoldGoods(report)); assert!(p.validate().is_ok(), "{:?}", p.validate()); } +/// The envelope's `commodity_code` describes a product; a disclosure has none, +/// so it must not be required here. #[test] -fn unsold_goods_category_contradicting_the_commodity_code_heading_fails() { - // Footwear commodity code, apparel category word — same passport, two - // fields describing the product, disagreeing with each other. +fn an_unsold_goods_disclosure_needs_no_envelope_commodity_code() { let mut p = make_passport(); p.product_group = ProductGroup::UnsoldGoods; - p.product_group_data = Some(ProductGroupData::UnsoldGoods(unsold_goods_report( - "apparel", - ))); - p.commodity_code = - Some(crate::domain::commodity_code::CommodityCode::parse("64011000").expect("valid code")); - let err = p.validate().unwrap_err().to_string(); - assert!( - err.contains("does not match the Annex VII heading"), - "got: {err}" - ); + p.product_group_data = Some(ProductGroupData::UnsoldGoods(unsold_goods_report())); + p.commodity_code = None; + assert!(p.validate().is_ok(), "{:?}", p.validate()); } #[test] -fn unsold_goods_home_textile_category_always_contradicts_annex_vii_scope() { - // "home-textile" has no Annex VII heading at all, so it can never be - // consistent with a commodity_code that (per the scope check above) must - // already be apparel- or footwear-headed. +fn an_unsold_goods_disclosure_with_no_lines_fails() { let mut p = make_passport(); p.product_group = ProductGroup::UnsoldGoods; - p.product_group_data = Some(ProductGroupData::UnsoldGoods(unsold_goods_report( - "home-textile", - ))); - p.commodity_code = - Some(crate::domain::commodity_code::CommodityCode::parse("620342").expect("valid code")); + let mut report = unsold_goods_report(); + report.lines.clear(); + p.product_group_data = Some(ProductGroupData::UnsoldGoods(report)); let err = p.validate().unwrap_err().to_string(); - assert!( - err.contains("does not match the Annex VII heading"), - "got: {err}" - ); + assert!(err.contains("at least one product line"), "got: {err}"); } #[test] diff --git a/crates/dpp-domain/src/domain/product_group/data/mod.rs b/crates/dpp-domain/src/domain/product_group/data/mod.rs index f2c9f55c..e39f7613 100644 --- a/crates/dpp-domain/src/domain/product_group/data/mod.rs +++ b/crates/dpp-domain/src/domain/product_group/data/mod.rs @@ -36,4 +36,8 @@ pub use steel::SteelData; pub use textile::{FibreEntry, TextileData}; pub use toy::ToyData; pub use tyre::TyreData; -pub use unsold_goods::{UnsoldGoodsDestination, UnsoldGoodsReason, UnsoldGoodsReport}; +pub use unsold_goods::{ + CnCategory, CnCategoryError, DiscardReason, DiscardedProductLine, DiscardedQuantity, + DisclosingEntity, DisclosureScope, FinancialYear, LegalEntityIdentifier, UnsoldGoodsReport, + WasteTreatmentSplit, +}; diff --git a/crates/dpp-domain/src/domain/product_group/data/unsold_goods.rs b/crates/dpp-domain/src/domain/product_group/data/unsold_goods.rs deleted file mode 100644 index 892b04f1..00000000 --- a/crates/dpp-domain/src/domain/product_group/data/unsold_goods.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Unsold Goods (EU ESPR, destruction ban — effective July 19, 2026). - -use serde::{Deserialize, Serialize}; - -/// Destination category for unsold textile goods under EU ESPR Article 25 (Annex VII). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum UnsoldGoodsDestination { - /// Donated to charity or social enterprise. - Donation, - /// Sent for material recycling. - Recycling, - /// Repurposed or upcycled within the supply chain. - Repurposing, - /// Returned to supplier for reuse. - SupplierReturn, - /// Destruction permitted under an approved exemption (requires justification). - ExemptDestruction, -} - -/// Reason category explaining why goods were unsold. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum UnsoldGoodsReason { - EndOfSeason, - QualityDefect, - PackagingDefect, - OverProduction, - CustomerReturn, - Other, -} - -/// Unsold Goods Destruction Ban report — EU ESPR Article 25 (Annex VII), effective July 19, 2026. -/// -/// Records the disposal of unsold textile goods. Destruction is banned unless -/// a specific exemption applies. All disposals must be reported in the DPP. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct UnsoldGoodsReport { - /// Reference period covered by this report (ISO 8601 date, e.g. `"2026-Q2"`). - pub reporting_period: String, - /// Total volume of unsold goods in kilograms. - pub volume_kg: f64, - /// Product category (e.g. `"apparel"`, `"footwear"`, `"home-textile"`). - pub product_category: String, - /// Reason the goods were unsold. - pub reason: UnsoldGoodsReason, - /// Destination / disposal method. - pub destination: UnsoldGoodsDestination, - /// If `destination` is `ExemptDestruction`, the mandatory justification text. - pub destruction_justification: Option, - /// ISO 3166-1 alpha-2 country where disposal took place. - pub country_of_disposal: String, - /// Name of the disposal operator or charity recipient (for audit trail). - pub operator_name: Option, -} diff --git a/crates/dpp-domain/src/domain/product_group/data/unsold_goods/cn_category.rs b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/cn_category.rs new file mode 100644 index 00000000..c1cf2d4c --- /dev/null +++ b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/cn_category.rs @@ -0,0 +1,86 @@ +//! [`CnCategory`] — the combined-nomenclature chapter or heading a discarded +//! product line is disclosed under. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Error from constructing a [`CnCategory`]. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum CnCategoryError { + /// Not 2 or 4 ASCII digits. + #[error("CN category must be 2 (chapter) or 4 (heading) ASCII digits, got '{0}'")] + InvalidFormat(String), +} + +/// A combined-nomenclature **chapter** (2 digits) or **heading** (4 digits). +/// +/// # Not [`CommodityCode`](crate::domain::commodity_code::CommodityCode) +/// +/// The two are different levels of the same nomenclature and must not be +/// substituted for one another. `CommodityCode` is a *product's own* +/// classification — 6, 8 or 10 digits — and answers "what is this thing". This +/// answers "which line of a disclosure does it belong on", and the applicable +/// act fixes the depth. +/// +/// **Commission Implementing Regulation (EU) 2026/2, Art. 3:** the disclosure of +/// discarded unsold consumer products "shall be delimited based on the **first +/// two digits** of the relevant combined nomenclature (CN) codes set out in +/// Annex I to Regulation (EEC) No 2658/87. However, the products listed in Annex +/// II to this Regulation shall be delimited based on the **first four digits**". +/// +/// So both depths are legitimate and which one is required depends on the +/// product. That test needs the Annex II list and lives in `dpp-rules`, not +/// here: this type refuses what is structurally malformed and makes no claim +/// about whether the depth is the right one — the same division of labour as +/// `CommodityCode` and [`Gtin`](crate::Gtin). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CnCategory(String); + +impl CnCategory { + /// Parse a CN chapter or heading. + /// + /// Surrounding whitespace is trimmed; separators are **not**. Compacting + /// `"62 03"` would turn a mistyped value into a different, valid heading — + /// the same reason `CommodityCode` refuses them. + /// + /// # Errors + /// [`CnCategoryError::InvalidFormat`] unless the trimmed input is exactly 2 + /// or 4 ASCII digits. + pub fn parse(s: &str) -> Result { + let trimmed = s.trim(); + let valid_length = matches!(trimmed.len(), 2 | 4); + if !valid_length || !trimmed.bytes().all(|b| b.is_ascii_digit()) { + return Err(CnCategoryError::InvalidFormat(s.to_owned())); + } + Ok(Self(trimmed.to_owned())) + } + + /// The category as stored. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The CN chapter — the first two digits, whichever depth this is. + /// + /// A heading always extends a chapter, so this is the part two categories at + /// different depths can be compared on. + #[must_use] + pub fn chapter(&self) -> &str { + &self.0[..2] + } + + /// Whether this is a 4-digit heading rather than a 2-digit chapter. + #[must_use] + pub fn is_heading(&self) -> bool { + self.0.len() == 4 + } +} + +impl std::fmt::Display for CnCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} diff --git a/crates/dpp-domain/src/domain/product_group/data/unsold_goods/entity.rs b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/entity.rs new file mode 100644 index 00000000..fc5f7746 --- /dev/null +++ b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/entity.rs @@ -0,0 +1,27 @@ +//! [`DisclosingEntity`] — the header block of the Annex I disclosure. + +use serde::{Deserialize, Serialize}; + +use super::identifier::LegalEntityIdentifier; +use super::scope::DisclosureScope; + +/// Who is making the disclosure. +/// +/// The first four rows of Annex I, Section 2. This is not passport data in the +/// ordinary sense — it identifies an **undertaking over a financial year**, not +/// a product placed on the market, which is the whole reason ESPR Arts. 24–25 +/// impose no passport and this product-group slot exists only for +/// implementation convenience. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DisclosingEntity { + /// Annex I note (a): "either the name of the standalone undertaking or, for + /// a subsidiary, **the name of the parent undertaking** of a group in the + /// case of a consolidated disclosure." + pub name: String, + /// Note (b): the EUID, or another officially recognised scheme where no EUID + /// is available. + pub identifier: LegalEntityIdentifier, + /// Note (c): standalone, or consolidated with its undertakings listed. + pub scope: DisclosureScope, +} diff --git a/crates/dpp-domain/src/domain/product_group/data/unsold_goods/financial_year.rs b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/financial_year.rs new file mode 100644 index 00000000..13202316 --- /dev/null +++ b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/financial_year.rs @@ -0,0 +1,44 @@ +//! [`FinancialYear`] — the period a disclosure covers. + +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +/// The financial year a disclosure reports on, by its start and end dates. +/// +/// # Why a financial year and not a calendar period +/// +/// **Commission Implementing Regulation (EU) 2026/2, Art. 1:** the Regulation +/// "shall apply to products discarded in **each financial year** as from the +/// first full financial year after the date of application of this Regulation. +/// Economic operators shall disclose that information **within 12 months after +/// the end of that financial year**." +/// +/// A financial year is the undertaking's own, so it is not derivable from a year +/// number and does not necessarily start in January. Annex I asks for both +/// endpoints as `dd/mm/yyyy` for exactly that reason, and the previous model's +/// free-text `"2026-Q2"` could not express it — a quarter is not a period this +/// disclosure is ever made for. +/// +/// Ordering is not enforced by the type. A start after its end is a malformed +/// disclosure that should be *reported*, not made unreadable; the check lives +/// with the other cross-field rules in `dpp-rules`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FinancialYear { + /// First day of the financial year. + pub start: NaiveDate, + /// Last day of the financial year. + pub end: NaiveDate, +} + +impl FinancialYear { + /// The date by which this year's disclosure is due — 12 months after the + /// end of the financial year, per Art. 1. + /// + /// `None` only if the addition overflows the calendar, which no real + /// financial year does. + #[must_use] + pub fn disclosure_due_by(&self) -> Option { + self.end.checked_add_months(chrono::Months::new(12)) + } +} diff --git a/crates/dpp-domain/src/domain/product_group/data/unsold_goods/identifier.rs b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/identifier.rs new file mode 100644 index 00000000..bb6cb660 --- /dev/null +++ b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/identifier.rs @@ -0,0 +1,44 @@ +//! [`LegalEntityIdentifier`] — how the disclosing undertaking is identified. + +use serde::{Deserialize, Serialize}; + +/// The identifier of the legal entity making the disclosure. +/// +/// **Annex I note (b) of Commission Implementing Regulation (EU) 2026/2:** it +/// "shall be the European unique identifier (`EUID`) established by Directive +/// (EU) 2017/1132 … or, **where not available**, any other identifier from an +/// officially recognised scheme in the Member State concerned." +/// +/// An enum rather than a string plus a type field, because the two arms are not +/// symmetric: EUID needs no scheme name and the alternative is meaningless +/// without one. Annex I prints exactly this as a checkbox — "Type of identifier: +/// EUID | Other, namely: ___" — where the blank exists only on the second arm. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +#[non_exhaustive] +pub enum LegalEntityIdentifier { + /// The European unique identifier, per Directive (EU) 2017/1132. + Euid { + /// The EUID itself. + value: String, + }, + /// An identifier from an officially recognised Member State scheme, used + /// only where no EUID is available. + Other { + /// The scheme the identifier belongs to — Annex I's "namely:" blank. + /// Without it the value cannot be resolved by a reader. + scheme: String, + /// The identifier itself. + value: String, + }, +} + +impl LegalEntityIdentifier { + /// The identifier value, whichever scheme it belongs to. + #[must_use] + pub fn value(&self) -> &str { + match self { + Self::Euid { value } | Self::Other { value, .. } => value, + } + } +} diff --git a/crates/dpp-domain/src/domain/product_group/data/unsold_goods/line.rs b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/line.rs new file mode 100644 index 00000000..b92561be --- /dev/null +++ b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/line.rs @@ -0,0 +1,104 @@ +//! [`DiscardedProductLine`] — one row of the Annex I disclosure table, and the +//! [`DiscardedQuantity`] that carries whether a figure was measured or estimated. + +use serde::{Deserialize, Serialize}; + +use super::cn_category::CnCategory; +use super::reason::DiscardReason; +use super::treatment::WasteTreatmentSplit; + +/// A whole-number quantity, and whether it was counted or estimated. +/// +/// **Annex I, Section 2:** "The format of the numbers shall not include +/// separators and the information shall be **rounded to the nearest whole +/// number**" — hence `u64` and not a float. +/// +/// Notes (f) and (g) allow either figure to be derived from the other — units +/// estimated from an accurately determined weight, or weight from an accurate +/// count — and then require that the estimate be marked: "Where estimates are +/// used, this should be clarified by accompanying the disclosed value with +/// `±`." +/// +/// A struct rather than two loose fields so a value cannot exist without its +/// provenance, and a provenance flag cannot be left orphaned when the value +/// moves. Same reasoning as `ObligationDate` in the instrument catalog. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscardedQuantity { + /// The figure, rounded to a whole number. + pub value: u64, + /// Whether `value` is an estimate. Renders with the `±` Annex I requires. + #[serde(default)] + pub estimated: bool, +} + +impl DiscardedQuantity { + /// A counted figure. + #[must_use] + pub fn measured(value: u64) -> Self { + Self { + value, + estimated: false, + } + } + + /// An estimated figure — displays with the `±` marker. + #[must_use] + pub fn estimated(value: u64) -> Self { + Self { + value, + estimated: true, + } + } +} + +impl std::fmt::Display for DiscardedQuantity { + /// Renders as Annex I requires: no separators, and `±` where estimated. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.estimated { + write!(f, "±{}", self.value) + } else { + write!(f, "{}", self.value) + } + } +} + +/// One row of the Annex I table: a product category, discarded in one financial +/// year, for one reason. +/// +/// # One line per reason, not per category +/// +/// **Annex I note (h):** "If units of the same product category are discarded +/// for **different reasons**, a separate line is necessary for each reason, +/// indicating the number and weight of units for each reason." So a category may +/// legitimately appear on several lines, and [`Self::reason`] is singular by +/// design rather than a set. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscardedProductLine { + /// The CN chapter or heading(s) this line covers — note (d), and Art. 3 for + /// which depth applies. + /// + /// Plural because note (f) allows it: "Multiple items sold together, such as + /// an electric drill with drill bits, cosmetic kits or first aid kits, may be + /// considered as one unit and may, where appropriate, **indicate more than + /// one CN code**." + pub cn_categories: Vec, + /// Note (e): "established on the basis of the combined nomenclature … or a + /// more detailed description". + pub description: String, + /// Note (f): total units discarded in the period, for this category. + pub units_discarded: DiscardedQuantity, + /// Note (g): combined weight of those units, in kilogrammes. + pub weight_kg: DiscardedQuantity, + /// Whether packaging is included in [`Self::weight_kg`] — its own column in + /// Annex I, because the answer changes what the weight means. + pub packaging_included: bool, + /// Note (h), and the closed list in Del. Reg. (EU) 2026/296 Art. 2. + pub reason: DiscardReason, + /// A more detailed explanation, which note (h) permits alongside the reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason_detail: Option, + /// Note (i): where the line actually went, as percentages of weight. + pub treatment: WasteTreatmentSplit, +} diff --git a/crates/dpp-domain/src/domain/product_group/data/unsold_goods/mod.rs b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/mod.rs new file mode 100644 index 00000000..b3c62fbc --- /dev/null +++ b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/mod.rs @@ -0,0 +1,48 @@ +//! Unsold consumer products — the disclosure required by ESPR Arts. 24–25, in +//! the format its implementing act prescribes. +//! +//! # The two acts this models +//! +//! - **Commission Implementing Regulation (EU) 2026/2** (CELEX `32026R0002`), +//! made under ESPR Art. 24(3) — the details and format of the disclosure. +//! Art. 2(1) binds it to **Annex I**; Art. 3 delimits categories by CN code. +//! - **Commission Delegated Regulation (EU) 2026/296** (CELEX `32026R0296`), +//! made under ESPR Art. 25(5) — the closed list of derogations from the +//! destruction prohibition, which Annex I note (h) makes the reason vocabulary. +//! +//! Both were adopted on 9 February 2026. The model here predates neither any +//! more. +//! +//! # Layout +//! +//! - [`report`] — [`UnsoldGoodsReport`], the whole disclosure. +//! - [`entity`] / [`identifier`] / [`scope`] — who is disclosing, and for whom. +//! - [`financial_year`] — the period, which is the undertaking's own. +//! - [`mod@line`] — one row of the Annex I table, plus [`DiscardedQuantity`]. +//! (Disambiguated: `line` is also `core`'s `line!` macro.) +//! - [`cn_category`] — the CN chapter or heading a line is filed under. +//! - [`reason`] — the Del. Reg. 2026/296 Art. 2 derogations. +//! - [`treatment`] — the six-way percentage split, and the derived total. + +pub mod cn_category; +pub mod entity; +pub mod financial_year; +pub mod identifier; +pub mod line; +pub mod reason; +pub mod report; +pub mod scope; +pub mod treatment; + +#[cfg(test)] +mod tests; + +pub use cn_category::{CnCategory, CnCategoryError}; +pub use entity::DisclosingEntity; +pub use financial_year::FinancialYear; +pub use identifier::LegalEntityIdentifier; +pub use line::{DiscardedProductLine, DiscardedQuantity}; +pub use reason::DiscardReason; +pub use report::UnsoldGoodsReport; +pub use scope::DisclosureScope; +pub use treatment::WasteTreatmentSplit; diff --git a/crates/dpp-domain/src/domain/product_group/data/unsold_goods/reason.rs b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/reason.rs new file mode 100644 index 00000000..cc4ac8f4 --- /dev/null +++ b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/reason.rs @@ -0,0 +1,121 @@ +//! [`DiscardReason`] — the closed list of circumstances under which an unsold +//! consumer product may lawfully be destroyed. + +use serde::{Deserialize, Serialize}; + +/// Why a line of unsold consumer products was discarded. +/// +/// # This list is the law's, not ours +/// +/// **Annex I note (h) of Commission Implementing Regulation (EU) 2026/2:** +/// reasons for discarding "shall, where applicable, refer to the reasons listed +/// in delegated acts adopted pursuant to Article 25(5) of Regulation (EU) +/// 2024/1781". That delegated act is **Commission Delegated Regulation (EU) +/// 2026/296**, and its **Article 2** enumerates the derogations from the +/// destruction prohibition, points (a) to (j). The variants below are those +/// points, in that order. +/// +/// The previous model carried an invented set — `EndOfSeason`, `QualityDefect`, +/// `PackagingDefect`, `OverProduction`, `CustomerReturn`, `Other` — none of +/// which appears in the Regulation. Two of them (`EndOfSeason`, +/// `OverProduction`) name commercial circumstances that are *not* derogations at +/// all, so a disclosure using them asserted a lawful destruction that the act +/// does not permit. +/// +/// # Two constraints that are not in the type +/// +/// **Point (h) is subordinate.** It applies "only where none of the +/// circumstances referred to in points (a) to (g) are applicable" — a condition +/// over the whole set of available reasons, which a single enum value cannot +/// express. It is checked in `dpp-rules`. +/// +/// **Every reason carries a documentation duty.** Art. 3 of the same act +/// requires the operator to keep specified evidence per derogation for **five +/// years** after destruction, in electronic form, produced within 30 days of a +/// competent authority's request. That evidence is not passport data and is not +/// modelled here; the reason recorded is the claim, not the proof of it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum DiscardReason { + /// **(a)** A dangerous product within the meaning of Regulation (EU) 2023/988. + DangerousProduct, + /// **(b)** Unfit for purpose because non-compliant with Union or national + /// law, for reasons other than (a), where destruction is required by law or + /// is the appropriate and proportionate corrective action. + NonCompliantWithLaw, + /// **(c)** Found to infringe intellectual property rights by judicial or ADR + /// decision, rightsholder notification, or a substantiated internal + /// investigation. + IntellectualPropertyInfringement, + /// **(d)** Subject to an IP-protecting licence whose permitted period for + /// sale or transfer has expired. + LicensedPeriodExpired, + /// **(e)** Unsuitable for preparing for reuse or remanufacturing because + /// protected or inappropriate labels, logos or design characteristics cannot + /// technically be removed or rendered inaccessible. + MarkingsCannotBeRemoved, + /// **(f)** Reasonably unacceptable for consumer use through damage, + /// deterioration or contamination, where repair and refurbishment are not + /// technically feasible or cost-effective. + DamagedOrContaminated, + /// **(g)** Unfit for its intended purpose through a design or manufacturing + /// defect for which repair is not technically feasible. + DefectiveBeyondRepair, + /// **(h)** Offered for donation — to at least three suitable social economy + /// entities in the Union, or on an easily accessible page of the operator's + /// website, for at least eight weeks — and not accepted. + /// + /// Available **only** where none of (a) to (g) applies. + OfferedForDonationNotAccepted, + /// **(i)** Received as a donation by a social economy entity in the Union, + /// but no recipient could be found. + DonatedButNoRecipientFound, + /// **(j)** Made available on the market after being prepared for reuse by a + /// waste treatment operator, but no recipient could be found. + ReusedButNoRecipientFound, +} + +impl DiscardReason { + /// Every reason, in the order Del. Reg. (EU) 2026/296 Art. 2 lists them. + pub const ALL: &'static [Self] = &[ + Self::DangerousProduct, + Self::NonCompliantWithLaw, + Self::IntellectualPropertyInfringement, + Self::LicensedPeriodExpired, + Self::MarkingsCannotBeRemoved, + Self::DamagedOrContaminated, + Self::DefectiveBeyondRepair, + Self::OfferedForDonationNotAccepted, + Self::DonatedButNoRecipientFound, + Self::ReusedButNoRecipientFound, + ]; + + /// The point of Del. Reg. (EU) 2026/296 Art. 2 this reason is. + /// + /// Kept so a disclosure, a determination or an error message can cite the + /// act rather than our own name for it. + #[must_use] + pub fn article_2_point(self) -> char { + match self { + Self::DangerousProduct => 'a', + Self::NonCompliantWithLaw => 'b', + Self::IntellectualPropertyInfringement => 'c', + Self::LicensedPeriodExpired => 'd', + Self::MarkingsCannotBeRemoved => 'e', + Self::DamagedOrContaminated => 'f', + Self::DefectiveBeyondRepair => 'g', + Self::OfferedForDonationNotAccepted => 'h', + Self::DonatedButNoRecipientFound => 'i', + Self::ReusedButNoRecipientFound => 'j', + } + } + + /// Whether this reason is available only when no other applies — true for + /// point (h) alone, whose text begins "only where none of the circumstances + /// referred to in points (a) to (g) are applicable". + #[must_use] + pub fn is_subordinate(self) -> bool { + matches!(self, Self::OfferedForDonationNotAccepted) + } +} diff --git a/crates/dpp-domain/src/domain/product_group/data/unsold_goods/report.rs b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/report.rs new file mode 100644 index 00000000..015f73ec --- /dev/null +++ b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/report.rs @@ -0,0 +1,95 @@ +//! [`UnsoldGoodsReport`] — the whole Annex I disclosure. + +use serde::{Deserialize, Serialize}; + +use super::entity::DisclosingEntity; +use super::financial_year::FinancialYear; +use super::line::DiscardedProductLine; + +/// The disclosure of information on discarded unsold consumer products. +/// +/// # What this is, and what it is not +/// +/// This is **not a passport**. ESPR Arts. 24–25 impose a duty on an *economic +/// operator* over a *financial year*, disclosed on the operator's own website — +/// or, where it publishes sustainability reporting under Directive 2013/34/EU +/// Art. 19a or 29a, by a link to that report naming where the information sits +/// (Impl. Reg. (EU) 2026/2 Art. 2(2)). There is no product record anywhere in +/// it, which is why `unsold-goods` carries `PassportObligation::NotRequired` +/// while still being in force and determinable today. +/// +/// It occupies a product-group slot for implementation convenience, not because +/// it is a product group. +/// +/// # The format is prescribed +/// +/// **Art. 2(1):** "The visual presentation and content of the disclosure … shall +/// comply with the format set out in **Annex I**." The structure below is that +/// table: a header identifying the undertaking and its financial year, a +/// repeating body of product lines, and two narrative rows. +/// +/// Two formatting rules from Section 2 that a renderer must honour and a type +/// cannot: numbers carry **no separators**, and every figure is **rounded to the +/// nearest whole number**. See [`DiscardedQuantity`](super::line::DiscardedQuantity) +/// for the estimate marker. +/// +/// # This shape replaced an invented one +/// +/// The previous `UnsoldGoodsReport` predated both acts. It reported a free-text +/// quarter rather than a financial year, categorised by names like `"apparel"` +/// where Art. 3 requires CN codes, recorded a single destination where Annex I +/// requires a six-way percentage split, carried no unit count, no packaging +/// flag, no entity header and neither narrative row — and its reason list was +/// ours rather than the Regulation's. It is not a migration of that model; the +/// axes are different. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnsoldGoodsReport { + /// The undertaking making the disclosure — Annex I's first four rows. + pub entity: DisclosingEntity, + /// The financial year disclosed, by its own start and end dates. + pub financial_year: FinancialYear, + /// The body of the table. "Additional lines may be added as necessary" + /// (Annex I, Section 2), and note (h) requires a separate line per reason + /// within a category. + pub lines: Vec, + /// Annex I note (i): measures taken to prevent destruction, which "shall + /// include measures taken in the **preceding** financial year and must be + /// based, where relevant, on the information on unsold consumer products + /// destroyed in the past". + pub measures_taken: String, + /// Annex I note (j): measures planned, which "shall include measures for + /// implementation in the future" and "in particular … specific measures + /// necessary to prevent the destruction of the categories of products + /// destroyed in the preceding financial year for the same reasons, and + /// describe how the measures are expected to achieve that purpose". + pub measures_planned: String, +} + +impl UnsoldGoodsReport { + /// Total weight discarded across every line, in kilogrammes. + /// + /// Sums the disclosed figures whether measured or estimated — the split + /// between the two is a property of each line, and flattening it into one + /// number here would assert a precision the lines do not have. Callers + /// needing that distinction should read the lines. + #[must_use] + pub fn total_weight_kg(&self) -> u64 { + self.lines.iter().map(|l| l.weight_kg.value).sum() + } + + /// Total units discarded across every line. + #[must_use] + pub fn total_units(&self) -> u64 { + self.lines.iter().map(|l| l.units_discarded.value).sum() + } + + /// Whether any line reports an estimated figure, which Annex I requires be + /// shown with `±` wherever it appears. + #[must_use] + pub fn contains_estimates(&self) -> bool { + self.lines + .iter() + .any(|l| l.units_discarded.estimated || l.weight_kg.estimated) + } +} diff --git a/crates/dpp-domain/src/domain/product_group/data/unsold_goods/scope.rs b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/scope.rs new file mode 100644 index 00000000..af95d3db --- /dev/null +++ b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/scope.rs @@ -0,0 +1,36 @@ +//! [`DisclosureScope`] — whether the disclosure covers one undertaking or a group. + +use serde::{Deserialize, Serialize}; + +/// Whether this disclosure speaks for one undertaking or for a group. +/// +/// **Annex I note (c) of Commission Implementing Regulation (EU) 2026/2:** "In +/// the case of a consolidated disclosure, the subsidiaries discarding unsold +/// consumer products **shall be listed** in addition to the parent undertaking. +/// In the case of other groups consisting of independent undertakings and a +/// central organisation supporting the group … with a common brand name, +/// consolidated disclosure may take place on a shared website, provided that the +/// member undertakings are listed." +/// +/// The list is not optional decoration on the consolidated arm — without it a +/// reader cannot tell which undertakings a figure covers, and the same tonnage +/// could be disclosed by a parent and omitted by every subsidiary with nothing +/// visible. So it is a field of the variant, and a standalone disclosure has no +/// place to put one. +/// +/// Note also what note (a) does to the name: for a subsidiary in a consolidated +/// disclosure, the entity name is **the parent's**, not the subsidiary's. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +#[non_exhaustive] +pub enum DisclosureScope { + /// One undertaking, disclosing for itself. + Standalone, + /// A parent undertaking disclosing for a group. + Consolidated { + /// The subsidiaries or member undertakings this disclosure covers. + /// Required by note (c); an empty list is a malformed consolidated + /// disclosure rather than a group of none. + undertakings: Vec, + }, +} diff --git a/crates/dpp-domain/src/domain/product_group/data/unsold_goods/tests.rs b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/tests.rs new file mode 100644 index 00000000..32fa80f8 --- /dev/null +++ b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/tests.rs @@ -0,0 +1,235 @@ +//! Tests for the unsold-goods disclosure model. + +use chrono::NaiveDate; +use serde_json::json; + +use super::*; + +fn line() -> DiscardedProductLine { + DiscardedProductLine { + cn_categories: vec![CnCategory::parse("6203").unwrap()], + description: "Men's suits, ensembles, jackets and trousers".to_owned(), + 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, + }, + } +} + +fn report() -> UnsoldGoodsReport { + UnsoldGoodsReport { + entity: DisclosingEntity { + name: "Example Retail Group SA".to_owned(), + identifier: LegalEntityIdentifier::Euid { + value: "LUB123456789".to_owned(), + }, + scope: DisclosureScope::Standalone, + }, + financial_year: FinancialYear { + start: NaiveDate::from_ymd_opt(2027, 4, 1).unwrap(), + end: NaiveDate::from_ymd_opt(2028, 3, 31).unwrap(), + }, + lines: vec![line()], + measures_taken: "Introduced pre-season demand forecasting.".to_owned(), + measures_planned: "Extending the donation window to twelve weeks.".to_owned(), + } +} + +// ── CN category ───────────────────────────────────────────────────────────── + +/// Art. 3 allows exactly two depths: the CN chapter and the CN heading. +#[test] +fn both_depths_article_3_allows_parse() { + let chapter = CnCategory::parse("62").expect("chapter"); + assert_eq!(chapter.as_str(), "62"); + assert!(!chapter.is_heading()); + + let heading = CnCategory::parse("6203").expect("heading"); + assert_eq!(heading.as_str(), "6203"); + assert!(heading.is_heading()); + assert_eq!(heading.chapter(), "62"); +} + +/// A product's own 6/8/10-digit code is not a disclosure category. Accepting one +/// would file a whole chapter's worth of goods under a single article. +#[test] +fn a_commodity_code_is_not_a_cn_category() { + for code in ["620342", "62034231", "6203423100"] { + assert!( + CnCategory::parse(code).is_err(), + "{code} must not parse as a CN category" + ); + } +} + +/// Compacting `"62 03"` would turn a mistyped value into a different, valid +/// heading — so separators are refused, never stripped. +#[test] +fn separators_are_refused_not_stripped() { + for code in ["62 03", "62.03", "62-03", ""] { + assert!(CnCategory::parse(code).is_err(), "{code} must not parse"); + } + assert_eq!(CnCategory::parse(" 6203 ").unwrap().as_str(), "6203"); +} + +// ── The reason vocabulary ─────────────────────────────────────────────────── + +/// Del. Reg. (EU) 2026/296 Art. 2 enumerates points (a) to (j). If the list here +/// is ever a different length, one of them has been dropped or invented. +#[test] +fn the_reason_list_is_article_2_points_a_to_j() { + assert_eq!(DiscardReason::ALL.len(), 10); + let points: Vec = DiscardReason::ALL + .iter() + .map(|r| r.article_2_point()) + .collect(); + assert_eq!( + points, + vec!['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] + ); +} + +/// Point (h) alone begins "only where none of the circumstances referred to in +/// points (a) to (g) are applicable". +#[test] +fn only_point_h_is_subordinate() { + let subordinate: Vec = DiscardReason::ALL + .iter() + .filter(|r| r.is_subordinate()) + .map(|r| r.article_2_point()) + .collect(); + assert_eq!(subordinate, vec!['h']); +} + +// ── The treatment split ───────────────────────────────────────────────────── + +/// Annex I note (i): "Destruction is the sum of recycling, other recovery and +/// disposal." Preparing for reuse and unknown are outside it — which is not the +/// intuitive reading, and is why this is asserted rather than assumed. +#[test] +fn destruction_is_recycling_plus_other_recovery_plus_disposal() { + let split = line().treatment; + assert_eq!(split.total_destruction_pct(), 75); + assert_eq!(split.total_pct(), 100); +} + +/// The shares are `u8`, so three of them can exceed `u8::MAX` in a malformed +/// record. The sum widens rather than wrapping or panicking: an impossible +/// number a caller can see beats a plausible one it cannot. +#[test] +fn a_malformed_split_widens_rather_than_wrapping() { + let split = WasteTreatmentSplit { + preparing_for_reuse_pct: 0, + recycling_pct: 200, + other_recovery_pct: 200, + disposal_pct: 200, + unknown_pct: 0, + }; + assert_eq!(split.total_destruction_pct(), 600); +} + +// ── Quantities ────────────────────────────────────────────────────────────── + +/// Annex I notes (f) and (g): an estimate is shown "accompanying the disclosed +/// value with `±`", and Section 2 forbids separators. +#[test] +fn an_estimate_renders_with_the_annex_i_marker() { + assert_eq!(DiscardedQuantity::estimated(430).to_string(), "±430"); + assert_eq!(DiscardedQuantity::measured(1_200).to_string(), "1200"); +} + +// ── The report ────────────────────────────────────────────────────────────── + +/// Art. 1: disclosure is due "within 12 months after the end of that financial +/// year". +#[test] +fn the_disclosure_deadline_is_twelve_months_after_the_year_end() { + let due = report().financial_year.disclosure_due_by().unwrap(); + assert_eq!(due, NaiveDate::from_ymd_opt(2029, 3, 31).unwrap()); +} + +#[test] +fn totals_aggregate_across_lines() { + let mut r = report(); + r.lines.push(line()); + assert_eq!(r.total_units(), 2_400); + assert_eq!(r.total_weight_kg(), 860); + assert!(r.contains_estimates()); +} + +// ── Wire format ───────────────────────────────────────────────────────────── + +#[test] +fn the_report_round_trips() { + let original = report(); + let json = serde_json::to_string(&original).expect("serialise"); + let back: UnsoldGoodsReport = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(back, original); +} + +/// The wire is camelCase and the two tagged enums carry a `type` discriminant, +/// which is what Annex I's checkbox rows become. +#[test] +fn the_wire_keys_are_camel_case_and_tagged() { + let value = serde_json::to_value(report()).unwrap(); + assert_eq!(value["financialYear"]["start"], json!("2027-04-01")); + assert_eq!(value["entity"]["identifier"]["type"], json!("euid")); + assert_eq!(value["entity"]["scope"]["type"], json!("standalone")); + assert!(value["measuresTaken"].is_string()); + + let line = &value["lines"][0]; + assert_eq!(line["cnCategories"], json!(["6203"])); + assert_eq!(line["packagingIncluded"], json!(false)); + assert_eq!(line["reason"], json!("damagedOrContaminated")); + assert_eq!(line["weightKg"]["estimated"], json!(true)); + assert_eq!(line["treatment"]["recyclingPct"], json!(50)); +} + +/// Total destruction is derived from three fields the act defines it in terms +/// of, so it must not appear on the wire as a fourth. +#[test] +fn total_destruction_is_not_a_stored_field() { + let value = serde_json::to_value(report()).unwrap(); + let treatment = &value["lines"][0]["treatment"]; + assert!( + treatment.get("totalDestructionPct").is_none(), + "totalDestructionPct must be derived, not stored: {treatment}" + ); +} + +/// A consolidated disclosure has to name its undertakings — note (c) — and the +/// standalone arm has nowhere to put them. +#[test] +fn a_consolidated_scope_carries_its_undertakings() { + let scope = DisclosureScope::Consolidated { + undertakings: vec!["Sub One SARL".to_owned(), "Sub Two GmbH".to_owned()], + }; + let value = serde_json::to_value(&scope).unwrap(); + assert_eq!(value["type"], json!("consolidated")); + assert_eq!(value["undertakings"][1], json!("Sub Two GmbH")); + + let back: DisclosureScope = serde_json::from_value(value).unwrap(); + assert_eq!(back, scope); +} + +/// Where no EUID is available the scheme has to travel with the value, or a +/// reader cannot resolve it. +#[test] +fn a_non_euid_identifier_carries_its_scheme() { + let id = LegalEntityIdentifier::Other { + scheme: "SE-Bolagsverket".to_owned(), + value: "5560000000".to_owned(), + }; + assert_eq!(id.value(), "5560000000"); + let value = serde_json::to_value(&id).unwrap(); + assert_eq!(value["type"], json!("other")); + assert_eq!(value["scheme"], json!("SE-Bolagsverket")); +} diff --git a/crates/dpp-domain/src/domain/product_group/data/unsold_goods/treatment.rs b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/treatment.rs new file mode 100644 index 00000000..03ba989c --- /dev/null +++ b/crates/dpp-domain/src/domain/product_group/data/unsold_goods/treatment.rs @@ -0,0 +1,78 @@ +//! [`WasteTreatmentSplit`] — where a discarded line actually went, as +//! percentages of weight. + +use serde::{Deserialize, Serialize}; + +/// How a discarded product line was treated, split across the operations +/// Annex I of Commission Implementing Regulation (EU) 2026/2 names. +/// +/// # Percentages of weight, not of units +/// +/// **Annex I note (i):** "The percentages of disclosed waste treatment +/// operations shall be calculated on the basis of the **weight** of discarded +/// unsold consumer products." The unit count on the line plays no part in this +/// split. +/// +/// The same note requires the information to be "retrieved from waste treatment +/// operators that collect unsold consumer products", and where it cannot be +/// obtained, treatment "shall be listed as `unknown`". [`Self::unknown_pct`] is +/// therefore a real answer, not a missing one — it says the operator asked and +/// could not find out, which is a different statement from an incomplete +/// disclosure. +/// +/// # Total destruction is derived, never stored +/// +/// Annex I prints a "Total destruction (in %)" column, and note (i) defines it: +/// "**Destruction is the sum of recycling, other recovery and disposal.**" It is +/// therefore computed by [`Self::total_destruction_pct`] and has no field. +/// Storing it would create a second home for a number the act already defines in +/// terms of three others, and the two could disagree. +/// +/// Note what that definition puts *outside* destruction: preparing for reuse, +/// and unknown. Recycling is inside it. That is the act's arithmetic and it is +/// not the intuitive one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WasteTreatmentSplit { + /// Preparing for reuse, as defined in Directive 2008/98/EC Art. 3(16). + pub preparing_for_reuse_pct: u8, + /// Recycling, as defined in Directive 2008/98/EC Art. 3(17). + pub recycling_pct: u8, + /// Other recovery — e.g. energy recovery — per Directive 2008/98/EC Art. 3(15). + pub other_recovery_pct: u8, + /// Disposal, as defined in Directive 2008/98/EC Art. 3(19). + pub disposal_pct: u8, + /// The share whose treatment the operator could not establish. Annex I note + /// (i) provides for this explicitly. + pub unknown_pct: u8, +} + +impl WasteTreatmentSplit { + /// Total destruction: **recycling + other recovery + disposal**, per Annex I + /// note (i). + /// + /// Returns `u16` rather than `u8` deliberately — three `u8` shares can sum + /// past 255 in a malformed record, and a panic or a silent wrap is a worse + /// answer than a number the caller can see is impossible. + #[must_use] + pub fn total_destruction_pct(&self) -> u16 { + u16::from(self.recycling_pct) + + u16::from(self.other_recovery_pct) + + u16::from(self.disposal_pct) + } + + /// The sum of every share, which a well-formed split makes 100. + /// + /// Not asserted here: this type describes what was disclosed, and refusing + /// to represent a disclosure that does not add up would make an invalid + /// record unreadable rather than reportable. The check belongs with the + /// other cross-field rules in `dpp-rules`. + #[must_use] + pub fn total_pct(&self) -> u16 { + u16::from(self.preparing_for_reuse_pct) + + u16::from(self.recycling_pct) + + u16::from(self.other_recovery_pct) + + u16::from(self.disposal_pct) + + u16::from(self.unknown_pct) + } +} diff --git a/crates/dpp-domain/src/domain/product_group/mod.rs b/crates/dpp-domain/src/domain/product_group/mod.rs index 2af218ad..83581ee7 100644 --- a/crates/dpp-domain/src/domain/product_group/mod.rs +++ b/crates/dpp-domain/src/domain/product_group/mod.rs @@ -26,14 +26,17 @@ pub mod validation; #[cfg(test)] mod tests; +pub use data::unsold_goods::{ + CnCategory, CnCategoryError, DiscardReason, DiscardedProductLine, DiscardedQuantity, + DisclosingEntity, DisclosureScope, FinancialYear, LegalEntityIdentifier, WasteTreatmentSplit, +}; pub use data::{ AluminiumData, BatteryData, ConstructionData, CriticalRawMaterial, DetergentData, DynamicPerformance, ElectronicsData, EnvironmentalReading, ExpectedLifetime, FibreEntry, FurnitureData, HarmfulEvents, HazardSymbol, HazardousSubstance, MaterialComposition, MattressData, ProductGroupData, StateOfChargeReading, StateOfHealth, SteelData, SurfactantEntry, SvhcSubstance, TemperatureRange, TextileData, ToyData, TyreData, - UnsoldGoodsDestination, UnsoldGoodsReason, UnsoldGoodsReport, UsageHistory, - redact_product_group_data, + UnsoldGoodsReport, UsageHistory, redact_product_group_data, }; pub use enums::{ BatteryChemistry, BatteryStatus, BatteryType, CarbonFootprintClass, CarbonFootprintClassError, @@ -43,6 +46,6 @@ pub use metrics::{CarbonFootprint, RepairCriterion, RepairabilityScore}; pub use product_group::ProductGroup; pub use validation::{ battery_recycled_chemistry_conflicts, unsold_goods_annex_vii_heading, - unsold_goods_category_matches_heading, validate_battery_operating_temp, - validate_fibre_composition, validate_surfactants, validate_svhc_substances, + unsold_goods_cn_depth_is_correct, validate_battery_operating_temp, validate_fibre_composition, + validate_surfactants, validate_svhc_substances, }; diff --git a/crates/dpp-domain/src/domain/product_group/tests.rs b/crates/dpp-domain/src/domain/product_group/tests.rs index 545ae160..7019e054 100644 --- a/crates/dpp-domain/src/domain/product_group/tests.rs +++ b/crates/dpp-domain/src/domain/product_group/tests.rs @@ -624,16 +624,7 @@ fn sample_detergent_data() -> ProductGroupData { } fn sample_unsold_goods_data() -> ProductGroupData { - ProductGroupData::UnsoldGoods(UnsoldGoodsReport { - reporting_period: "2026-Q3".into(), - volume_kg: 120.0, - product_category: "apparel".into(), - reason: UnsoldGoodsReason::EndOfSeason, - destination: UnsoldGoodsDestination::Donation, - destruction_justification: None, - country_of_disposal: "DE".into(), - operator_name: Some("Caritas Berlin".into()), - }) + ProductGroupData::UnsoldGoods(crate::test_support::sample_unsold_goods_report()) } #[test] diff --git a/crates/dpp-domain/src/domain/product_group/validation.rs b/crates/dpp-domain/src/domain/product_group/validation.rs index b2d2c26d..8841626f 100644 --- a/crates/dpp-domain/src/domain/product_group/validation.rs +++ b/crates/dpp-domain/src/domain/product_group/validation.rs @@ -71,15 +71,11 @@ pub fn unsold_goods_annex_vii_heading( dpp_rules::unsold_goods::annex_vii::annex_vii_heading(commodity_code) } -/// Whether a declared `UnsoldGoodsReport.product_category` word is -/// consistent with the Annex VII heading a passport's `commodity_code` -/// falls under. Delegates to [`dpp_rules`]. +/// Whether a disclosure line's CN category is filed at the depth Impl. Reg. +/// (EU) 2026/2 Art. 3 requires for it. Delegates to [`dpp_rules`]. #[must_use] -pub fn unsold_goods_category_matches_heading( - product_category: &str, - heading: dpp_rules::unsold_goods::annex_vii::AnnexViiHeading, -) -> bool { - dpp_rules::unsold_goods::annex_vii::product_category_matches_heading(product_category, heading) +pub fn unsold_goods_cn_depth_is_correct(cn_category: &str) -> bool { + dpp_rules::unsold_goods::disclosure::cn_depth_is_correct(cn_category) } /// Validate a detergent surfactant list. Delegates to [`dpp_rules`]. diff --git a/crates/dpp-domain/src/lib.rs b/crates/dpp-domain/src/lib.rs index 7d8d1d87..e01023c4 100644 --- a/crates/dpp-domain/src/lib.rs +++ b/crates/dpp-domain/src/lib.rs @@ -46,16 +46,63 @@ pub use domain::{ PassportId, PassportView, RETENTION_MUTABLE_FIELDS, }, product_group::{ - AluminiumData, BatteryChemistry, BatteryData, BatteryStatus, BatteryType, CarbonFootprint, - CarbonFootprintClass, CarbonFootprintClassError, ConstructionData, DetergentData, - DeviceType, DynamicPerformance, ElectronicsData, EnergyEfficiencyClass, - EnvironmentalReading, ExpectedLifetime, FibreEntry, FurnitureData, HarmfulEvents, - HazardSymbol, HazardousSubstance, LifecycleStage, MaterialComposition, MattressData, - ProductGroup, ProductGroupData, ProductionRoute, RepairCriterion, RepairabilityScore, - StateOfChargeReading, StateOfHealth, SteelData, SurfactantEntry, SvhcSubstance, - SystemBoundary, TemperatureRange, TextileData, ToyData, TyreData, UnsoldGoodsDestination, - UnsoldGoodsReason, UnsoldGoodsReport, UsageHistory, redact_product_group_data, - validate_fibre_composition, validate_surfactants, validate_svhc_substances, + AluminiumData, + BatteryChemistry, + BatteryData, + BatteryStatus, + BatteryType, + CarbonFootprint, + CarbonFootprintClass, + CarbonFootprintClassError, + // The unsold-goods disclosure, whose shape is fixed by Impl. Reg. (EU) + // 2026/2 Annex I — see `domain::product_group::data::unsold_goods`. + CnCategory, + CnCategoryError, + ConstructionData, + DetergentData, + DeviceType, + DiscardReason, + DiscardedProductLine, + DiscardedQuantity, + DisclosingEntity, + DisclosureScope, + DynamicPerformance, + ElectronicsData, + EnergyEfficiencyClass, + EnvironmentalReading, + ExpectedLifetime, + FibreEntry, + FinancialYear, + FurnitureData, + HarmfulEvents, + HazardSymbol, + HazardousSubstance, + LegalEntityIdentifier, + LifecycleStage, + MaterialComposition, + MattressData, + ProductGroup, + ProductGroupData, + ProductionRoute, + RepairCriterion, + RepairabilityScore, + StateOfChargeReading, + StateOfHealth, + SteelData, + SurfactantEntry, + SvhcSubstance, + SystemBoundary, + TemperatureRange, + TextileData, + ToyData, + TyreData, + UnsoldGoodsReport, + UsageHistory, + WasteTreatmentSplit, + redact_product_group_data, + validate_fibre_composition, + validate_surfactants, + validate_svhc_substances, }, product_identity::ProductIdentity, status::PassportStatus, diff --git a/crates/dpp-domain/src/schemas/embedded.rs b/crates/dpp-domain/src/schemas/embedded.rs index 2f4493ca..aeacdb1f 100644 --- a/crates/dpp-domain/src/schemas/embedded.rs +++ b/crates/dpp-domain/src/schemas/embedded.rs @@ -2,13 +2,13 @@ use semver::Version; use super::{SchemaEntry, SchemaOrigin}; -struct EmbeddedSchema { - product_group: &'static str, - version: &'static str, - json: &'static str, +pub(crate) struct EmbeddedSchema { + pub(crate) product_group: &'static str, + pub(crate) version: &'static str, + pub(crate) json: &'static str, } -const EMBEDDED: &[EmbeddedSchema] = &[ +pub(crate) const EMBEDDED: &[EmbeddedSchema] = &[ EmbeddedSchema { product_group: "battery", version: "1.0.0", @@ -64,10 +64,17 @@ const EMBEDDED: &[EmbeddedSchema] = &[ version: "1.2.0", json: include_str!("../../schemas/textile/v1.2.0.json"), }, + // No v1.0.0. It predated Impl. Reg. (EU) 2026/2 and nothing can carry a + // document forward from it: a financial year is not derivable from a + // quarter, a CN code is not derivable from the word "apparel", a six-way + // treatment split is not derivable from one destination, and its reason + // list has no member in common with the Art. 2 derogations. A lens would + // have to invent every one of those, so the version was removed rather than + // migrated. Safe only because nothing has ever been stored under it. EmbeddedSchema { product_group: "unsold-goods", - version: "1.0.0", - json: include_str!("../../schemas/unsold-goods/v1.0.0.json"), + version: "2.0.0", + json: include_str!("../../schemas/unsold-goods/v2.0.0.json"), }, EmbeddedSchema { product_group: "steel", diff --git a/crates/dpp-domain/src/schemas/tests.rs b/crates/dpp-domain/src/schemas/tests.rs index a786e42a..d222c804 100644 --- a/crates/dpp-domain/src/schemas/tests.rs +++ b/crates/dpp-domain/src/schemas/tests.rs @@ -6,13 +6,11 @@ use semver::Version; #[test] fn registry_loads_all_embedded_schemas() { let reg = VersionedSchemaRegistry::new(); - // battery 1.0 + 2.0 + 2.1 + 2.2 + 2.3 + 2.4 + 2.5 + 2.6, - // textile 1.0 + 1.1 + 1.2, unsold-goods 1.0, - // steel 1.0 + 1.1, electronics 1.0 + 1.1 + 1.2, construction 1.0 + 1.1, - // tyre 1.0, toy 1.0 + 1.1, aluminium 1.0 + 1.1, furniture 1.0 + 1.1 + 1.2, - // mattress 1.0, - // detergent 1.0 + 1.1 - assert_eq!(reg.len(), 30); + // Derived from the embedded table rather than written out. The count and + // the per-product-group list that used to sit here went stale the first + // time a schema version landed, which is the whole failure this asserts + // against: `new()` must load every embedded schema, whatever there are. + assert_eq!(reg.len(), super::embedded::EMBEDDED.len()); } #[test] @@ -93,7 +91,7 @@ fn register_new_schema_succeeds() { let mut reg = VersionedSchemaRegistry::new(); let schema = r#"{"type": "object", "properties": {"gtin": {"type": "string"}}}"#; assert!(reg.register("plastics", "1.0.0", schema.to_owned()).is_ok()); - assert_eq!(reg.len(), 31); + assert_eq!(reg.len(), super::embedded::EMBEDDED.len() + 1); let entry = reg .get_entry("plastics", &"1.0.0".parse().unwrap()) @@ -159,7 +157,7 @@ fn register_or_replace_new_returns_false() { .register_or_replace("plastics", "1.0.0", schema.to_owned()) .unwrap(); assert!(!replaced); - assert_eq!(reg.len(), 31); + assert_eq!(reg.len(), super::embedded::EMBEDDED.len() + 1); } #[test] @@ -170,7 +168,7 @@ fn register_or_replace_existing_returns_true() { .register_or_replace("battery", "1.0.0", new_schema.to_owned()) .unwrap(); assert!(replaced); - assert_eq!(reg.len(), 30); // count unchanged + assert_eq!(reg.len(), super::embedded::EMBEDDED.len()); // count unchanged assert!( reg.get("battery", &"1.0.0".parse().unwrap()) .unwrap() @@ -195,11 +193,11 @@ fn unregister_runtime_schema_succeeds() { let schema = r#"{"type": "object"}"#; reg.register("plastics", "1.0.0", schema.to_owned()) .unwrap(); - assert_eq!(reg.len(), 31); + assert_eq!(reg.len(), super::embedded::EMBEDDED.len() + 1); let removed = reg.unregister("plastics", &"1.0.0".parse().unwrap()); assert!(removed); - assert_eq!(reg.len(), 30); + assert_eq!(reg.len(), super::embedded::EMBEDDED.len()); assert!(reg.get("plastics", &"1.0.0".parse().unwrap()).is_none()); } @@ -208,7 +206,7 @@ fn unregister_embedded_schema_does_nothing() { let mut reg = VersionedSchemaRegistry::new(); let removed = reg.unregister("battery", &"1.0.0".parse().unwrap()); assert!(!removed); - assert_eq!(reg.len(), 30); // still there + assert_eq!(reg.len(), super::embedded::EMBEDDED.len()); // still there } #[test] @@ -440,34 +438,99 @@ fn conformance_textile_v1_invalid_country_pattern() { #[cfg(not(target_arch = "wasm32"))] #[test] -fn conformance_unsold_goods_v1_valid() { +fn conformance_unsold_goods_v2_valid() { let reg = VersionedSchemaRegistry::new(); - let v: Version = "1.0.0".parse().unwrap(); + let v: Version = "2.0.0".parse().unwrap(); let data = serde_json::json!({ - "reportingPeriod": "2026-Q2", - "volumeKg": 120.5, - "productCategory": "apparel", - "reason": "end_of_season", - "destination": "donation", - "countryOfDisposal": "DE" + "entity": { + "name": "Example Retail Group SA", + "identifier": { "type": "euid", "value": "LUB123456789" }, + "scope": { "type": "standalone" } + }, + "financialYear": { "start": "2027-01-01", "end": "2027-12-31" }, + "lines": [{ + "cnCategories": ["6203"], + "description": "Men's suits and trousers", + "unitsDiscarded": { "value": 1200 }, + "weightKg": { "value": 430, "estimated": true }, + "packagingIncluded": false, + "reason": "damagedOrContaminated", + "treatment": { + "preparingForReusePct": 20, "recyclingPct": 50, + "otherRecoveryPct": 20, "disposalPct": 5, "unknownPct": 5 + } + }], + "measuresTaken": "Pre-season demand forecasting.", + "measuresPlanned": "Twelve-week donation window." }); assert!(reg.validate("unsold-goods", &v, &data).is_ok()); } +/// The reason vocabulary is Del. Reg. (EU) 2026/296 Art. 2's derogation list. +/// `end_of_season` was ours and is not a derogation at all — a disclosure using +/// it claimed a lawful destruction the act does not permit. #[cfg(not(target_arch = "wasm32"))] #[test] -fn conformance_unsold_goods_v1_invalid_destination_enum() { +fn conformance_unsold_goods_v2_rejects_a_reason_outside_article_2() { let reg = VersionedSchemaRegistry::new(); - let v: Version = "1.0.0".parse().unwrap(); - // "incineration" is not a valid destination enum value. - let data = serde_json::json!({ - "reportingPeriod": "2026-Q2", - "volumeKg": 50.0, - "productCategory": "apparel", - "reason": "end_of_season", - "destination": "incineration", - "countryOfDisposal": "DE" + let v: Version = "2.0.0".parse().unwrap(); + let mut data = serde_json::json!({ + "entity": { + "name": "Example Retail Group SA", + "identifier": { "type": "euid", "value": "LUB123456789" }, + "scope": { "type": "standalone" } + }, + "financialYear": { "start": "2027-01-01", "end": "2027-12-31" }, + "lines": [{ + "cnCategories": ["6203"], + "description": "Men's suits and trousers", + "unitsDiscarded": { "value": 1200 }, + "weightKg": { "value": 430 }, + "packagingIncluded": false, + "reason": "damagedOrContaminated", + "treatment": { + "preparingForReusePct": 20, "recyclingPct": 50, + "otherRecoveryPct": 20, "disposalPct": 5, "unknownPct": 5 + } + }], + "measuresTaken": "Pre-season demand forecasting.", + "measuresPlanned": "Twelve-week donation window." + }); + data["lines"][0]["reason"] = serde_json::json!("end_of_season"); + assert!(reg.validate("unsold-goods", &v, &data).is_err()); +} + +/// Art. 3 delimits by CN chapter or heading; a product's own 6/8/10-digit code +/// is a different level of the nomenclature and files a whole chapter's goods +/// under one article. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn conformance_unsold_goods_v2_rejects_a_full_commodity_code_as_a_category() { + let reg = VersionedSchemaRegistry::new(); + let v: Version = "2.0.0".parse().unwrap(); + let mut data = serde_json::json!({ + "entity": { + "name": "Example Retail Group SA", + "identifier": { "type": "euid", "value": "LUB123456789" }, + "scope": { "type": "standalone" } + }, + "financialYear": { "start": "2027-01-01", "end": "2027-12-31" }, + "lines": [{ + "cnCategories": ["6203"], + "description": "Men's suits and trousers", + "unitsDiscarded": { "value": 1200 }, + "weightKg": { "value": 430 }, + "packagingIncluded": false, + "reason": "damagedOrContaminated", + "treatment": { + "preparingForReusePct": 20, "recyclingPct": 50, + "otherRecoveryPct": 20, "disposalPct": 5, "unknownPct": 5 + } + }], + "measuresTaken": "Pre-season demand forecasting.", + "measuresPlanned": "Twelve-week donation window." }); + data["lines"][0]["cnCategories"] = serde_json::json!(["62034231"]); assert!(reg.validate("unsold-goods", &v, &data).is_err()); } diff --git a/crates/dpp-domain/src/test_support.rs b/crates/dpp-domain/src/test_support.rs index a28ee25b..b755f69a 100644 --- a/crates/dpp-domain/src/test_support.rs +++ b/crates/dpp-domain/src/test_support.rs @@ -231,3 +231,46 @@ pub(crate) fn fully_populated_passport() -> Passport { }); passport } + +/// A minimal, well-formed unsold-goods disclosure. +/// +/// One line, a treatment split totalling 100, and a CN heading outside Annex II +/// so the depth lint stays quiet — tests that want a finding should reach in and +/// break one thing rather than build a whole second fixture. +pub(crate) fn sample_unsold_goods_report() +-> crate::domain::product_group::data::unsold_goods::UnsoldGoodsReport { + use crate::domain::product_group::data::unsold_goods::*; + use chrono::NaiveDate; + + UnsoldGoodsReport { + entity: DisclosingEntity { + name: "Example Retail Group SA".to_owned(), + identifier: LegalEntityIdentifier::Euid { + value: "LUB123456789".to_owned(), + }, + 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, ensembles, jackets and trousers".to_owned(), + 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 across all lines.".to_owned(), + measures_planned: "Extending the donation offer window to twelve weeks.".to_owned(), + } +} diff --git a/crates/dpp-domain/tests/fixtures/schema-compat/unsold-goods/v1.0.0.json b/crates/dpp-domain/tests/fixtures/schema-compat/unsold-goods/v1.0.0.json deleted file mode 100644 index 1fadd647..00000000 --- a/crates/dpp-domain/tests/fixtures/schema-compat/unsold-goods/v1.0.0.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "countryOfDisposal": "DE", - "destination": "donation", - "productCategory": "apparel", - "reason": "end_of_season", - "reportingPeriod": "x", - "volumeKg": 1.0 -} diff --git a/crates/dpp-domain/tests/fixtures/schema-compat/unsold-goods/v2.0.0.json b/crates/dpp-domain/tests/fixtures/schema-compat/unsold-goods/v2.0.0.json new file mode 100644 index 00000000..ee752b49 --- /dev/null +++ b/crates/dpp-domain/tests/fixtures/schema-compat/unsold-goods/v2.0.0.json @@ -0,0 +1,43 @@ +{ + "entity": { + "identifier": { + "type": "euid", + "value": "LUB123456789" + }, + "name": "Example Retail Group SA", + "scope": { + "type": "standalone" + } + }, + "financialYear": { + "end": "2027-12-31", + "start": "2027-01-01" + }, + "lines": [ + { + "cnCategories": [ + "6203" + ], + "description": "Men's suits, ensembles, jackets and trousers", + "packagingIncluded": false, + "reason": "damagedOrContaminated", + "treatment": { + "disposalPct": 5, + "otherRecoveryPct": 20, + "preparingForReusePct": 20, + "recyclingPct": 50, + "unknownPct": 5 + }, + "unitsDiscarded": { + "estimated": false, + "value": 1200 + }, + "weightKg": { + "estimated": true, + "value": 430 + } + } + ], + "measuresPlanned": "Extending the donation offer window to twelve weeks.", + "measuresTaken": "Introduced pre-season demand forecasting across all lines." +} diff --git a/crates/dpp-rules/src/lint/unsold_goods.rs b/crates/dpp-rules/src/lint/unsold_goods.rs deleted file mode 100644 index 5a325b41..00000000 --- a/crates/dpp-rules/src/lint/unsold_goods.rs +++ /dev/null @@ -1,354 +0,0 @@ -//! Unsold-goods plausibility lints (EU ESPR Article 25 destruction ban -//! reports) — consistency checks the schema does not itself require. - -use alloc::{format, vec::Vec}; - -use super::{LintFinding, LintSeverity}; - -/// Borrowing view over the unsold-goods report fields these lints inspect. -#[derive(Debug, Clone, Copy)] -pub struct UnsoldGoodsLintInput<'a> { - pub reporting_period: &'a str, - pub volume_kg: f64, - /// Serde code, e.g. `"donation"`, `"exempt_destruction"`. - pub destination: &'a str, - pub operator_name: Option<&'a str>, - pub destruction_justification: Option<&'a str>, - /// Current UTC year — this crate has no clock, so the caller supplies it. - pub as_of_year: u32, - /// Current UTC month (1–12). - pub as_of_month: u32, -} - -struct ParsedPeriod { - year: u32, - /// `None` for a bare year; otherwise the last month the period covers - /// (quarter end for `YYYY-QN`, the month itself for `YYYY-MM`). - end_month: Option, -} - -fn parse_reporting_period(s: &str) -> Option { - let s = s.trim(); - let is_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit()); - - if s.len() == 4 && is_digits(s) { - return Some(ParsedPeriod { - year: s.parse().ok()?, - end_month: None, - }); - } - if let Some((y, q)) = s.split_once("-Q").or_else(|| s.split_once("-q")) { - if y.len() == 4 && is_digits(y) && q.len() == 1 && is_digits(q) { - let quarter: u32 = q.parse().ok()?; - if (1..=4).contains(&quarter) { - return Some(ParsedPeriod { - year: y.parse().ok()?, - end_month: Some(quarter * 3), - }); - } - } - return None; - } - if let Some((y, m)) = s.split_once('-') { - if y.len() == 4 && is_digits(y) && m.len() == 2 && is_digits(m) { - let month: u32 = m.parse().ok()?; - if (1..=12).contains(&month) { - return Some(ParsedPeriod { - year: y.parse().ok()?, - end_month: Some(month), - }); - } - } - return None; - } - None -} - -/// Format plausibility: `reportingPeriod` is free text in the schema, but a -/// value that matches none of the conventional forms (`YYYY`, `YYYY-QN`, -/// `YYYY-MM`) used elsewhere in this report is likely a typo. -#[must_use] -pub fn reporting_period_format_implausible( - input: &UnsoldGoodsLintInput<'_>, -) -> Option { - if parse_reporting_period(input.reporting_period).is_some() { - return None; - } - Some(LintFinding { - code: "unsold_goods.reporting_period_format_implausible", - field: "reportingPeriod", - severity: LintSeverity::Notice, - message: format!( - "reportingPeriod '{}' does not match a recognised YYYY, YYYY-QN, or YYYY-MM format — intended?", - input.reporting_period - ), - }) -} - -/// Cross-field ordering: a disposal cannot be reported for a period that -/// hasn't happened yet. Only fires when the period parses (see -/// [`reporting_period_format_implausible`] for the unparsable case). -#[must_use] -pub fn reporting_period_in_future(input: &UnsoldGoodsLintInput<'_>) -> Option { - let period = parse_reporting_period(input.reporting_period)?; - let is_future = match period.end_month { - Some(month) => { - period.year > input.as_of_year - || (period.year == input.as_of_year && month > input.as_of_month) - } - None => period.year > input.as_of_year, - }; - if !is_future { - return None; - } - Some(LintFinding { - code: "unsold_goods.reporting_period_in_future", - field: "reportingPeriod", - severity: LintSeverity::Warning, - message: format!( - "reportingPeriod '{}' is in the future — intended?", - input.reporting_period - ), - }) -} - -const VOLUME_KG_IMPLAUSIBLE_THRESHOLD: f64 = 1_000_000.0; - -/// Range plausibility: 1,000 tonnes of unsold goods in a single report is far -/// beyond a typical reporting-period volume — worth a second look as a -/// possible unit slip (e.g. grams entered as kilograms). -#[must_use] -pub fn volume_kg_implausibly_large(input: &UnsoldGoodsLintInput<'_>) -> Option { - if !input.volume_kg.is_finite() || input.volume_kg <= VOLUME_KG_IMPLAUSIBLE_THRESHOLD { - return None; - } - Some(LintFinding { - code: "unsold_goods.volume_kg_implausibly_large", - field: "volumeKg", - severity: LintSeverity::Notice, - message: format!( - "volumeKg ({}) exceeds 1,000,000 kg for a single report — intended, or a unit slip \ - (e.g. grams entered as kilograms)?", - input.volume_kg - ), - }) -} - -const THIRD_PARTY_DESTINATIONS: &[&str] = &[ - "donation", - "recycling", - "supplier_return", - "exempt_destruction", -]; - -/// Claim-without-evidence check: the schema's own field description calls -/// `operatorName` "required for audit trail" for third-party destinations -/// (everything except a same-operator repurposing), yet the field itself is -/// optional. -#[must_use] -pub fn operator_name_missing_for_third_party_destination( - input: &UnsoldGoodsLintInput<'_>, -) -> Option { - let is_third_party = THIRD_PARTY_DESTINATIONS - .iter() - .any(|d| input.destination.eq_ignore_ascii_case(d)); - if !is_third_party || input.operator_name.is_some() { - return None; - } - Some(LintFinding { - code: "unsold_goods.operator_name_missing_for_third_party_destination", - field: "operatorName", - severity: LintSeverity::Notice, - message: format!( - "destination '{}' involves a third party but operatorName is absent — intended?", - input.destination - ), - }) -} - -/// Structural plausibility: `destructionJustification` only has defined -/// meaning when `destination` is `exempt_destruction` (the schema's own -/// conditional-required rule). A populated value alongside any other -/// destination is a stray field, not a schema violation. -#[must_use] -pub fn destruction_justification_without_exempt_destination( - input: &UnsoldGoodsLintInput<'_>, -) -> Option { - if input.destruction_justification.is_none() - || input.destination.eq_ignore_ascii_case("exempt_destruction") - { - return None; - } - Some(LintFinding { - code: "unsold_goods.destruction_justification_without_exempt_destination", - field: "destructionJustification", - severity: LintSeverity::Notice, - message: format!( - "destructionJustification is populated but destination is '{}', not exempt_destruction — intended?", - input.destination - ), - }) -} - -/// Run every unsold-goods plausibility lint and collect the findings. -#[must_use] -pub fn lint_unsold_goods(input: &UnsoldGoodsLintInput<'_>) -> Vec { - let mut out = Vec::new(); - out.extend(reporting_period_format_implausible(input)); - out.extend(reporting_period_in_future(input)); - out.extend(volume_kg_implausibly_large(input)); - out.extend(operator_name_missing_for_third_party_destination(input)); - out.extend(destruction_justification_without_exempt_destination(input)); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - fn base_input() -> UnsoldGoodsLintInput<'static> { - UnsoldGoodsLintInput { - reporting_period: "2026-Q2", - volume_kg: 500.0, - destination: "donation", - operator_name: Some("Caritas Skopje"), - destruction_justification: None, - as_of_year: 2026, - as_of_month: 7, - } - } - - // ── parse_reporting_period (via the two lints that use it) ───────────── - - #[test] - fn recognised_formats_all_parse() { - for s in ["2026", "2026-Q2", "2026-07"] { - let mut input = base_input(); - input.reporting_period = s; - assert!(reporting_period_format_implausible(&input).is_none(), "{s}"); - } - } - - #[test] - fn garbage_format_triggers() { - let mut input = base_input(); - input.reporting_period = "asdf"; - let finding = reporting_period_format_implausible(&input).unwrap(); - assert_eq!( - finding.code, - "unsold_goods.reporting_period_format_implausible" - ); - } - - #[test] - fn out_of_range_quarter_triggers_format_lint() { - let mut input = base_input(); - input.reporting_period = "2026-Q9"; - assert!(reporting_period_format_implausible(&input).is_some()); - } - - // ── reporting_period_in_future ────────────────────────────────────────── - - #[test] - fn past_period_passes() { - assert!(reporting_period_in_future(&base_input()).is_none()); - } - - #[test] - fn future_quarter_triggers() { - let mut input = base_input(); - input.reporting_period = "2027-Q1"; - let finding = reporting_period_in_future(&input).unwrap(); - assert_eq!(finding.code, "unsold_goods.reporting_period_in_future"); - } - - #[test] - fn same_year_later_month_triggers() { - let mut input = base_input(); - input.reporting_period = "2026-12"; - input.as_of_month = 7; - assert!(reporting_period_in_future(&input).is_some()); - } - - #[test] - fn unparsable_period_does_not_trigger_future_lint() { - let mut input = base_input(); - input.reporting_period = "asdf"; - assert!(reporting_period_in_future(&input).is_none()); - } - - // ── volume_kg_implausibly_large ───────────────────────────────────────── - - #[test] - fn ordinary_volume_passes() { - assert!(volume_kg_implausibly_large(&base_input()).is_none()); - } - - #[test] - fn huge_volume_triggers() { - let mut input = base_input(); - input.volume_kg = 5_000_000.0; - let finding = volume_kg_implausibly_large(&input).unwrap(); - assert_eq!(finding.code, "unsold_goods.volume_kg_implausibly_large"); - } - - // ── operator_name_missing_for_third_party_destination ─────────────────── - - #[test] - fn third_party_with_operator_name_passes() { - assert!(operator_name_missing_for_third_party_destination(&base_input()).is_none()); - } - - #[test] - fn third_party_without_operator_name_triggers() { - let mut input = base_input(); - input.operator_name = None; - let finding = operator_name_missing_for_third_party_destination(&input).unwrap(); - assert_eq!( - finding.code, - "unsold_goods.operator_name_missing_for_third_party_destination" - ); - } - - #[test] - fn repurposing_destination_never_triggers() { - let mut input = base_input(); - input.destination = "repurposing"; - input.operator_name = None; - assert!(operator_name_missing_for_third_party_destination(&input).is_none()); - } - - // ── destruction_justification_without_exempt_destination ──────────────── - - #[test] - fn no_justification_passes() { - assert!(destruction_justification_without_exempt_destination(&base_input()).is_none()); - } - - #[test] - fn justification_on_exempt_destination_passes() { - let mut input = base_input(); - input.destination = "exempt_destruction"; - input.destruction_justification = - Some("Contaminated batch, health authority order 2026-119"); - assert!(destruction_justification_without_exempt_destination(&input).is_none()); - } - - #[test] - fn justification_on_other_destination_triggers() { - let mut input = base_input(); - input.destruction_justification = Some("stray text"); - let finding = destruction_justification_without_exempt_destination(&input).unwrap(); - assert_eq!( - finding.code, - "unsold_goods.destruction_justification_without_exempt_destination" - ); - } - - // ── lint_unsold_goods aggregator ──────────────────────────────────────── - - #[test] - fn clean_input_produces_no_findings() { - assert!(lint_unsold_goods(&base_input()).is_empty()); - } -} diff --git a/crates/dpp-rules/src/lint/unsold_goods/lints.rs b/crates/dpp-rules/src/lint/unsold_goods/lints.rs new file mode 100644 index 00000000..04e71282 --- /dev/null +++ b/crates/dpp-rules/src/lint/unsold_goods/lints.rs @@ -0,0 +1,220 @@ +//! Unsold-goods disclosure lints — consistency checks that Commission +//! Implementing Regulation (EU) 2026/2 implies but a JSON Schema cannot state. +//! +//! Advisory only. A lint never blocks a disclosure; it says something looks +//! wrong to a reader who knows the act. +//! +//! # Replaced wholesale +//! +//! The previous pack linted a shape that predated the act: a `"YYYY-QN"` +//! reporting period, a single `volume_kg`, one destination word, and a free-text +//! destruction justification. None of those fields exists any more, and three of +//! its five findings were about a quarter — a period this disclosure is never +//! made for, since Art. 1 fixes it to the undertaking's **financial year**. + +use alloc::{format, vec::Vec}; + +use super::super::{LintFinding, LintSeverity}; +use crate::unsold_goods::disclosure; + +/// Borrowing view over one disclosure line. +#[derive(Debug, Clone, Copy)] +pub struct DisclosureLineInput<'a> { + /// The CN chapter or heading this line is filed under. + pub cn_category: &'a str, + /// The Del. Reg. (EU) 2026/296 Art. 2 point letter claimed for this line. + pub reason_point: char, + /// Units discarded. + pub units: u64, + /// Weight discarded, in kilogrammes. + pub weight_kg: u64, + /// The treatment split, in the Annex I column order. + pub preparing_for_reuse_pct: u8, + pub recycling_pct: u8, + pub other_recovery_pct: u8, + pub disposal_pct: u8, + pub unknown_pct: u8, +} + +/// Borrowing view over the whole disclosure. +#[derive(Debug, Clone, Copy)] +pub struct UnsoldGoodsLintInput<'a> { + /// Every line of the Annex I table. + pub lines: &'a [DisclosureLineInput<'a>], + /// Whether a consolidated disclosure listed its undertakings. + pub consolidated_undertaking_count: Option, + /// Annex I note (i) — measures taken, trimmed length. + pub measures_taken_len: usize, + /// Annex I note (j) — measures planned, trimmed length. + pub measures_planned_len: usize, +} + +/// A narrative row shorter than this is unlikely to describe a measure. +const MEANINGFUL_NARRATIVE_CHARS: usize = 20; + +fn split_does_not_total_100(line: &DisclosureLineInput<'_>, index: usize) -> Option { + if disclosure::treatment_split_is_complete( + line.preparing_for_reuse_pct, + line.recycling_pct, + line.other_recovery_pct, + line.disposal_pct, + line.unknown_pct, + ) { + return None; + } + let total = u16::from(line.preparing_for_reuse_pct) + + u16::from(line.recycling_pct) + + u16::from(line.other_recovery_pct) + + u16::from(line.disposal_pct) + + u16::from(line.unknown_pct); + Some(LintFinding { + code: "unsold_goods.treatment_split_does_not_total_100", + field: "/lines", + severity: LintSeverity::Warning, + message: format!( + "line {index}: waste treatment shares total {total}%, not 100% — Annex I note (i) \ + provides `unknown` for the share whose treatment could not be established, so no \ + share is left unaccounted for" + ), + }) +} + +fn cn_depth_too_shallow(line: &DisclosureLineInput<'_>, index: usize) -> Option { + if disclosure::cn_depth_is_correct(line.cn_category) { + return None; + } + if line.cn_category.len() != 2 { + return Some(LintFinding { + code: "unsold_goods.cn_category_malformed", + field: "/lines", + severity: LintSeverity::Warning, + message: format!( + "line {index}: '{}' is not a CN chapter (2 digits) or heading (4 digits)", + line.cn_category + ), + }); + } + let headings = disclosure::annex_ii_headings_in_chapter(line.cn_category); + Some(LintFinding { + code: "unsold_goods.cn_category_needs_four_digits", + field: "/lines", + severity: LintSeverity::Warning, + message: format!( + "line {index}: chapter '{}' contains Annex II headings ({}), which Art. 3 requires be \ + disclosed at four digits — a chapter-level line hides which of them the goods were", + line.cn_category, + headings.join(", ") + ), + }) +} + +fn weight_without_units(line: &DisclosureLineInput<'_>, index: usize) -> Option { + if line.weight_kg > 0 && line.units == 0 { + return Some(LintFinding { + code: "unsold_goods.weight_without_units", + field: "/lines", + severity: LintSeverity::Notice, + message: format!( + "line {index}: {} kg discarded but zero units — note (f) allows the count to be \ + estimated from the weight, so a figure is expected here", + line.weight_kg + ), + }); + } + None +} + +/// Point (h) applies "only where none of the circumstances referred to in points +/// (a) to (g) are applicable", so claiming it for a category alongside a +/// stronger reason is a contradiction. +fn donation_claimed_alongside_stronger_reason( + lines: &[DisclosureLineInput<'_>], +) -> Option { + let mut offending: Vec<&str> = Vec::new(); + for line in lines { + if line.reason_point != 'h' { + continue; + } + let same_category: Vec = lines + .iter() + .filter(|l| l.cn_category == line.cn_category) + .map(|l| l.reason_point) + .collect(); + if !disclosure::donation_reason_is_admissible(&same_category) + && !offending.contains(&line.cn_category) + { + offending.push(line.cn_category); + } + } + if offending.is_empty() { + return None; + } + Some(LintFinding { + code: "unsold_goods.donation_reason_alongside_stronger_reason", + field: "/lines", + severity: LintSeverity::Warning, + message: format!( + "category/ies {} claim Art. 2 point (h) — offered for donation and not accepted — \ + alongside a point (a)-(g) reason. Point (h) is available only where none of (a) to \ + (g) applies", + offending.join(", ") + ), + }) +} + +fn consolidated_without_undertakings(input: &UnsoldGoodsLintInput<'_>) -> Option { + if input.consolidated_undertaking_count == Some(0) { + return Some(LintFinding { + code: "unsold_goods.consolidated_disclosure_lists_no_undertakings", + field: "/entity/scope", + severity: LintSeverity::Warning, + message: + "a consolidated disclosure must list the subsidiaries or member undertakings it \ + covers (Annex I note (c)); with none listed a reader cannot tell whose figures \ + these are" + .into(), + }); + } + None +} + +fn narrative_too_thin(len: usize, field: &'static str, note: char) -> Option { + if len >= MEANINGFUL_NARRATIVE_CHARS { + return None; + } + Some(LintFinding { + code: "unsold_goods.prevention_measures_not_described", + field, + severity: LintSeverity::Notice, + message: format!( + "Annex I note ({note}) asks for the measures themselves, not a placeholder — {len} \ + characters is unlikely to describe one" + ), + }) +} + +/// Run every unsold-goods disclosure lint. +#[must_use] +pub fn lint_unsold_goods(input: &UnsoldGoodsLintInput<'_>) -> Vec { + let mut findings = Vec::new(); + + for (index, line) in input.lines.iter().enumerate() { + findings.extend(split_does_not_total_100(line, index)); + findings.extend(cn_depth_too_shallow(line, index)); + findings.extend(weight_without_units(line, index)); + } + findings.extend(donation_claimed_alongside_stronger_reason(input.lines)); + findings.extend(consolidated_without_undertakings(input)); + findings.extend(narrative_too_thin( + input.measures_taken_len, + "/measuresTaken", + 'i', + )); + findings.extend(narrative_too_thin( + input.measures_planned_len, + "/measuresPlanned", + 'j', + )); + + findings +} diff --git a/crates/dpp-rules/src/lint/unsold_goods/mod.rs b/crates/dpp-rules/src/lint/unsold_goods/mod.rs new file mode 100644 index 00000000..6bd5b62f --- /dev/null +++ b/crates/dpp-rules/src/lint/unsold_goods/mod.rs @@ -0,0 +1,13 @@ +//! Unsold-goods disclosure lints — Impl. Reg. (EU) 2026/2. +//! +//! A directory module rather than one file because rule 7 of +//! `docs/architecture/CODE-LAYOUT.md` puts tests in a sibling file, which a flat +//! module has nowhere to put. Its two neighbours here still use inline tests and +//! are baselined; this one is new, so it follows the rule. + +pub mod lints; + +#[cfg(test)] +mod tests; + +pub use lints::{DisclosureLineInput, UnsoldGoodsLintInput, lint_unsold_goods}; diff --git a/crates/dpp-rules/src/lint/unsold_goods/tests.rs b/crates/dpp-rules/src/lint/unsold_goods/tests.rs new file mode 100644 index 00000000..523e15c6 --- /dev/null +++ b/crates/dpp-rules/src/lint/unsold_goods/tests.rs @@ -0,0 +1,134 @@ +//! Tests for the unsold-goods disclosure lints. + +use alloc::vec::Vec; + +use super::lints::{DisclosureLineInput, UnsoldGoodsLintInput, lint_unsold_goods}; + +/// A line that breaks nothing: CN heading outside Annex II, split totalling 100. +fn clean_line() -> DisclosureLineInput<'static> { + DisclosureLineInput { + cn_category: "6203", + reason_point: 'f', + units: 1_200, + weight_kg: 430, + preparing_for_reuse_pct: 20, + recycling_pct: 50, + other_recovery_pct: 20, + disposal_pct: 5, + unknown_pct: 5, + } +} + +fn input<'a>(lines: &'a [DisclosureLineInput<'a>]) -> UnsoldGoodsLintInput<'a> { + UnsoldGoodsLintInput { + lines, + consolidated_undertaking_count: None, + measures_taken_len: 60, + measures_planned_len: 60, + } +} + +fn codes(findings: &[super::super::LintFinding]) -> Vec<&str> { + findings.iter().map(|f| f.code).collect() +} + +#[test] +fn a_clean_disclosure_produces_no_findings() { + let lines = [clean_line()]; + assert!(lint_unsold_goods(&input(&lines)).is_empty()); +} + +/// Annex I note (i) provides `unknown` for the share that could not be +/// established, so nothing is left over and a split must reach 100. +#[test] +fn a_split_that_misses_100_is_flagged() { + let mut line = clean_line(); + line.disposal_pct = 1; + let lines = [line]; + let findings = lint_unsold_goods(&input(&lines)); + assert!(codes(&findings).contains(&"unsold_goods.treatment_split_does_not_total_100")); +} + +/// Art. 3: four digits for Annex II products. Chapter 85 holds several, so a +/// chapter-level line there hides which heading applied. +#[test] +fn a_chapter_containing_annex_ii_headings_is_flagged() { + let mut line = clean_line(); + line.cn_category = "85"; + let lines = [line]; + let findings = lint_unsold_goods(&input(&lines)); + assert!(codes(&findings).contains(&"unsold_goods.cn_category_needs_four_digits")); +} + +/// Chapter 62 contains no Annex II heading, so two digits is the depth Art. 3 +/// asks for and must not be flagged. +#[test] +fn a_chapter_with_no_annex_ii_heading_is_accepted_at_two_digits() { + let mut line = clean_line(); + line.cn_category = "62"; + let lines = [line]; + assert!(lint_unsold_goods(&input(&lines)).is_empty()); +} + +#[test] +fn a_malformed_cn_category_is_flagged_separately() { + let mut line = clean_line(); + line.cn_category = "620342"; + let lines = [line]; + let findings = lint_unsold_goods(&input(&lines)); + assert!(codes(&findings).contains(&"unsold_goods.cn_category_malformed")); +} + +/// Point (h) applies "only where none of the circumstances referred to in points +/// (a) to (g) are applicable". +#[test] +fn point_h_beside_a_stronger_reason_for_the_same_category_is_flagged() { + let mut donation = clean_line(); + donation.reason_point = 'h'; + let lines = [clean_line(), donation]; + let findings = lint_unsold_goods(&input(&lines)); + assert!(codes(&findings).contains(&"unsold_goods.donation_reason_alongside_stronger_reason")); +} + +/// Different categories are independent — (h) for one and (f) for another is +/// exactly what the derogation contemplates. +#[test] +fn point_h_for_a_different_category_is_not_flagged() { + let mut donation = clean_line(); + donation.reason_point = 'h'; + donation.cn_category = "6204"; + let lines = [clean_line(), donation]; + let findings = lint_unsold_goods(&input(&lines)); + assert!(!codes(&findings).contains(&"unsold_goods.donation_reason_alongside_stronger_reason")); +} + +#[test] +fn a_consolidated_disclosure_listing_no_undertakings_is_flagged() { + let lines = [clean_line()]; + let mut i = input(&lines); + i.consolidated_undertaking_count = Some(0); + let findings = lint_unsold_goods(&i); + assert!( + codes(&findings).contains(&"unsold_goods.consolidated_disclosure_lists_no_undertakings") + ); +} + +#[test] +fn thin_prevention_measures_are_flagged() { + let lines = [clean_line()]; + let mut i = input(&lines); + i.measures_planned_len = 3; + let findings = lint_unsold_goods(&i); + assert!(codes(&findings).contains(&"unsold_goods.prevention_measures_not_described")); +} + +/// Note (f) lets the count be estimated from an accurate weight, so weight with +/// no units at all is a gap rather than a rounding artefact. +#[test] +fn weight_with_no_units_is_flagged() { + let mut line = clean_line(); + line.units = 0; + let lines = [line]; + let findings = lint_unsold_goods(&input(&lines)); + assert!(codes(&findings).contains(&"unsold_goods.weight_without_units")); +} diff --git a/crates/dpp-rules/src/unsold_goods/annex_vii.rs b/crates/dpp-rules/src/unsold_goods/annex_vii.rs index 25045fcf..29592930 100644 --- a/crates/dpp-rules/src/unsold_goods/annex_vii.rs +++ b/crates/dpp-rules/src/unsold_goods/annex_vii.rs @@ -57,33 +57,9 @@ pub fn is_within_annex_vii_scope(commodity_code: &str) -> bool { annex_vii_heading(commodity_code).is_some() } -/// Whether a declared `UnsoldGoodsReport.product_category` word is -/// consistent with the Annex VII heading a passport's `commodity_code` -/// actually falls under. -/// -/// `"accessories"` is part of heading 1 (apparel and clothing accessories), -/// not a peer of `"apparel"` — Annex VII has two headings, not three, so -/// both words are consistent with that one heading. `"home-textile"` and -/// `"other"` correspond to **no** Annex VII heading (Annex VII does not -/// cover home textiles at all), so they contradict any in-scope commodity -/// code — there is no heading they could ever match. -#[must_use] -pub fn product_category_matches_heading(product_category: &str, heading: AnnexViiHeading) -> bool { - matches!( - (product_category, heading), - ( - "apparel" | "accessories", - AnnexViiHeading::ApparelAndClothingAccessories - ) | ("footwear", AnnexViiHeading::Footwear) - ) -} - #[cfg(test)] mod tests { - use super::{ - AnnexViiHeading, annex_vii_heading, is_within_annex_vii_scope, - product_category_matches_heading, - }; + use super::{AnnexViiHeading, annex_vii_heading, is_within_annex_vii_scope}; #[test] fn apparel_heading_prefixes_are_in_scope() { @@ -127,56 +103,4 @@ mod tests { ); assert_eq!(annex_vii_heading("851712"), None); } - - #[test] - fn apparel_and_accessories_both_match_the_one_apparel_heading() { - assert!(product_category_matches_heading( - "apparel", - AnnexViiHeading::ApparelAndClothingAccessories - )); - assert!(product_category_matches_heading( - "accessories", - AnnexViiHeading::ApparelAndClothingAccessories - )); - } - - #[test] - fn footwear_matches_only_the_footwear_heading() { - assert!(product_category_matches_heading( - "footwear", - AnnexViiHeading::Footwear - )); - assert!(!product_category_matches_heading( - "footwear", - AnnexViiHeading::ApparelAndClothingAccessories - )); - } - - #[test] - fn crossed_categories_do_not_match() { - assert!(!product_category_matches_heading( - "apparel", - AnnexViiHeading::Footwear - )); - assert!(!product_category_matches_heading( - "accessories", - AnnexViiHeading::Footwear - )); - } - - #[test] - fn home_textile_and_other_match_no_heading() { - // Annex VII has no home-textile heading at all, so these two words - // can never be consistent with an in-scope commodity code. - for category in ["home-textile", "other"] { - assert!(!product_category_matches_heading( - category, - AnnexViiHeading::ApparelAndClothingAccessories - )); - assert!(!product_category_matches_heading( - category, - AnnexViiHeading::Footwear - )); - } - } } diff --git a/crates/dpp-rules/src/unsold_goods/disclosure.rs b/crates/dpp-rules/src/unsold_goods/disclosure.rs new file mode 100644 index 00000000..11a750ec --- /dev/null +++ b/crates/dpp-rules/src/unsold_goods/disclosure.rs @@ -0,0 +1,139 @@ +//! Disclosure rules — Commission Implementing Regulation (EU) 2026/2. +//! +//! # Two scopes, and they are not the same +//! +//! ESPR **Art. 25** prohibits the *destruction* of the unsold consumer products +//! in **Annex VII** — apparel, clothing accessories and footwear, and nothing +//! else. That scope lives in [`super::annex_vii`]. +//! +//! ESPR **Art. 24** imposes a *disclosure* duty on discarded unsold **consumer +//! products** generally, and Impl. Reg. (EU) 2026/2 implements it. Its own +//! Annex II — the list this module carries — runs to 45 CN headings covering +//! soap, tyres, luggage, bed linen, air conditioners, refrigerators, computers, +//! batteries, lamps, furniture, toys and sanitary articles. +//! +//! **The disclosure is therefore much wider than the destruction ban.** Treating +//! Annex VII as the scope of the disclosure would silently drop every category +//! outside apparel and footwear from a report that is required to carry them. + +use alloc::vec::Vec; + +/// The CN headings of Annex II to Impl. Reg. (EU) 2026/2 — the consumer products +/// a disclosure must delimit at **four** digits rather than two. +/// +/// Read from the OJ text (OJ L, 10.2.2026). Annex II's own preamble narrows it: +/// "Products listed in this Annex that are **components, intermediate products +/// or products that are not primarily intended for consumers** are not covered +/// by the obligation" — a limit on the *goods*, not on the code, and one no +/// table of headings can express. So membership here answers "which depth", not +/// "is this in scope". +const ANNEX_II_HEADINGS: &[&str] = &[ + "3401", "3402", "4011", "4202", "4203", "4303", "4818", "6301", "6302", "6303", "6304", "6306", + "6307", "8415", "8418", "8421", "8422", "8423", "8443", "8450", "8467", "8471", "8506", "8507", + "8508", "8509", "8510", "8513", "8516", "8517", "8518", "8519", "8521", "8523", "8524", "8527", + "8528", "8539", "9006", "9401", "9403", "9404", "9503", "9504", "9619", +]; + +/// Whether a CN heading is listed in Annex II, and so must be disclosed at +/// four-digit depth. +#[must_use] +pub fn is_annex_ii_heading(heading: &str) -> bool { + ANNEX_II_HEADINGS.contains(&heading) +} + +/// Whether a disclosure line's CN category is filed at the depth **Art. 3** +/// requires. +/// +/// Art. 3: categories are delimited on the **first two digits** of the CN code, +/// "however, the products listed in Annex II … shall be delimited based on the +/// **first four digits**". +/// +/// So the test is asymmetric, and deliberately permissive in one direction: +/// +/// - A 4-digit heading is always acceptable — it is required for Annex II +/// products and is strictly more precise than the two-digit default for +/// everything else. +/// - A 2-digit chapter is acceptable **unless** the chapter contains an Annex II +/// heading, in which case a product from it may have needed four digits and +/// the chapter has hidden which. +/// +/// The second case cannot be decided from the code alone — a chapter holding an +/// Annex II heading also holds others — so this returns `false` and lets the +/// caller report it as a finding rather than an error. +#[must_use] +pub fn cn_depth_is_correct(cn_category: &str) -> bool { + match cn_category.len() { + 4 => true, + 2 => !chapter_contains_annex_ii_heading(cn_category), + _ => false, + } +} + +/// Whether any Annex II heading sits inside this CN chapter. +#[must_use] +pub fn chapter_contains_annex_ii_heading(chapter: &str) -> bool { + ANNEX_II_HEADINGS.iter().any(|h| h.starts_with(chapter)) +} + +/// Every Annex II heading inside a chapter, so a finding can say which four-digit +/// codes the disclosure may have needed. +#[must_use] +pub fn annex_ii_headings_in_chapter(chapter: &str) -> Vec<&'static str> { + ANNEX_II_HEADINGS + .iter() + .filter(|h| h.starts_with(chapter)) + .copied() + .collect() +} + +/// The share of a line that counts as **destroyed**. +/// +/// Annex I note (i): "Destruction is the sum of recycling, other recovery and +/// disposal." Preparing for reuse and unknown are outside it. +/// +/// Widened to `u16` because three `u8` shares can sum past 255 in a malformed +/// record, and a wrap would report a small number for a large problem. +#[must_use] +pub fn total_destruction_pct(recycling: u8, other_recovery: u8, disposal: u8) -> u16 { + u16::from(recycling) + u16::from(other_recovery) + u16::from(disposal) +} + +/// Whether a treatment split accounts for the whole line. +/// +/// Note (i) has the percentages "calculated on the basis of the weight of +/// discarded unsold consumer products", and provides `unknown` for the share +/// whose treatment could not be established — so there is no share left over and +/// a well-formed split totals exactly 100. +#[must_use] +pub fn treatment_split_is_complete( + preparing_for_reuse: u8, + recycling: u8, + other_recovery: u8, + disposal: u8, + unknown: u8, +) -> bool { + u16::from(preparing_for_reuse) + + u16::from(recycling) + + u16::from(other_recovery) + + u16::from(disposal) + + u16::from(unknown) + == 100 +} + +/// Whether a set of reasons used across one product category is admissible under +/// Del. Reg. (EU) 2026/296 Art. 2, point (h). +/// +/// Point (h) — offered for donation and not accepted — applies "**only where +/// none of the circumstances referred to in points (a) to (g) are applicable**". +/// It is the one derogation defined by the absence of the others, so it cannot +/// be checked on a single line: the question is whether the operator claimed it +/// for a category it also claimed a stronger reason for. +/// +/// `points` are the Art. 2 point letters used for one CN category in one +/// disclosure. Returns `false` where (h) appears alongside any of (a)–(g). +#[must_use] +pub fn donation_reason_is_admissible(points: &[char]) -> bool { + let uses_h = points.contains(&'h'); + let uses_a_to_g = points.iter().any(|p| ('a'..='g').contains(p)); + !(uses_h && uses_a_to_g) +} diff --git a/crates/dpp-rules/src/unsold_goods/mod.rs b/crates/dpp-rules/src/unsold_goods/mod.rs index b57a8d4f..7ade6518 100644 --- a/crates/dpp-rules/src/unsold_goods/mod.rs +++ b/crates/dpp-rules/src/unsold_goods/mod.rs @@ -1,2 +1,9 @@ -//! Unsold goods — ESPR Art. 25 destruction ban (Annex VII). +//! Unsold consumer products — the ESPR Art. 25 destruction ban (Annex VII) and +//! the Art. 24 disclosure duty (Impl. Reg. (EU) 2026/2). +//! +//! The two have **different scopes** and are kept in separate modules for that +//! reason: the ban reaches apparel and footwear, the disclosure reaches consumer +//! products generally. See [`disclosure`] for what that difference costs if it +//! is collapsed. pub mod annex_vii; +pub mod disclosure; diff --git a/crates/dpp-tests/fixtures/aas/environments/unsold-goods.json b/crates/dpp-tests/fixtures/aas/environments/unsold-goods.json index bc4bd886..a7a7a03d 100644 --- a/crates/dpp-tests/fixtures/aas/environments/unsold-goods.json +++ b/crates/dpp-tests/fixtures/aas/environments/unsold-goods.json @@ -127,7 +127,7 @@ "modelType": "Property", "idShort": "schemaVersion", "valueType": "xs:string", - "value": "1.0.0" + "value": "2.0.0" }, { "modelType": "Property", @@ -316,39 +316,93 @@ "submodelElements": [ { "modelType": "Property", - "idShort": "reportingPeriod", + "idShort": "entityName", "valueType": "xs:string", - "value": "2026-Q3" + "value": "Example Retail Group SA" }, { "modelType": "Property", - "idShort": "volumeKg", + "idShort": "entityIdentifier", + "valueType": "xs:string", + "value": "LUB123456789" + }, + { + "modelType": "Property", + "idShort": "disclosureScope", + "valueType": "xs:string", + "value": "standalone" + }, + { + "modelType": "Property", + "idShort": "financialYearStart", + "valueType": "xs:string", + "value": "2027-01-01" + }, + { + "modelType": "Property", + "idShort": "financialYearEnd", + "valueType": "xs:string", + "value": "2027-12-31" + }, + { + "modelType": "Property", + "idShort": "totalWeightKg", + "valueType": "xs:double", + "value": "430" + }, + { + "modelType": "Property", + "idShort": "totalUnits", "valueType": "xs:double", - "value": "420" + "value": "1200" }, { "modelType": "Property", - "idShort": "productCategory", + "idShort": "line0CnCategories", "valueType": "xs:string", - "value": "apparel" + "value": "6203" + }, + { + "modelType": "Property", + "idShort": "line0Description", + "valueType": "xs:string", + "value": "Men's suits, ensembles, jackets and trousers" + }, + { + "modelType": "Property", + "idShort": "line0WeightKg", + "valueType": "xs:double", + "value": "430" + }, + { + "modelType": "Property", + "idShort": "line0Units", + "valueType": "xs:double", + "value": "1200" }, { "modelType": "Property", - "idShort": "reason", + "idShort": "line0Reason", "valueType": "xs:string", - "value": "end_of_season" + "value": "damagedOrContaminated" + }, + { + "modelType": "Property", + "idShort": "line0TotalDestructionPct", + "valueType": "xs:double", + "value": "75" }, { "modelType": "Property", - "idShort": "destination", + "idShort": "measuresTaken", "valueType": "xs:string", - "value": "donation" + "value": "Introduced pre-season demand forecasting across all lines." }, { "modelType": "Property", - "idShort": "countryOfDisposal", + "idShort": "measuresPlanned", "valueType": "xs:string", - "value": "DE" + "value": "Extending the donation offer window to twelve weeks." } ] } diff --git a/crates/dpp-tests/src/fixtures.rs b/crates/dpp-tests/src/fixtures.rs index 02a7096f..4d948e09 100644 --- a/crates/dpp-tests/src/fixtures.rs +++ b/crates/dpp-tests/src/fixtures.rs @@ -123,3 +123,50 @@ pub fn make_subject( product_categories: vec![], } } + +/// A minimal, well-formed unsold-goods disclosure in the Annex I shape of +/// Commission Implementing Regulation (EU) 2026/2. +/// +/// One line, a treatment split totalling 100, and a CN heading outside Annex II +/// so the depth lint stays quiet. +#[must_use] +pub fn unsold_goods_report() -> dpp_domain::UnsoldGoodsReport { + use chrono::NaiveDate; + use dpp_domain::{ + CnCategory, DiscardReason, DiscardedProductLine, DiscardedQuantity, DisclosingEntity, + DisclosureScope, FinancialYear, LegalEntityIdentifier, UnsoldGoodsReport, + 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, ensembles, jackets 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 across all lines.".into(), + measures_planned: "Extending the donation offer window to twelve weeks.".into(), + } +} diff --git a/crates/dpp-tests/tests/all_product_groups_aas.rs b/crates/dpp-tests/tests/all_product_groups_aas.rs index ebe5a320..3cbc50a2 100644 --- a/crates/dpp-tests/tests/all_product_groups_aas.rs +++ b/crates/dpp-tests/tests/all_product_groups_aas.rs @@ -21,8 +21,7 @@ use dpp_domain::{ AluminiumData, ConstructionData, DetergentData, DeviceType, ElectronicsData, EnergyEfficiencyClass, FibreEntry, FurnitureData, Gtin, MattressData, ProductGroup, ProductGroupData, ProductionRoute, RepairabilityScore, SteelData, SurfactantEntry, - SvhcSubstance, TextileData, ToyData, TyreData, UnsoldGoodsDestination, UnsoldGoodsReason, - UnsoldGoodsReport, + SvhcSubstance, TextileData, ToyData, TyreData, UnsoldGoodsReport, }; use dpp_tests::fixtures::base_passport as base; @@ -222,16 +221,7 @@ fn detergent_data() -> DetergentData { } fn unsold_goods_report() -> UnsoldGoodsReport { - UnsoldGoodsReport { - reporting_period: "2026-Q3".into(), - volume_kg: 420.0, - product_category: "apparel".into(), - reason: UnsoldGoodsReason::EndOfSeason, - destination: UnsoldGoodsDestination::Donation, - destruction_justification: None, - country_of_disposal: "DE".into(), - operator_name: Some("Charity Recipient e.V.".into()), - } + dpp_tests::fixtures::unsold_goods_report() } /// Every product group's data, paired with its schema version and the expected @@ -304,7 +294,7 @@ fn all_product_group_cases() -> Vec<(ProductGroup, ProductGroupData, &'static st ( ProductGroup::UnsoldGoods, ProductGroupData::UnsoldGoods(unsold_goods_report()), - "1.0.0", + "2.0.0", "UnsoldGoodsReport", ), ] diff --git a/crates/dpp-tests/tests/layout.rs b/crates/dpp-tests/tests/layout.rs index 522d017e..750dcfee 100644 --- a/crates/dpp-tests/tests/layout.rs +++ b/crates/dpp-tests/tests/layout.rs @@ -203,7 +203,6 @@ const ONE_TYPE_PER_FILE_BASELINE: &[&str] = &[ "crates/dpp-domain/src/domain/identity.rs", "crates/dpp-domain/src/domain/lint.rs", "crates/dpp-domain/src/domain/product_group/data/battery.rs", - "crates/dpp-domain/src/domain/product_group/data/unsold_goods.rs", "crates/dpp-domain/src/domain/product_group/enums.rs", "crates/dpp-domain/src/domain/product_group/metrics.rs", "crates/dpp-domain/src/domain/seal.rs", @@ -444,7 +443,6 @@ const INLINE_TESTS_BASELINE: &[&str] = &[ "crates/dpp-rules/src/common/numeric.rs", "crates/dpp-rules/src/lint/battery.rs", "crates/dpp-rules/src/lint/textile.rs", - "crates/dpp-rules/src/lint/unsold_goods.rs", "crates/dpp-rules/src/metals/aluminium.rs", "crates/dpp-rules/src/textiles/fibre.rs", "crates/dpp-rules/src/unsold_goods/annex_vii.rs", diff --git a/crates/dpp-tests/tests/schema_conformity.rs b/crates/dpp-tests/tests/schema_conformity.rs index a7098b4e..ade1622f 100644 --- a/crates/dpp-tests/tests/schema_conformity.rs +++ b/crates/dpp-tests/tests/schema_conformity.rs @@ -222,8 +222,8 @@ fn steel_schema_v1_is_valid() { } #[test] -fn unsold_goods_schema_v1_is_valid() { - let schema = schema("unsold-goods", "1.0.0"); +fn unsold_goods_schema_v2_is_valid() { + let schema = schema("unsold-goods", "2.0.0"); assert_eq!(schema["type"].as_str().unwrap(), "object"); } diff --git a/docs/architecture/DATA-MODEL.md b/docs/architecture/DATA-MODEL.md index ab11d805..0f9a3a90 100644 --- a/docs/architecture/DATA-MODEL.md +++ b/docs/architecture/DATA-MODEL.md @@ -123,7 +123,7 @@ Elements of `Passport.materials` — bill of materials entries. | `battery` | `battery_type` | Battery Reg. 2023/1542 Art. 1(3) — closed, five categories, required | | `steel` | `product_category` | `"flat"` / `"long"` / … | | `electronics` | `product_category` | `"smartphone"` / `"other-mobile-phone"` / `"cordless-phone"` / `"tablet"` — closed, Reg. (EU) 2023/1670 Art. 1(1) | -| `unsold-goods` | `product_category` | `"apparel"` / `"footwear"` / … — **ours, and superseded.** Impl. Reg. (EU) 2026/2 Art. 3 delimits by CN code. See §4.4 | +| `unsold-goods` | *(none)* | Removed in schema v2.0.0. Impl. Reg. (EU) 2026/2 Art. 3 delimits a disclosure by **CN code**, so its lines carry `cnCategories`, not a category word of ours. See §4.4 | | `furniture` | `product_type` | — | | `tyre` | `tyre_class` | `"C1"` / … | @@ -285,7 +285,7 @@ others are listed here rather than each getting a stub. | `steel` | `SteelData` | v1.1.0 | CBAM-aligned. Intermediate product, earliest indicative act of any group | | `toy` | `ToyData` | v1.1.0 | Reg. (EU) 2025/2509 | | `tyre` | `TyreData` | v1.0.0 | | -| `unsold-goods` | `UnsoldGoodsReport` | v1.0.0 | See §4.4 — **not a product group** and its model is out of date | +| `unsold-goods` | `UnsoldGoodsReport` | v2.0.0 | See §4.4 — **not a product group**; built to Impl. Reg. (EU) 2026/2 Annex I | `ProductGroupData::Other` keeps the tag and payload of a product group this build has no typed variant for, verbatim, so an unknown group round-trips rather than @@ -299,28 +299,45 @@ an operator over a financial year**, not a product placed on the market. It carries `PassportObligation::NotRequired`: the duty is real and binding today, and there is no passport anywhere in Arts. 24–25. -⚠️ **`UnsoldGoodsReport` does not match the adopted format.** Two acts now govern -this, both adopted 9 February 2026 and neither reflected in the type: +Two acts govern it, both adopted 9 February 2026, and `UnsoldGoodsReport` v2.0.0 +is built to them: - **Commission Implementing Regulation (EU) 2026/2** (CELEX `32026R0002`), under - Art. 24(3) — Art. 2(1) requires the disclosure to comply with the format in its - **Annex I**, and Art. 3 delimits categories by **CN code**, first two digits - (four for its Annex II list). + Art. 24(3) — Art. 2(1) binds the disclosure to the format in its **Annex I**, + and Art. 3 delimits categories by **CN code**, first two digits (four for the + products of its Annex II). - **Commission Delegated Regulation (EU) 2026/296** (CELEX `32026R0296`), under - Art. 25(5) — the **closed list of derogations** from the destruction ban. - Annex I note (h) of 2026/2 points its reason vocabulary at that list. - -Known divergences: the period is a **financial year** with start and end dates, -not a free-text quarter; categories are **CN codes**, not names like `"apparel"`; -the disclosure needs a legal-entity header and a standalone-vs-consolidated flag; -unit counts and a packaging-included flag are absent; waste treatment is a -**percentage split** across preparing-for-reuse, recycling, other recovery, -disposal, total destruction and unknown — where *destruction is the sum of -recycling, other recovery and disposal* — not a single destination; and the two -"measures taken / planned to prevent destruction" fields have no representation. -`UnsoldGoodsReason`'s variants are ours, not the Regulation's. - -Reuse `CommodityCode` for the CN axis when this is rebuilt. + Art. 25(5) — the **closed list of ten derogations**, points (a) to (j). Annex I + note (h) of 2026/2 makes it the reason vocabulary, so the two interlock. + +The shape is Annex I's: an `entity` header (name, EUID-or-other identifier, +standalone vs consolidated with its undertakings listed), a `financialYear` with +both endpoints, a repeating body of `lines`, and the two narrative rows +`measuresTaken` and `measuresPlanned`. Each line carries its CN categories — +plural, because note (f) allows several where items sold together count as one +unit — a description, unit and weight quantities each flagged `estimated` or not, +a packaging-included flag, one `reason`, and a `treatment` split. + +Three points that are easy to get wrong: + +- **Total destruction is derived, never stored.** Note (i) defines it as + *recycling + other recovery + disposal*. Preparing-for-reuse and unknown sit + outside it, which is not the intuitive reading. `WasteTreatmentSplit::total_destruction_pct` + computes it; the wire has no such field. +- **`unknown` is an answer, not a gap.** Note (i) provides it for the share whose + treatment could not be obtained from the waste treatment operator, so a + well-formed split totals exactly 100 and nothing is left over. +- **`CnCategory` is not `CommodityCode`.** Two digits or four, against six/eight/ten + — a product's own classification is a different level of the same nomenclature + and files a whole chapter's goods under one article if substituted. + +**There is no v1.0.0.** It predated both acts and nothing could carry a document +forward from it: a financial year is not derivable from `"2026-Q2"`, a CN code is +not derivable from `"apparel"`, a six-way split is not derivable from one +destination, and its reason list shares no member with the Art. 2 derogations — +two of its reasons named commercial circumstances that are not derogations at +all. A lens would have had to invent every one of those, so the version was +removed rather than migrated. Safe only because nothing was ever stored under it. --- diff --git a/plugins/product-group-textile/src/lib.rs b/plugins/product-group-textile/src/lib.rs index 1d10bb94..a82855a5 100644 --- a/plugins/product-group-textile/src/lib.rs +++ b/plugins/product-group-textile/src/lib.rs @@ -49,13 +49,14 @@ impl DppProductGroupPlugin for TextilePlugin { fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError> { if is_unsold(input) { + // The Annex I header rows. The repeating body is checked in + // `unsold_goods::calculate`, which can see across lines — the two + // rules that matter (the treatment split, and point (h)'s + // subordination) are not answerable field by field. Validator::new(input) - .require_str("reportingPeriod") - .require_non_negative("volumeKg") - .require_str("productCategory") - .require_str("reason") - .require_str("destination") - .require_country("countryOfDisposal") + .require_non_empty_array("lines") + .require_str("measuresTaken") + .require_str("measuresPlanned") .finish() } else { Validator::new(input) @@ -106,15 +107,33 @@ mod tests { }) } + /// A disclosure in the Annex I shape of Impl. Reg. (EU) 2026/2. fn unsold() -> Value { json!({ "productGroup": "unsoldGoods", - "reportingPeriod": "2026-Q2", - "volumeKg": 120.0, - "productCategory": "apparel", - "reason": "end_of_season", - "destination": "donation", - "countryOfDisposal": "MK" + "entity": { + "name": "Example Retail Group SA", + "identifier": { "type": "euid", "value": "LUB123456789" }, + "scope": { "type": "standalone" } + }, + "financialYear": { "start": "2027-01-01", "end": "2027-12-31" }, + "lines": [{ + "cnCategories": ["6203"], + "description": "Men's suits and trousers", + "unitsDiscarded": { "value": 1200, "estimated": false }, + "weightKg": { "value": 430, "estimated": true }, + "packagingIncluded": false, + "reason": "damagedOrContaminated", + "treatment": { + "preparingForReusePct": 20, + "recyclingPct": 50, + "otherRecoveryPct": 20, + "disposalPct": 5, + "unknownPct": 5 + } + }], + "measuresTaken": "Introduced pre-season demand forecasting.", + "measuresPlanned": "Extending the donation window to twelve weeks." }) } @@ -146,7 +165,7 @@ mod tests { } #[test] - fn unsold_donation_is_compliant() { + fn a_consistent_disclosure_is_compliant() { assert_eq!( TextilePlugin .calculate_metrics(&unsold()) @@ -156,10 +175,12 @@ mod tests { ); } + /// Annex I note (i) provides `unknown` for the share that could not be + /// established, so nothing is left over and the split must reach 100. #[test] - fn unsold_exempt_without_justification_is_non_compliant() { + fn a_treatment_split_that_misses_100_is_non_compliant() { let mut d = unsold(); - d["destination"] = json!("exempt_destruction"); + d["lines"][0]["treatment"]["disposalPct"] = json!(1); assert_eq!( TextilePlugin .calculate_metrics(&d) @@ -169,6 +190,33 @@ mod tests { ); } + /// Del. Reg. (EU) 2026/296 Art. 2 point (h) applies "only where none of the + /// circumstances referred to in points (a) to (g) are applicable". + #[test] + fn donation_claimed_beside_a_stronger_reason_is_non_compliant() { + let mut d = unsold(); + let mut second = d["lines"][0].clone(); + second["reason"] = json!("offeredForDonationNotAccepted"); + d["lines"].as_array_mut().unwrap().push(second); + assert_eq!( + TextilePlugin + .calculate_metrics(&d) + .unwrap() + .compliance_status, + PluginComplianceStatus::NonCompliant + ); + } + + /// A disclosure with no lines is structurally invalid, not merely + /// non-compliant — it is rejected at validation before any determination is + /// reached. + #[test] + fn a_disclosure_with_no_lines_fails_validation() { + let mut d = unsold(); + d["lines"] = json!([]); + assert!(TextilePlugin.validate_input(&d).is_err()); + } + #[test] fn out_of_range_fibre_pcts_are_non_compliant() { // Sums to 100 but neither percentage is physically valid. @@ -210,17 +258,12 @@ mod tests { assert!(TextilePlugin.validate_input(&d).is_err()); } + /// Annex I notes (i) and (j) ask for the measures themselves; a run of + /// whitespace is not one, and the validator treats it as absent. #[test] - fn whitespace_only_justification_is_non_compliant() { + fn whitespace_only_prevention_measures_fail_validation() { let mut d = unsold(); - d["destination"] = json!("exempt_destruction"); - d["destructionJustification"] = json!(" "); // 10 spaces - assert_eq!( - TextilePlugin - .calculate_metrics(&d) - .unwrap() - .compliance_status, - PluginComplianceStatus::NonCompliant - ); + d["measuresPlanned"] = json!(" "); + assert!(TextilePlugin.validate_input(&d).is_err()); } } diff --git a/plugins/product-group-textile/src/unsold_goods.rs b/plugins/product-group-textile/src/unsold_goods.rs index 8a262cc8..94b9c38f 100644 --- a/plugins/product-group-textile/src/unsold_goods.rs +++ b/plugins/product-group-textile/src/unsold_goods.rs @@ -1,51 +1,146 @@ -//! Unsold Goods Destruction Ban — EU ESPR Article 25 / Annex VII, effective 2026-07-19. +//! Discarded unsold consumer products — ESPR Art. 24 disclosure, in the format +//! of Commission Implementing Regulation (EU) 2026/2 Annex I. //! -//! - `exempt_destruction` with justification < 10 chars → NON_COMPLIANT. -//! - `exempt_destruction` with justification ≥ 10 chars → COMPLIANT (flagged). -//! - Approved destinations (donation/recycling/repurposing/supplier_return) → COMPLIANT. -//! - Anything else → NON_COMPLIANT. +//! # What this can and cannot determine +//! +//! **No passport obligation exists here.** ESPR Arts. 24–25 bind an economic +//! operator over a financial year and require no digital product passport at +//! all. So this never emits a *passport* compliance verdict; it checks that a +//! disclosure is internally consistent with the format the act prescribes, which +//! is a different and much narrower claim. +//! +//! The checks are the ones a single document can answer: +//! +//! - every line's waste-treatment split totals 100% — Annex I note (i) provides +//! `unknown` for the share that could not be established, so nothing is left +//! over; +//! - point (h) of Del. Reg. (EU) 2026/296 Art. 2 — offered for donation and not +//! accepted — is not claimed for a CN category that also claims one of points +//! (a) to (g), because (h) applies "only where none of" those does; +//! - both narrative rows are present. +//! +//! What it deliberately does **not** check is whether the reason claimed is +//! *true*. Art. 3 of the same act makes that a documentary question — five years +//! of per-derogation evidence, produced to a competent authority within 30 days +//! — and no amount of reading the disclosure answers it. use dpp_plugin_sdk::traits::{PluginComplianceStatus, PluginResult}; -use dpp_plugin_sdk::validate::{num, str_of}; +use dpp_plugin_sdk::validate::str_of; use serde_json::{Value, json}; +/// Sum of one line's five treatment shares, widened so a malformed record +/// cannot wrap into a plausible number. +fn treatment_total(line: &Value) -> u32 { + ["preparingForReusePct", "recyclingPct", "otherRecoveryPct", "disposalPct", "unknownPct"] + .iter() + .map(|k| { + line.get("treatment") + .and_then(|t| t.get(*k)) + .and_then(Value::as_u64) + .unwrap_or(0) as u32 + }) + .sum() +} + +/// The Art. 2 point letter a reason maps to, for the point (h) subordination +/// check. `None` for a reason this build does not know. +fn reason_point(reason: &str) -> Option { + Some(match reason { + "dangerousProduct" => 'a', + "nonCompliantWithLaw" => 'b', + "intellectualPropertyInfringement" => 'c', + "licensedPeriodExpired" => 'd', + "markingsCannotBeRemoved" => 'e', + "damagedOrContaminated" => 'f', + "defectiveBeyondRepair" => 'g', + "offeredForDonationNotAccepted" => 'h', + "donatedButNoRecipientFound" => 'i', + "reusedButNoRecipientFound" => 'j', + _ => return None, + }) +} + pub fn calculate(input: &Value) -> PluginResult { - let destination = str_of(input, "destination").unwrap_or(""); - let volume_kg = num(input, "volumeKg"); - - let (status, detail): (PluginComplianceStatus, &str) = match destination { - "exempt_destruction" => { - let justification = str_of(input, "destructionJustification").unwrap_or(""); - // Count trimmed characters — a run of whitespace is not a - // substantive justification. - if justification.trim().chars().count() < 10 { - ( - PluginComplianceStatus::NonCompliant, - "exempt_destruction requires destructionJustification of at least 10 characters", - ) - } else { - ( - PluginComplianceStatus::Compliant, - "exempt destruction with valid justification", + let lines = input.get("lines").and_then(Value::as_array); + + let Some(lines) = lines else { + return PluginResult::new(PluginComplianceStatus::NonCompliant).with_extra(json!({ + "regulationArticle": "ESPR Article 24; Impl. Reg. (EU) 2026/2 Annex I", + "detail": "disclosure carries no lines", + })); + }; + if lines.is_empty() { + return PluginResult::new(PluginComplianceStatus::NonCompliant).with_extra(json!({ + "regulationArticle": "ESPR Article 24; Impl. Reg. (EU) 2026/2 Annex I", + "detail": "disclosure carries no lines", + })); + } + + let mut problems: Vec = Vec::new(); + + for (i, line) in lines.iter().enumerate() { + let total = treatment_total(line); + if total != 100 { + problems.push(format!( + "line {i}: waste treatment shares total {total}%, not 100%" + )); + } + } + + // Point (h) is the one derogation defined by the absence of the others, so + // it has to be checked across the lines of a category, never on one line. + for (i, line) in lines.iter().enumerate() { + let reason = str_of(line, "reason").unwrap_or(""); + if reason_point(reason) != Some('h') { + continue; + } + let category = line + .get("cnCategories") + .and_then(Value::as_array) + .and_then(|c| c.first()) + .and_then(Value::as_str) + .unwrap_or(""); + let clashes = lines.iter().any(|other| { + let other_category = other + .get("cnCategories") + .and_then(Value::as_array) + .and_then(|c| c.first()) + .and_then(Value::as_str) + .unwrap_or(""); + other_category == category + && matches!( + reason_point(str_of(other, "reason").unwrap_or("")), + Some('a'..='g') ) - } + }); + if clashes { + problems.push(format!( + "line {i}: category '{category}' claims Art. 2 point (h) alongside a point (a)-(g) \ + reason; (h) applies only where none of (a) to (g) does" + )); + } + } + + for (field, note) in [("measuresTaken", 'i'), ("measuresPlanned", 'j')] { + if str_of(input, field).unwrap_or("").trim().is_empty() { + problems.push(format!("{field} is required by Annex I note ({note})")); } - "donation" | "recycling" | "repurposing" | "supplier_return" => ( - PluginComplianceStatus::Compliant, - "approved disposal destination", - ), - "" => ( - PluginComplianceStatus::NonCompliant, - "missing destination field", - ), - _ => (PluginComplianceStatus::NonCompliant, "unknown destination"), + } + + let status = if problems.is_empty() { + PluginComplianceStatus::Compliant + } else { + PluginComplianceStatus::NonCompliant }; PluginResult::new(status).with_extra(json!({ - "regulationArticle": "ESPR Article 25 / Annex VII", - "effectiveDate": "2026-07-19", - "destination": destination, - "detail": detail, - "volumeKg": volume_kg, + "regulationArticle": "ESPR Article 24; Impl. Reg. (EU) 2026/2 Annex I; Del. Reg. (EU) 2026/296 Art. 2", + "lineCount": lines.len(), + "detail": if problems.is_empty() { + "disclosure is internally consistent with the Annex I format".to_owned() + } else { + problems.join("; ") + }, + "passportObligation": "none — ESPR Arts. 24-25 impose no digital product passport", })) }