Skip to content

Commit 4851c79

Browse files
authored
feat(fedex): full customer references support (#1089)
Expands FedEx customerReferences from a single hardcoded invoice ref to multi-type (CUSTOMER_REFERENCE, INVOICE_NUMBER, DEPARTMENT_NUMBER, P_O_NUMBER, RMA_ASSOCIATION); fixes the REF field on labels (#1082). Co-authored-by: Chris Nolan <chrisnolan.ca+github@gmail.com> refs #1082
1 parent 64a7801 commit 4851c79

4 files changed

Lines changed: 296 additions & 13 deletions

File tree

modules/connectors/fedex/karrio/providers/fedex/shipment/create.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -490,16 +490,11 @@ def shipment_request(
490490
shipper.company_name or shipper.contact, max=35
491491
),
492492
comments=None,
493-
customerReferences=(
494-
[
495-
fedex.CustomerReferenceType(
496-
customerReferenceType="INVOICE_NUMBER",
497-
value=customs.invoice,
498-
)
499-
]
500-
if customs.invoice
501-
else []
502-
),
493+
customerReferences=provider_utils.collect_customer_references(
494+
payload,
495+
customs,
496+
options,
497+
)["commercial_invoice"],
503498
taxesOrMiscellaneousCharge=None,
504499
taxesOrMiscellaneousChargeType=None,
505500
freightCharge=None,
@@ -714,7 +709,11 @@ def shipment_request(
714709
fedex.RequestedPackageLineItemType(
715710
sequenceNumber=None,
716711
subPackagingType="OTHER",
717-
customerReferences=[],
712+
customerReferences=provider_utils.collect_customer_references(
713+
payload,
714+
customs,
715+
options,
716+
)["package"],
718717
declaredValue=fedex.TotalDeclaredValueType(
719718
amount=lib.identity(
720719
lib.to_money(package.total_value)

modules/connectors/fedex/karrio/providers/fedex/utils.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import karrio.schemas.fedex.shipping_request as shipping_request
12
import karrio.schemas.fedex.tracking_document_request as fedex
23
import gzip
34
import typing
45
import karrio.lib as lib
56
import karrio.core as core
7+
import karrio.core.models as models
8+
import karrio.core.units as units
69

710

811
class Settings(core.Settings):
@@ -100,3 +103,90 @@ def state_code(address: lib.units.ComputedAddress) -> str:
100103
if address.state_code.lower() == "qc" and address.country_code == "CA"
101104
else address.state_code
102105
)
106+
107+
108+
# Max lengths sourced from FedEx API spec (vendor/ship-api.json, CustomerReference schema).
109+
# Note: CUSTOMER_REFERENCE allows 40 chars for Express but only 30 for Ground.
110+
# We use the conservative Ground limit (30) to ensure all service types work correctly.
111+
CUSTOMER_REFERENCE_MAX_LENGTH = {
112+
"CUSTOMER_REFERENCE": 30,
113+
"DEPARTMENT_NUMBER": 30,
114+
"INVOICE_NUMBER": 30,
115+
"P_O_NUMBER": 30,
116+
"RMA_ASSOCIATION": 20,
117+
}
118+
119+
120+
def build_customer_reference(
121+
reference_type: str,
122+
value: typing.Optional[str],
123+
) -> typing.Optional[shipping_request.CustomerReferenceType]:
124+
parsed_value = lib.text(
125+
value,
126+
max=CUSTOMER_REFERENCE_MAX_LENGTH.get(reference_type, 30),
127+
)
128+
129+
if not any(parsed_value or ""):
130+
return None
131+
132+
return shipping_request.CustomerReferenceType(
133+
customerReferenceType=reference_type,
134+
value=parsed_value,
135+
)
136+
137+
138+
def collect_customer_references(
139+
payload: models.ShipmentRequest,
140+
customs: models.Customs,
141+
options: units.ShippingOptions,
142+
) -> typing.Dict[str, typing.List[shipping_request.CustomerReferenceType]]:
143+
raw_options = payload.options or {}
144+
invoice_number = lib.text(customs.invoice) or lib.text(options.invoice_number.state)
145+
146+
# NOTE: These references are sent via the option, but many don't current have a UI exposure.
147+
# would the label 'metadata' be a better fit for these values?
148+
references = {
149+
"CUSTOMER_REFERENCE": build_customer_reference(
150+
"CUSTOMER_REFERENCE",
151+
payload.reference,
152+
),
153+
"DEPARTMENT_NUMBER": build_customer_reference(
154+
"DEPARTMENT_NUMBER",
155+
lib.text(
156+
raw_options.get("fedex_department_number")
157+
or raw_options.get("department_number")
158+
),
159+
),
160+
"INVOICE_NUMBER": build_customer_reference("INVOICE_NUMBER", invoice_number),
161+
"P_O_NUMBER": build_customer_reference(
162+
"P_O_NUMBER",
163+
lib.text(raw_options.get("fedex_po_number") or raw_options.get("po_number")),
164+
),
165+
"RMA_ASSOCIATION": build_customer_reference(
166+
"RMA_ASSOCIATION",
167+
lib.text(
168+
raw_options.get("fedex_rma_association")
169+
or raw_options.get("rma_association")
170+
),
171+
),
172+
}
173+
174+
return {
175+
"commercial_invoice": [
176+
reference
177+
for key in ["INVOICE_NUMBER", "CUSTOMER_REFERENCE", "DEPARTMENT_NUMBER"]
178+
for reference in [references[key]]
179+
if reference is not None
180+
],
181+
"package": [
182+
reference
183+
for key in [
184+
"CUSTOMER_REFERENCE",
185+
"DEPARTMENT_NUMBER",
186+
"P_O_NUMBER",
187+
"RMA_ASSOCIATION",
188+
]
189+
for reference in [references[key]]
190+
if reference is not None
191+
],
192+
}

modules/connectors/fedex/tests/fedex/test_shipment.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,8 @@ def test_parse_return_shipment_response(self):
365365
"customsClearanceDetail": {
366366
"commercialInvoice": {
367367
"customerReferences": [
368-
{"customerReferenceType": "INVOICE_NUMBER", "value": "123456789"}
368+
{"customerReferenceType": "INVOICE_NUMBER", "value": "123456789"},
369+
{"customerReferenceType": "CUSTOMER_REFERENCE", "value": "#Order 11111"},
369370
],
370371
"originatorName": "Input Your Information",
371372
"termsOfSale": "DDU",
@@ -448,6 +449,12 @@ def test_parse_return_shipment_response(self):
448449
"units": "IN",
449450
"width": 12.0,
450451
},
452+
"customerReferences": [
453+
{
454+
"customerReferenceType": "CUSTOMER_REFERENCE",
455+
"value": "#Order 11111",
456+
}
457+
],
451458
"groupPackageCount": 1,
452459
"packageSpecialServices": {},
453460
"subPackagingType": "OTHER",
@@ -569,6 +576,12 @@ def test_parse_return_shipment_response(self):
569576
"units": "IN",
570577
"width": 12.0,
571578
},
579+
"customerReferences": [
580+
{
581+
"customerReferenceType": "CUSTOMER_REFERENCE",
582+
"value": "#Order 11111",
583+
}
584+
],
572585
"groupPackageCount": 1,
573586
"packageSpecialServices": {},
574587
"subPackagingType": "OTHER",
@@ -640,7 +653,8 @@ def test_parse_return_shipment_response(self):
640653
"customsClearanceDetail": {
641654
"commercialInvoice": {
642655
"customerReferences": [
643-
{"customerReferenceType": "INVOICE_NUMBER", "value": "123456789"}
656+
{"customerReferenceType": "INVOICE_NUMBER", "value": "123456789"},
657+
{"customerReferenceType": "CUSTOMER_REFERENCE", "value": "#Order 11111"},
644658
],
645659
"originatorName": "Input Your Information",
646660
"termsOfSale": "DDU",
@@ -723,6 +737,12 @@ def test_parse_return_shipment_response(self):
723737
"units": "IN",
724738
"width": 12,
725739
},
740+
"customerReferences": [
741+
{
742+
"customerReferenceType": "CUSTOMER_REFERENCE",
743+
"value": "#Order 11111",
744+
}
745+
],
726746
"groupPackageCount": 1,
727747
"packageSpecialServices": {},
728748
"subPackagingType": "OTHER",
@@ -736,6 +756,12 @@ def test_parse_return_shipment_response(self):
736756
"units": "IN",
737757
"width": 11,
738758
},
759+
"customerReferences": [
760+
{
761+
"customerReferenceType": "CUSTOMER_REFERENCE",
762+
"value": "#Order 11111",
763+
}
764+
],
739765
"groupPackageCount": 1,
740766
"packageSpecialServices": {},
741767
"subPackagingType": "OTHER",
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import unittest
2+
from unittest import mock
3+
4+
import karrio.providers.fedex.utils as provider_utils
5+
6+
7+
class TestFedExBuildCustomerReference(unittest.TestCase):
8+
def test_returns_none_for_none_value(self):
9+
result = provider_utils.build_customer_reference("CUSTOMER_REFERENCE", None)
10+
self.assertIsNone(result)
11+
12+
def test_returns_none_for_empty_string(self):
13+
result = provider_utils.build_customer_reference("CUSTOMER_REFERENCE", "")
14+
self.assertIsNone(result)
15+
16+
def test_returns_reference_for_valid_value(self):
17+
result = provider_utils.build_customer_reference("CUSTOMER_REFERENCE", "Ref-123")
18+
self.assertIsNotNone(result)
19+
self.assertEqual(result.customerReferenceType, "CUSTOMER_REFERENCE")
20+
self.assertEqual(result.value, "Ref-123")
21+
22+
def test_truncates_customer_reference_to_30_chars(self):
23+
result = provider_utils.build_customer_reference("CUSTOMER_REFERENCE", "X" * 35)
24+
self.assertIsNotNone(result)
25+
self.assertEqual(len(result.value), 30)
26+
27+
def test_truncates_rma_association_to_20_chars(self):
28+
result = provider_utils.build_customer_reference("RMA_ASSOCIATION", "R" * 25)
29+
self.assertIsNotNone(result)
30+
self.assertEqual(len(result.value), 20)
31+
32+
def test_preserves_value_at_exact_max_length(self):
33+
exact_value = "X" * 30
34+
result = provider_utils.build_customer_reference("CUSTOMER_REFERENCE", exact_value)
35+
self.assertIsNotNone(result)
36+
self.assertEqual(result.value, exact_value)
37+
38+
def test_preserves_value_under_max_length(self):
39+
result = provider_utils.build_customer_reference("DEPARTMENT_NUMBER", "DEPT-001")
40+
self.assertIsNotNone(result)
41+
self.assertEqual(result.value, "DEPT-001")
42+
43+
44+
class TestFedExCollectCustomerReferences(unittest.TestCase):
45+
def _make_payload(self, reference=None, options=None):
46+
payload = mock.MagicMock()
47+
payload.reference = reference
48+
payload.options = options or {}
49+
return payload
50+
51+
def _make_customs(self, invoice=None):
52+
customs = mock.MagicMock()
53+
customs.invoice = invoice
54+
return customs
55+
56+
def _make_options(self, invoice_number=None):
57+
options = mock.MagicMock()
58+
options.invoice_number.state = invoice_number
59+
return options
60+
61+
def test_returns_commercial_invoice_and_package_keys(self):
62+
result = provider_utils.collect_customer_references(
63+
self._make_payload(),
64+
self._make_customs(),
65+
self._make_options(),
66+
)
67+
self.assertIn("commercial_invoice", result)
68+
self.assertIn("package", result)
69+
70+
def test_empty_references_excluded_when_no_data(self):
71+
result = provider_utils.collect_customer_references(
72+
self._make_payload(reference=None),
73+
self._make_customs(invoice=None),
74+
self._make_options(invoice_number=None),
75+
)
76+
self.assertEqual(result["commercial_invoice"], [])
77+
self.assertEqual(result["package"], [])
78+
79+
def test_invoice_number_in_commercial_invoice_from_customs(self):
80+
result = provider_utils.collect_customer_references(
81+
self._make_payload(),
82+
self._make_customs(invoice="INV-123"),
83+
self._make_options(),
84+
)
85+
values = {r.customerReferenceType: r.value for r in result["commercial_invoice"]}
86+
self.assertIn("INVOICE_NUMBER", values)
87+
self.assertEqual(values["INVOICE_NUMBER"], "INV-123")
88+
89+
def test_invoice_number_falls_back_to_options(self):
90+
result = provider_utils.collect_customer_references(
91+
self._make_payload(),
92+
self._make_customs(invoice=None),
93+
self._make_options(invoice_number="OPT-INV-789"),
94+
)
95+
values = {r.customerReferenceType: r.value for r in result["commercial_invoice"]}
96+
self.assertIn("INVOICE_NUMBER", values)
97+
self.assertEqual(values["INVOICE_NUMBER"], "OPT-INV-789")
98+
99+
def test_customs_invoice_takes_priority_over_options(self):
100+
result = provider_utils.collect_customer_references(
101+
self._make_payload(),
102+
self._make_customs(invoice="CUSTOMS-INV"),
103+
self._make_options(invoice_number="OPT-INV"),
104+
)
105+
values = {r.customerReferenceType: r.value for r in result["commercial_invoice"]}
106+
self.assertEqual(values["INVOICE_NUMBER"], "CUSTOMS-INV")
107+
108+
def test_customer_reference_in_package_from_payload(self):
109+
result = provider_utils.collect_customer_references(
110+
self._make_payload(reference="#MyOrder"),
111+
self._make_customs(),
112+
self._make_options(),
113+
)
114+
types = [r.customerReferenceType for r in result["package"]]
115+
self.assertIn("CUSTOMER_REFERENCE", types)
116+
117+
def test_package_reference_number_is_ignored_for_po_number(self):
118+
result = provider_utils.collect_customer_references(
119+
self._make_payload(),
120+
self._make_customs(),
121+
self._make_options(),
122+
)
123+
types = [r.customerReferenceType for r in result["package"]]
124+
self.assertNotIn("P_O_NUMBER", types)
125+
126+
def test_po_number_falls_back_to_options(self):
127+
result = provider_utils.collect_customer_references(
128+
self._make_payload(options={"fedex_po_number": "OPT-PO-999"}),
129+
self._make_customs(),
130+
self._make_options(),
131+
)
132+
values = {r.customerReferenceType: r.value for r in result["package"]}
133+
self.assertIn("P_O_NUMBER", values)
134+
self.assertEqual(values["P_O_NUMBER"], "OPT-PO-999")
135+
136+
def test_invoice_number_excluded_from_package_references(self):
137+
result = provider_utils.collect_customer_references(
138+
self._make_payload(),
139+
self._make_customs(invoice="INV-123"),
140+
self._make_options(),
141+
)
142+
package_types = [r.customerReferenceType for r in result["package"]]
143+
self.assertNotIn("INVOICE_NUMBER", package_types)
144+
145+
def test_customer_reference_appears_in_both_commercial_and_package(self):
146+
result = provider_utils.collect_customer_references(
147+
self._make_payload(reference="#SharedRef"),
148+
self._make_customs(),
149+
self._make_options(),
150+
)
151+
commercial_types = [r.customerReferenceType for r in result["commercial_invoice"]]
152+
package_types = [r.customerReferenceType for r in result["package"]]
153+
self.assertIn("CUSTOMER_REFERENCE", commercial_types)
154+
self.assertIn("CUSTOMER_REFERENCE", package_types)
155+
156+
def test_rma_association_in_package_from_options(self):
157+
result = provider_utils.collect_customer_references(
158+
self._make_payload(options={"fedex_rma_association": "RMA-001"}),
159+
self._make_customs(),
160+
self._make_options(),
161+
)
162+
values = {r.customerReferenceType: r.value for r in result["package"]}
163+
self.assertIn("RMA_ASSOCIATION", values)
164+
self.assertEqual(values["RMA_ASSOCIATION"], "RMA-001")
165+
166+
167+
if __name__ == "__main__":
168+
unittest.main()

0 commit comments

Comments
 (0)