diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f1cf0a4..2fa756d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `org`: new `Attribute` model with `Item.Attributes` for named item features such as color or size (EN 16931 BG-32). Attributes replace the previous practice of mapping `Item.Meta` into output formats — meta is internal-only data. Each attribute is identified by a `key` or `type` and holds exactly one of a `text`, `code`, `amount` (with optional `unit`), or `date` value. Standard keys cover physical properties of the item, dates, nutritional declarations, and CO2e emissions. Item attribute keys must be unique. - `org`: `kj` (kilojoule) and `kcal` (kilocalorie) units. +- `org`: new `Package` model with `bill.Line.Packages` describing the physical packages used to pack and ship a line's items, with package type keys mapped to UN/ECE Recommendation 21 codes. ## [v0.502.2] - 2026-07-06 diff --git a/bill/line.go b/bill/line.go index 5c263e737..b85c8512a 100644 --- a/bill/line.go +++ b/bill/line.go @@ -34,6 +34,9 @@ type Line struct { // Breakdown of the line item for more detailed information. The sum of all lines // will be used for the item price. Breakdown []*SubLine `json:"breakdown,omitempty" jsonschema:"title=Breakdown"` + // Packages describe the physical packages or containers used to pack and + // ship the line's items, most useful in delivery documents. + Packages []*org.Package `json:"packages,omitempty" jsonschema:"title=Packages"` // Result of quantity multiplied by the item's price (calculated) Sum *num.Amount `json:"sum,omitempty" jsonschema:"title=Sum" jsonschema_extras:"calculated=true"` // Discounts applied to this line @@ -211,6 +214,7 @@ func normalizeLine(l *Line) { l.Discounts = CleanLineDiscounts(l.Discounts) l.Charges = CleanLineCharges(l.Charges) l.Breakdown = CleanSubLines(l.Breakdown) + l.Packages = org.CleanPackages(l.Packages) } func normalizeSubLine(sl *SubLine) { diff --git a/data/rules/org.json b/data/rules/org.json index bed7ef3e2..202f44488 100644 --- a/data/rules/org.json +++ b/data/rules/org.json @@ -435,6 +435,47 @@ } ] }, + { + "id": "GOBL-ORG-PACKAGE", + "object": "org.Package", + "subsets": [ + { + "field": "key", + "subsets": [ + { + "guard": "present", + "assert": [ + { + "id": "GOBL-ORG-PACKAGE-01", + "desc": "package key must be or extend one of the pre-defined keys", + "tests": "be or starts with one of [bag, bale, barrel, bin, box, bundle, can, carton, case, crate, drum, envelope, pallet, reel, roll, sack, tray, tub, tube, unpacked]" + } + ] + } + ] + }, + { + "field": "count", + "assert": [ + { + "id": "GOBL-ORG-PACKAGE-02", + "desc": "package count must be zero or positive", + "tests": "at least 0" + } + ] + }, + { + "field": "attributes", + "assert": [ + { + "id": "GOBL-ORG-PACKAGE-03", + "desc": "package attributes must not contain duplicate keys", + "tests": "attribute keys must be unique" + } + ] + } + ] + }, { "id": "GOBL-ORG-PERSON", "object": "org.Person", diff --git a/data/schemas/bill/line.json b/data/schemas/bill/line.json index b5f719978..6b953e9b6 100644 --- a/data/schemas/bill/line.json +++ b/data/schemas/bill/line.json @@ -55,6 +55,14 @@ "title": "Breakdown", "description": "Breakdown of the line item for more detailed information. The sum of all lines\nwill be used for the item price." }, + "packages": { + "items": { + "$ref": "https://gobl.org/draft-0/org/package" + }, + "type": "array", + "title": "Packages", + "description": "Packages describe the physical packages or containers used to pack and\nship the line's items, most useful in delivery documents." + }, "sum": { "$ref": "https://gobl.org/draft-0/num/amount", "title": "Sum", diff --git a/data/schemas/org/package.json b/data/schemas/org/package.json new file mode 100644 index 000000000..ba9e1628d --- /dev/null +++ b/data/schemas/org/package.json @@ -0,0 +1,137 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gobl.org/draft-0/org/package", + "$ref": "#/$defs/org.Package", + "$defs": { + "org.Package": { + "properties": { + "uuid": { + "type": "string", + "format": "uuid", + "title": "UUID", + "description": "Universally Unique Identifier." + }, + "label": { + "type": "string", + "title": "Label", + "description": "Label for the package for presentation in output documents." + }, + "key": { + "$ref": "https://gobl.org/draft-0/cbc/key", + "anyOf": [ + { + "const": "bag", + "title": "Bag" + }, + { + "const": "bale", + "title": "Bale" + }, + { + "const": "barrel", + "title": "Barrel" + }, + { + "const": "bin", + "title": "Bin" + }, + { + "const": "box", + "title": "Box" + }, + { + "const": "bundle", + "title": "Bundle" + }, + { + "const": "can", + "title": "Can" + }, + { + "const": "carton", + "title": "Carton" + }, + { + "const": "case", + "title": "Case" + }, + { + "const": "crate", + "title": "Crate" + }, + { + "const": "drum", + "title": "Drum" + }, + { + "const": "envelope", + "title": "Envelope" + }, + { + "const": "pallet", + "title": "Pallet" + }, + { + "const": "reel", + "title": "Reel" + }, + { + "const": "roll", + "title": "Roll" + }, + { + "const": "sack", + "title": "Sack" + }, + { + "const": "tray", + "title": "Tray" + }, + { + "const": "tub", + "title": "Tub" + }, + { + "const": "tube", + "title": "Tube" + }, + { + "const": "unpacked", + "title": "Unpacked", + "description": "Unpacked or unpackaged goods." + }, + { + "pattern": "^(?:[a-z]|[a-z0-9][a-z0-9-+]*[a-z0-9])$", + "title": "Other" + } + ], + "title": "Key", + "description": "Key for the type of package, based on the list pre-defined by GOBL\nwith mappings to UN/ECE Recommendation 21 codes." + }, + "count": { + "type": "integer", + "title": "Count", + "description": "Count of packages of this same type, when more than one." + }, + "identities": { + "items": { + "$ref": "https://gobl.org/draft-0/org/identity" + }, + "type": "array", + "title": "Identities", + "description": "Identities used to identify the individual package, such as a\nGS1 Serial Shipping Container Code (SSCC)." + }, + "attributes": { + "items": { + "$ref": "https://gobl.org/draft-0/org/attribute" + }, + "type": "array", + "title": "Attributes", + "description": "Attributes describe features of the package, such as its dimensions\nand gross weight." + } + }, + "type": "object", + "description": "Package describes a physical package or container used to pack and ship an item, based on the UBL \"Package\" class." + } + } +} \ No newline at end of file diff --git a/examples/es/delivery.yaml b/examples/es/delivery.yaml index 043df64a4..8775136dc 100644 --- a/examples/es/delivery.yaml +++ b/examples/es/delivery.yaml @@ -34,3 +34,10 @@ lines: item: name: "Promo Mugs" price: "12.50" + packages: + - key: "box" + count: 2 + attributes: + - key: "weight" + amount: "3.5" + unit: "kg" diff --git a/examples/es/out/delivery.json b/examples/es/out/delivery.json index 5ac9974f5..870f2236b 100644 --- a/examples/es/out/delivery.json +++ b/examples/es/out/delivery.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "26e0d92f3290c44676c0de4df677e6710174a5a93a5ea6f843f956175d9cca42" + "val": "0be5694841e9dfc76f3dfada9e65f65a2fb5e9474c3976f42f960cb54e53c1b2" } }, "doc": { @@ -56,6 +56,19 @@ "name": "Promo Mugs", "price": "12.50" }, + "packages": [ + { + "key": "box", + "count": 2, + "attributes": [ + { + "key": "weight", + "amount": "3.5", + "unit": "kg" + } + ] + } + ], "sum": "125.00", "total": "125.00" } diff --git a/org/norm.go b/org/norm.go index 13c18560e..e81215aba 100644 --- a/org/norm.go +++ b/org/norm.go @@ -26,5 +26,6 @@ func init() { norm.For(normalizeWebsite), norm.For(normalizeAttachment), norm.For(normalizeAttribute), + norm.For(normalizePackage), ) } diff --git a/org/org.go b/org/org.go index 11ed8ff95..58f111a75 100644 --- a/org/org.go +++ b/org/org.go @@ -20,6 +20,7 @@ func init() { Item{}, Name{}, Note{}, + Package{}, Party{}, Person{}, Registration{}, @@ -43,6 +44,7 @@ func init() { itemRules(), nameRules(), noteRules(), + packageRules(), personRules(), registrationRules(), telephoneRules(), diff --git a/org/package.go b/org/package.go new file mode 100644 index 000000000..4abea514d --- /dev/null +++ b/org/package.go @@ -0,0 +1,264 @@ +package org + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/rules/is" + "github.com/invopop/gobl/uuid" + "github.com/invopop/jsonschema" +) + +// UNECERec21Key is used in package key definition maps to provide the +// equivalent UN/ECE Recommendation 21 package type code. +const UNECERec21Key cbc.Key = "unece-rec21" + +// UNECERec21MutuallyDefined is the UN/ECE Rec 21 code used when a package +// key cannot be mapped to a standard code. +const UNECERec21MutuallyDefined cbc.Code = "ZZ" + +// Standard package keys with mappings to UN/ECE Recommendation 21 package +// type codes. Keys may be extended with sub-keys, e.g. "box+gift", and will +// map to the code of their base key. +const ( + PackageKeyBag cbc.Key = "bag" + PackageKeyBale cbc.Key = "bale" + PackageKeyBarrel cbc.Key = "barrel" + PackageKeyBin cbc.Key = "bin" + PackageKeyBox cbc.Key = "box" + PackageKeyBundle cbc.Key = "bundle" + PackageKeyCan cbc.Key = "can" + PackageKeyCarton cbc.Key = "carton" + PackageKeyCase cbc.Key = "case" + PackageKeyCrate cbc.Key = "crate" + PackageKeyDrum cbc.Key = "drum" + PackageKeyEnvelope cbc.Key = "envelope" + PackageKeyPallet cbc.Key = "pallet" + PackageKeyReel cbc.Key = "reel" + PackageKeyRoll cbc.Key = "roll" + PackageKeySack cbc.Key = "sack" + PackageKeyTray cbc.Key = "tray" + PackageKeyTub cbc.Key = "tub" + PackageKeyTube cbc.Key = "tube" + PackageKeyUnpacked cbc.Key = "unpacked" +) + +// PackageKeyDefinitions describes each of the standard package keys with +// their UN/ECE Recommendation 21 mappings. +var PackageKeyDefinitions = []*cbc.Definition{ + { + Key: PackageKeyBag, + Name: i18n.NewString("Bag"), + Map: cbc.CodeMap{UNECERec21Key: "BG"}, + }, + { + Key: PackageKeyBale, + Name: i18n.NewString("Bale"), + Map: cbc.CodeMap{UNECERec21Key: "BL"}, + }, + { + Key: PackageKeyBarrel, + Name: i18n.NewString("Barrel"), + Map: cbc.CodeMap{UNECERec21Key: "BA"}, + }, + { + Key: PackageKeyBin, + Name: i18n.NewString("Bin"), + Map: cbc.CodeMap{UNECERec21Key: "BI"}, + }, + { + Key: PackageKeyBox, + Name: i18n.NewString("Box"), + Map: cbc.CodeMap{UNECERec21Key: "BX"}, + }, + { + Key: PackageKeyBundle, + Name: i18n.NewString("Bundle"), + Map: cbc.CodeMap{UNECERec21Key: "BE"}, + }, + { + Key: PackageKeyCan, + Name: i18n.NewString("Can"), + Map: cbc.CodeMap{UNECERec21Key: "CA"}, + }, + { + Key: PackageKeyCarton, + Name: i18n.NewString("Carton"), + Map: cbc.CodeMap{UNECERec21Key: "CT"}, + }, + { + Key: PackageKeyCase, + Name: i18n.NewString("Case"), + Map: cbc.CodeMap{UNECERec21Key: "CS"}, + }, + { + Key: PackageKeyCrate, + Name: i18n.NewString("Crate"), + Map: cbc.CodeMap{UNECERec21Key: "CR"}, + }, + { + Key: PackageKeyDrum, + Name: i18n.NewString("Drum"), + Map: cbc.CodeMap{UNECERec21Key: "DR"}, + }, + { + Key: PackageKeyEnvelope, + Name: i18n.NewString("Envelope"), + Map: cbc.CodeMap{UNECERec21Key: "EN"}, + }, + { + Key: PackageKeyPallet, + Name: i18n.NewString("Pallet"), + Map: cbc.CodeMap{UNECERec21Key: "PX"}, + }, + { + Key: PackageKeyReel, + Name: i18n.NewString("Reel"), + Map: cbc.CodeMap{UNECERec21Key: "RL"}, + }, + { + Key: PackageKeyRoll, + Name: i18n.NewString("Roll"), + Map: cbc.CodeMap{UNECERec21Key: "RO"}, + }, + { + Key: PackageKeySack, + Name: i18n.NewString("Sack"), + Map: cbc.CodeMap{UNECERec21Key: "SA"}, + }, + { + Key: PackageKeyTray, + Name: i18n.NewString("Tray"), + Map: cbc.CodeMap{UNECERec21Key: "DS"}, // plastic, matches UnitTray + }, + { + Key: PackageKeyTub, + Name: i18n.NewString("Tub"), + Map: cbc.CodeMap{UNECERec21Key: "TB"}, + }, + { + Key: PackageKeyTube, + Name: i18n.NewString("Tube"), + Map: cbc.CodeMap{UNECERec21Key: "TU"}, + }, + { + Key: PackageKeyUnpacked, + Name: i18n.NewString("Unpacked"), + Desc: i18n.NewString("Unpacked or unpackaged goods."), + Map: cbc.CodeMap{UNECERec21Key: "NE"}, + }, +} + +// HasValidPackageKey provides a validator to ensure a package key is at +// least *based* on one of the standard keys, allowing extensions such as +// "box+gift". +var HasValidPackageKey = cbc.HasValidKeyIn(validBasePackageKeys()...) + +func validBasePackageKeys() []cbc.Key { + list := make([]cbc.Key, len(PackageKeyDefinitions)) + for i, v := range PackageKeyDefinitions { + list[i] = v.Key + } + return list +} + +// Package describes a physical package or container used to pack and ship +// an item, based on the UBL "Package" class. +type Package struct { + uuid.Identify + // Label for the package for presentation in output documents. + Label string `json:"label,omitempty" jsonschema:"title=Label"` + // Key for the type of package, based on the list pre-defined by GOBL + // with mappings to UN/ECE Recommendation 21 codes. + Key cbc.Key `json:"key,omitempty" jsonschema:"title=Key"` + // Count of packages of this same type, when more than one. + Count int `json:"count,omitempty" jsonschema:"title=Count"` + // Identities used to identify the individual package, such as a + // GS1 Serial Shipping Container Code (SSCC). + Identities []*Identity `json:"identities,omitempty" jsonschema:"title=Identities"` + // Attributes describe features of the package, such as its dimensions + // and gross weight. + Attributes []*Attribute `json:"attributes,omitempty" jsonschema:"title=Attributes"` +} + +func packageRules() *rules.Set { + return rules.For(new(Package), + rules.Field("key", + rules.AssertIfPresent("01", "package key must be or extend one of the pre-defined keys", + HasValidPackageKey, + ), + ), + rules.Field("count", + rules.Assert("02", "package count must be zero or positive", is.Min(0)), + ), + rules.Field("attributes", + rules.Assert("03", "package attributes must not contain duplicate keys", + AttributesHaveUniqueKeys(), + ), + ), + ) +} + +func normalizePackage(p *Package) { + uuid.Normalize(&p.UUID) + p.Label = cbc.NormalizeString(p.Label) + p.Attributes = CleanAttributes(p.Attributes) +} + +// UNECERec21 provides the UN/ECE Recommendation 21 package type code for +// the package's key, matching extended keys to their base definition. +// Returns "ZZ" (mutually defined) for unmatched keys, or an empty code +// when no key is set. +func (p *Package) UNECERec21() cbc.Code { + if p == nil || p.Key == "" { + return cbc.CodeEmpty + } + for _, def := range PackageKeyDefinitions { + if p.Key == def.Key || p.Key.HasPrefix(def.Key) { + return def.Map[UNECERec21Key] + } + } + return UNECERec21MutuallyDefined +} + +// IsEmpty returns true if the package has no meaningful content. +func (p *Package) IsEmpty() bool { + return p == nil || (p.Label == "" && + p.Key == "" && + p.Count == 0 && + len(p.Identities) == 0 && + len(p.Attributes) == 0) +} + +// CleanPackages removes any nil or empty packages from the list. +func CleanPackages(pkgs []*Package) []*Package { + var cleaned []*Package + for _, p := range pkgs { + if p.IsEmpty() { + continue + } + cleaned = append(cleaned, p) + } + return cleaned +} + +// JSONSchemaExtend adds extra details to the schema. +func (Package) JSONSchemaExtend(js *jsonschema.Schema) { + prop, ok := js.Properties.Get("key") + if !ok { + return + } + anyOf := make([]*jsonschema.Schema, 0, len(PackageKeyDefinitions)+1) + for _, def := range PackageKeyDefinitions { + anyOf = append(anyOf, &jsonschema.Schema{ + Const: def.Key, + Title: def.Name.String(), + Description: def.Desc.String(), + }) + } + anyOf = append(anyOf, &jsonschema.Schema{ + Title: "Other", + Pattern: cbc.KeyPattern, + }) + prop.AnyOf = anyOf +} diff --git a/org/package_test.go b/org/package_test.go new file mode 100644 index 000000000..57f67f30f --- /dev/null +++ b/org/package_test.go @@ -0,0 +1,151 @@ +package org_test + +import ( + "encoding/json" + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/norm" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/rules" + "github.com/invopop/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageValidation(t *testing.T) { + t.Run("valid", func(t *testing.T) { + p := &org.Package{ + Key: org.PackageKeyBox, + Count: 2, + Attributes: []*org.Attribute{ + { + Key: org.AttributeKeyWeight, + Amount: num.NewAmount(35, 1), + Unit: org.UnitKilogram, + }, + }, + } + assert.NoError(t, rules.Validate(p)) + }) + t.Run("valid with extended key", func(t *testing.T) { + p := &org.Package{ + Key: org.PackageKeyBox.With("gift"), + } + assert.NoError(t, rules.Validate(p)) + }) + t.Run("valid without key", func(t *testing.T) { + p := &org.Package{ + Label: "Custom wrap", + } + assert.NoError(t, rules.Validate(p)) + }) + t.Run("unknown key", func(t *testing.T) { + p := &org.Package{ + Key: "spaceship", + } + assert.ErrorContains(t, rules.Validate(p), "package key must be or extend one of the pre-defined keys") + }) + t.Run("negative count", func(t *testing.T) { + p := &org.Package{ + Key: org.PackageKeyBox, + Count: -1, + } + assert.ErrorContains(t, rules.Validate(p), "package count must be zero or positive") + }) + t.Run("duplicate attribute keys", func(t *testing.T) { + p := &org.Package{ + Key: org.PackageKeyBox, + Attributes: []*org.Attribute{ + {Key: org.AttributeKeyWeight, Amount: num.NewAmount(1, 0), Unit: org.UnitKilogram}, + {Key: org.AttributeKeyWeight, Amount: num.NewAmount(2, 0), Unit: org.UnitKilogram}, + }, + } + assert.ErrorContains(t, rules.Validate(p), "package attributes must not contain duplicate keys") + }) +} + +func TestPackageNormalization(t *testing.T) { + t.Run("nil", func(t *testing.T) { + var p *org.Package + assert.NotPanics(t, func() { + norm.Normalize(p) + }) + }) + t.Run("trims label and cleans attributes", func(t *testing.T) { + p := &org.Package{ + Label: " Box 1 of 2 ", + Attributes: []*org.Attribute{ + nil, + {}, + }, + } + norm.Normalize(p) + assert.Equal(t, "Box 1 of 2", p.Label) + assert.Empty(t, p.Attributes) + }) +} + +func TestPackageUNECERec21(t *testing.T) { + tests := []struct { + name string + pkg *org.Package + want cbc.Code + }{ + {"nil", nil, cbc.CodeEmpty}, + {"empty key", &org.Package{}, cbc.CodeEmpty}, + {"box", &org.Package{Key: org.PackageKeyBox}, "BX"}, + {"pallet", &org.Package{Key: org.PackageKeyPallet}, "PX"}, + {"unpacked", &org.Package{Key: org.PackageKeyUnpacked}, "NE"}, + {"extended key", &org.Package{Key: org.PackageKeyBox.With("gift")}, "BX"}, + {"unknown key", &org.Package{Key: "spaceship"}, org.UNECERec21MutuallyDefined}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.pkg.UNECERec21()) + }) + } +} + +func TestCleanPackages(t *testing.T) { + t.Run("removes nil and empty entries", func(t *testing.T) { + pkgs := []*org.Package{ + nil, + {}, + {Key: org.PackageKeyBox}, + } + out := org.CleanPackages(pkgs) + require.Len(t, out, 1) + assert.Equal(t, org.PackageKeyBox, out[0].Key) + }) + t.Run("returns nil when none remain", func(t *testing.T) { + assert.Nil(t, org.CleanPackages([]*org.Package{nil, {}})) + assert.Nil(t, org.CleanPackages(nil)) + }) +} + +func TestPackageJSONSchemaExtend(t *testing.T) { + base := ` + { + "properties": { + "key": { + "$ref": "https://gobl.org/draft-0/cbc/key", + "title": "Key" + } + } + } + ` + js := new(jsonschema.Schema) + require.NoError(t, json.Unmarshal([]byte(base), js)) + org.Package{}.JSONSchemaExtend(js) + + prop, ok := js.Properties.Get("key") + require.True(t, ok) + require.Len(t, prop.AnyOf, len(org.PackageKeyDefinitions)+1) + assert.Equal(t, org.PackageKeyBag, prop.AnyOf[0].Const) + assert.Equal(t, "Bag", prop.AnyOf[0].Title) + last := prop.AnyOf[len(prop.AnyOf)-1] + assert.Equal(t, "Other", last.Title) + assert.NotEmpty(t, last.Pattern) +}