Skip to content

Commit feb9e15

Browse files
committed
Add dpd_fr transporter with api v2
1 parent 65da194 commit feb9e15

20 files changed

+44684
-0
lines changed

roulier/carriers/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .gls_fr import glsbox as gls_fr_glsbox
44
from . import chronopost_fr
55
from . import dpd_fr_soap
6+
from . import dpd_fr
67
from . import geodis_fr
78
from . import mondialrelay
89
from . import mondialrelay_fr

roulier/carriers/dpd_fr/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import carrier

roulier/carriers/dpd_fr/carrier.py

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Copyright 2024 Akretion (http://www.akretion.com).
2+
# @author Florian Mounier <florian.mounier@akretion.com>
3+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
4+
import zeep
5+
6+
from ...carrier import Carrier, action
7+
from ...exception import CarrierError
8+
from .schema import DpdFrLabelInput, DpdFrLabelOutput
9+
10+
11+
class DpdFr(Carrier):
12+
__key__ = "dpd_fr"
13+
__url__ = (
14+
"https://e-station.cargonet.software/dpd-eprintwebservice/eprintwebservice.asmx"
15+
)
16+
__url_test__ = "https://e-station-testenv.cargonet.software/eprintwebservice/eprintwebservice.asmx"
17+
__ns_prefix__ = "http://www.cargonet.software"
18+
19+
def _get_client(self, is_test):
20+
url = self.__url_test__ if is_test else self.__url__
21+
client = zeep.Client(wsdl=f"{url}?WSDL")
22+
client.set_ns_prefix(None, self.__ns_prefix__)
23+
return client
24+
25+
@action
26+
def get_label(self, input: DpdFrLabelInput) -> DpdFrLabelOutput:
27+
client = self._get_client(input.auth.isTest)
28+
try:
29+
result = client.service.CreateShipmentWithLabelsBc(**input.soap(client))
30+
except zeep.exceptions.Fault as e:
31+
error_id = e.detail.xpath("//ErrorId")
32+
if len(error_id) > 0:
33+
error_id = error_id[0].text
34+
else:
35+
error_id = "UnknownError"
36+
raise CarrierError(None, msg=[{"id": error_id, "message": str(e)}]) from e
37+
return DpdFrLabelOutput.from_soap(result, input.service.labelFormat)

roulier/carriers/dpd_fr/schema.py

+293
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
# Copyright 2024 Akretion (http://www.akretion.com).
2+
# @author Florian Mounier <florian.mounier@akretion.com>
3+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
4+
from base64 import b64encode
5+
from datetime import date
6+
from enum import Enum
7+
from pydantic.functional_validators import AfterValidator
8+
from typing_extensions import Annotated
9+
from zeep import xsd
10+
11+
from ...schema import (
12+
LabelInput,
13+
Address,
14+
LabelOutput,
15+
Auth,
16+
Service,
17+
Parcel,
18+
ParcelLabel,
19+
Label,
20+
Tracking,
21+
)
22+
23+
24+
class Format(str, Enum):
25+
PNG = "PNG"
26+
PDF = "PDF"
27+
PDF_A6 = "PDF_A6"
28+
ZPL = "ZPL"
29+
ZPL300 = "ZPL300"
30+
ZPL_A6 = "ZPL_A6"
31+
ZPL300_A6 = "ZPL300_A6"
32+
EPL = "EPL"
33+
34+
35+
class Notifications(str, Enum):
36+
No = "No"
37+
Predict = "Predict"
38+
AutomaticSMS = "AutomaticSMS"
39+
AutomaticMail = "AutomaticMail"
40+
41+
42+
class Product(str, Enum):
43+
DPD_Classic = "DPD_Classic"
44+
DPD_Predict = "DPD_Predict"
45+
DPD_Relais = "DPD_Relais"
46+
47+
48+
class DpdFrAuth(Auth):
49+
login: str
50+
51+
def soap(self):
52+
return xsd.Element(
53+
"UserCredentials",
54+
xsd.ComplexType(
55+
[
56+
xsd.Element("userid", xsd.String()),
57+
xsd.Element("password", xsd.String()),
58+
]
59+
),
60+
)(
61+
userid=self.login,
62+
password=self.password,
63+
)
64+
65+
66+
def dpd_service_validator(service):
67+
if (
68+
service.product in (Product.DPD_Predict, Product.DPD_Classic)
69+
and service.pickupLocationId
70+
):
71+
raise ValueError(f"pickupLocationId can't be used with {service.product}")
72+
73+
if service.product == Product.DPD_Predict:
74+
if service.notifications != Notifications.Predict:
75+
raise ValueError("Predict notifications must be set to Predict")
76+
else:
77+
if service.notifications == Notifications.Predict:
78+
raise ValueError(
79+
f"Predict notifications can't be used with {service.product}"
80+
)
81+
if service.product == Product.DPD_Relais and not service.pickupLocationId:
82+
raise ValueError("pickupLocationId is mandatory for Relais")
83+
84+
return service
85+
86+
87+
class DpdFrService(Service):
88+
labelFormat: Format = Format.PDF
89+
agencyId: str
90+
customerCountry: str
91+
customerId: str
92+
shippingDate: date | None = None
93+
notifications: Notifications = Notifications.No
94+
product: Product = Product.DPD_Classic
95+
pickupLocationId: str | None = None
96+
97+
def soap(self, client, phone, email, ref):
98+
service = client.get_type("ns0:StdServices")
99+
contact = client.get_type("ns0:Contact")
100+
label_type = client.get_type("ns0:LabelType")
101+
ref_in_barcode = client.get_type("ns0:ReferenceInBarcode")
102+
103+
service_kwargs = {
104+
"contact": contact(sms=phone, email=email, type=self.notifications.value),
105+
}
106+
107+
if self.product == Product.DPD_Relais:
108+
parcel_shop = client.get_type("ns0:ParcelShop")
109+
shop_address = client.get_type("ns0:ShopAddress")
110+
service_kwargs.update(
111+
{
112+
"parcelshop": parcel_shop(
113+
shopaddress=shop_address(
114+
shopid=self.pickupLocationId,
115+
)
116+
)
117+
}
118+
)
119+
120+
return {
121+
"customer_countrycode": self.customerCountry,
122+
"customer_centernumber": self.agencyId,
123+
"customer_number": self.customerId,
124+
"referencenumber": self.reference1,
125+
"reference2": self.reference2 or ref,
126+
"reference3": self.reference3,
127+
"refnrasbarcode": str(bool(self.reference2)).lower(),
128+
"referenceInBarcode": ref_in_barcode(type="Reference2"),
129+
"shippingdate": self.shippingDate.strftime("%d/%m/%Y"),
130+
"labelType": label_type(
131+
type=(
132+
self.labelFormat.value
133+
if self.labelFormat != Format.PNG
134+
else "Default"
135+
)
136+
),
137+
"services": service(
138+
**service_kwargs,
139+
),
140+
}
141+
142+
143+
class DpdFrParcel(Parcel):
144+
def soap(self):
145+
return {
146+
"weight": self.weight,
147+
}
148+
149+
150+
class DpdFrAddress(Address):
151+
country: str
152+
zip: str
153+
city: str
154+
street1: str
155+
name2: str | None = None
156+
name3: str | None = None
157+
name4: str | None = None
158+
door1: str | None = None
159+
door2: str | None = None
160+
intercom: str | None = None
161+
162+
def soap(self, client):
163+
address = client.get_type("ns0:Address")
164+
address_info = client.get_type("ns0:AddressInfo")
165+
return {
166+
"address": address(
167+
name=", ".join(
168+
[part for part in (self.name, self.company) if part],
169+
)[0:35],
170+
countryPrefix=self.country,
171+
zipCode=self.zip,
172+
city=self.city,
173+
street=", ".join(
174+
[part for part in (self.street1, self.street2) if part]
175+
)[0:70],
176+
phoneNumber=self.phone,
177+
),
178+
"info": address_info(
179+
contact=self.company,
180+
name2=self.name2,
181+
name3=self.name3,
182+
name4=self.name4,
183+
digicode1=self.door1,
184+
digicode2=self.door2,
185+
intercomid=self.intercom,
186+
vinfo1=(
187+
self.delivery_instructions[0:35]
188+
if getattr(self, "delivery_instructions", None)
189+
else None
190+
),
191+
vinfo2=(
192+
self.delivery_instructions[35:70]
193+
if getattr(self, "delivery_instructions", None)
194+
and len(self.delivery_instructions) > 35
195+
else None
196+
),
197+
),
198+
}
199+
200+
201+
class DpdFrFromAddress(DpdFrAddress):
202+
phone: str
203+
204+
def soap(self, client):
205+
rv = super().soap(client)
206+
return {
207+
"shipperaddress": rv["address"],
208+
"shipperinfo": rv["info"],
209+
}
210+
211+
212+
class DpdFrToAddress(DpdFrAddress):
213+
def soap(self, client):
214+
rv = super().soap(client)
215+
return {
216+
"receiveraddress": rv["address"],
217+
"receiverinfo": rv["info"],
218+
}
219+
220+
221+
class DpdFrLabelInput(LabelInput):
222+
auth: DpdFrAuth
223+
service: Annotated[DpdFrService, AfterValidator(dpd_service_validator)]
224+
parcels: list[DpdFrParcel]
225+
from_address: DpdFrFromAddress
226+
to_address: DpdFrToAddress
227+
228+
def soap(self, client):
229+
request = client.get_type("ns0:StdShipmentLabelRequest")
230+
request_kwargs = {
231+
**self.service.soap(
232+
client,
233+
self.to_address.phone,
234+
self.to_address.email,
235+
self.parcels[0].reference,
236+
),
237+
**self.parcels[0].soap(),
238+
**self.from_address.soap(client),
239+
**self.to_address.soap(client),
240+
}
241+
242+
return {
243+
"_soapheaders": [self.auth.soap()],
244+
"request": request(**request_kwargs),
245+
}
246+
247+
248+
class DpdFrLabel(Label):
249+
@classmethod
250+
def from_soap(cls, result, format):
251+
return cls.model_construct(
252+
data=b64encode(result["label"]).decode("utf-8"),
253+
name=f"{format.value} Label",
254+
type=format.value,
255+
)
256+
257+
258+
class DpdFrTracking(Tracking):
259+
@classmethod
260+
def from_soap(cls, result):
261+
return cls.model_construct(
262+
number=result["BarcodeId"],
263+
)
264+
265+
266+
class DpdFrParcelLabel(ParcelLabel):
267+
label: DpdFrLabel | None = None
268+
269+
@classmethod
270+
def from_soap(cls, id, shipment, label, format):
271+
return cls.model_construct(
272+
id=id,
273+
label=DpdFrLabel.from_soap(label, format),
274+
reference=shipment["Shipment"]["BarCode"],
275+
tracking=DpdFrTracking.from_soap(shipment["Shipment"]),
276+
)
277+
278+
279+
class DpdFrLabelOutput(LabelOutput):
280+
parcels: list[DpdFrParcelLabel]
281+
282+
@classmethod
283+
def from_soap(cls, result, format):
284+
shipments = result["shipments"]["ShipmentBc"]
285+
labels = result["labels"]["Label"]
286+
assert len(shipments) == len(labels), "Mismatched shipments and labels"
287+
parcels = zip(shipments, labels)
288+
return cls.model_construct(
289+
parcels=[
290+
DpdFrParcelLabel.from_soap(i + 1, shipment, label, format)
291+
for i, (shipment, label) in enumerate(parcels)
292+
]
293+
)

roulier/carriers/dpd_fr/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)