Compare commits
1 commit
18.0
...
delivery-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08065d7c99 |
17 changed files with 968 additions and 0 deletions
1
bemade_delivery_gls_canada/__init__.py
Normal file
1
bemade_delivery_gls_canada/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
33
bemade_delivery_gls_canada/__manifest__.py
Normal file
33
bemade_delivery_gls_canada/__manifest__.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#
|
||||
# Bemade Inc.
|
||||
#
|
||||
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
|
||||
# Author: Marc Durepos (Contact : mdurepos@durpro.com)
|
||||
#
|
||||
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
|
||||
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
|
||||
# or modified copies of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
# DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
{
|
||||
'name': '',
|
||||
'version': '15.0.1.0.0',
|
||||
'summary': '',
|
||||
'description': '',
|
||||
'category': '',
|
||||
'author': 'Bemade Inc.',
|
||||
'website': 'http://www.bemade.org',
|
||||
'license': 'OPL-1',
|
||||
'depends': ['delivery', 'bemade_delivery_partner_accounts'],
|
||||
'data': [],
|
||||
'assets': {},
|
||||
'installable': True,
|
||||
'auto_install': False
|
||||
}
|
||||
6
bemade_delivery_gls_canada/models/__init__.py
Normal file
6
bemade_delivery_gls_canada/models/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from . import delivery_carrier
|
||||
from . import gls_canada_shipping_charge
|
||||
from . import gls_canada_credentials
|
||||
from . import gls_canada_shipment
|
||||
from . import res_partner
|
||||
from . import sale_order
|
||||
605
bemade_delivery_gls_canada/models/delivery_carrier.py
Normal file
605
bemade_delivery_gls_canada/models/delivery_carrier.py
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
import binascii
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from odoo import fields, models, api, _, exceptions
|
||||
from datetime import date, timedelta, datetime
|
||||
import holidays
|
||||
import pytz
|
||||
from timezonefinder import TimezoneFinder
|
||||
import phonenumbers
|
||||
from typing import List
|
||||
|
||||
_logger = logging.getLogger('Gls Canada')
|
||||
|
||||
|
||||
class DeliveryCarrier(models.Model):
|
||||
_inherit = 'delivery.carrier'
|
||||
|
||||
delivery_type = fields.Selection(selection_add=[("gls_canada", "GLS Canada")],
|
||||
ondelete={'gls_canada': 'set default'})
|
||||
|
||||
gls_canada_packaging_id = fields.Many2one('stock.package.type',
|
||||
string="Default Package Type")
|
||||
|
||||
gls_canada_payment_type = fields.Selection([('Prepaid', 'Prepaid'),
|
||||
('ThirdParty', 'ThirdParty'),
|
||||
('Collect', 'Collect')],
|
||||
string='Gls canada payment type',
|
||||
help="Please select GLS Canada payment type")
|
||||
gls_canada_delivery_type = fields.Selection([('AIR', 'AIR'),
|
||||
('GRD', 'GRD')],
|
||||
string='Gls canada delivery type',
|
||||
help="Please select GLS Canada delivery type")
|
||||
gls_canada_unit_measurement = fields.Selection([('K', 'kg (implies cm)'),
|
||||
('L', 'lb (implies inch)'),
|
||||
('KC', 'kg-cm'), ('KM', 'kg-meter'),
|
||||
('LI', 'lb-inch'),
|
||||
('LF', 'lb-feet'), ],
|
||||
string='Unit Of Measurement')
|
||||
parcel_type = fields.Selection([('Barrel', 'Barrel - freight'),
|
||||
('Bundle', 'Bundle - freight'),
|
||||
('Box', 'Box - freight'),
|
||||
('Crate', 'Crate - freight'),
|
||||
('FullLoad', 'FullLoad - freight'),
|
||||
('Piece', 'Piece - freight'),
|
||||
('Skid', 'Skid - freight'),
|
||||
('Tube', 'Tube - freight'),
|
||||
('Other', 'Other - freight'),
|
||||
('Box', 'Box - Parcel Canada'),
|
||||
('Envelope', 'Envelope - Parcel Canada'),
|
||||
('Other', 'Other - Parcel Canada'),
|
||||
('Mixed', 'Mixed - Parcel Canada')],
|
||||
string='Parcel Type')
|
||||
return_label = fields.Boolean("Required Return Label ?")
|
||||
category = fields.Selection([('Parcel', 'Parcel'),
|
||||
('Freight', 'Freight'),
|
||||
('Distribution', 'Distribution'),
|
||||
('Logistics', 'Logistics'), ],
|
||||
string='Gls canada category type')
|
||||
appointment_type = fields.Selection([('Scheduled', 'Scheduled'),
|
||||
('Required', 'Required')])
|
||||
|
||||
def gls_canada_rate_shipment(self, order):
|
||||
"""
|
||||
Estimate the cost of a shipment from a sales order. Called by rate_shipment in
|
||||
delivery/models/delivery_carrier.py.
|
||||
|
||||
:param order: ResultSet of sale.order records, all for the same recipient and from the same warehouse
|
||||
:return:
|
||||
"""
|
||||
|
||||
Estimate = self.env['gls.canada.shipping.estimate']
|
||||
|
||||
sender = order.warehouse_id.partner_id
|
||||
consignee = order.partner_shipping_id
|
||||
billing_account = order.delivery_billing_account
|
||||
weight = sum([
|
||||
(line.product_id.weight * line.product_uom_qty) for line in
|
||||
order.order_line if not line.is_delivery
|
||||
])
|
||||
data = {
|
||||
"sender": {
|
||||
"postalCode": sender.zip or '',
|
||||
"provinceCode": sender.state_id and sender.state_id.code or '',
|
||||
"countryCode": sender.country_id and sender.country_id.code or '',
|
||||
},
|
||||
"consignee": {
|
||||
"postalCode": consignee.zip or '',
|
||||
"provinceCode": consignee.state_id and consignee.state_id.code or '',
|
||||
"countryCode": consignee.country_id and consignee.country_id.code or '',
|
||||
},
|
||||
"billing": self.company_id.gls_canada_billing_number or '',
|
||||
"paymentType": self.gls_canada_payment_type,
|
||||
"deliveryType": self.gls_canada_delivery_type,
|
||||
|
||||
"unitOfMeasurement": self.gls_canada_unit_measurement,
|
||||
|
||||
"parcels": [
|
||||
{
|
||||
"id": "1",
|
||||
"parcelType": self.parcel_type,
|
||||
"quantity": "1",
|
||||
"weight": weight,
|
||||
"length": self.gls_canada_packaging_id.packaging_length or 0,
|
||||
"depth": self.gls_canada_packaging_id.height or 0,
|
||||
"width": self.gls_canada_packaging_id.width or 0,
|
||||
# "FCA_Class": "100.00",
|
||||
|
||||
# "requestReturnLabel": False,
|
||||
}
|
||||
],
|
||||
"category": self.category,
|
||||
|
||||
}
|
||||
if self.category == "Freight":
|
||||
if self.appointment_type == 'Scheduled':
|
||||
data.update({"appointment": {
|
||||
"type": self.appointment_type,
|
||||
"date": order.commitment_date.strftime(
|
||||
"%Y-%m-%d") if self.appointment_type == "Scheduled" else '',
|
||||
"time": order.commitment_date.strftime(
|
||||
"%H:%M") if self.appointment_type == "Scheduled" else '',
|
||||
}})
|
||||
# if self.appointment_type != 'Scheduled':
|
||||
else:
|
||||
data.update({"appointment": {
|
||||
"type": self.appointment_type,
|
||||
"phone": consignee.phone or ''
|
||||
}})
|
||||
|
||||
data = json.dumps(data)
|
||||
_logger.info("Rate Request Data %s" % data)
|
||||
username = self.company_id.gls_canada_username
|
||||
password = self.company_id.gls_canada_password
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_url = "%s/rate" % (self.company_id.gls_canada_api_url)
|
||||
response_data = requests.post(url=api_url, headers=headers,
|
||||
auth=HTTPBasicAuth(username, password),
|
||||
data=data)
|
||||
_logger.info("Rate response %s" % response_data.content)
|
||||
try:
|
||||
if response_data.status_code in [200, 201]:
|
||||
response_body = response_data.json()
|
||||
_logger.info(response_body)
|
||||
existing_records = Estimate.search(
|
||||
[('sale_order_id', '=', order and order.id)])
|
||||
existing_records.sudo().unlink()
|
||||
if isinstance(response_body.get('rates'), dict):
|
||||
response_body = [response_body.get('rates')]
|
||||
for response_dict in response_body.get('rates'):
|
||||
Estimate.sudo().create(
|
||||
{
|
||||
'account_type': response_dict.get('accountType'),
|
||||
'rate_type': response_dict.get('rateType'),
|
||||
'total_charge': response_dict.get('total'),
|
||||
'sale_order_id': order and order.id
|
||||
}
|
||||
)
|
||||
gls_canada_charge_id = Estimate.search(
|
||||
[('sale_order_id', '=', order and order.id)], order='total_charge',
|
||||
limit=1)
|
||||
order.gls_canada_shipping_charge_id = gls_canada_charge_id and gls_canada_charge_id.id
|
||||
return {'success': True,
|
||||
'price': gls_canada_charge_id and gls_canada_charge_id.total_charge or 0.0,
|
||||
'error_message': False, 'warning_message': False}
|
||||
else:
|
||||
return {'success': False, 'price': 0.0,
|
||||
'error_message': "%s %s" % (response_data, response_data.text),
|
||||
'warning_message': False}
|
||||
except Exception as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
def gls_canada_send_shipping(self, pickings):
|
||||
""" Send the package to the service provider
|
||||
|
||||
:param pickings: A recordset of pickings
|
||||
:return list: A list of dictionaries (one per picking) containing of the form::
|
||||
{ 'exact_price': price,
|
||||
'tracking_number': number }
|
||||
"""
|
||||
# See if there are available pickups
|
||||
shipments_dict = self._group_pickings_by_warehouse_and_recipient(pickings)
|
||||
pickups = self._gls_canada_get_pickups_for_senders(shipments_dict.keys())
|
||||
picking_shipment_data_dict = {}
|
||||
for sender, recipient_dict in shipments_dict.items():
|
||||
for recipient, picks in recipient_dict.items():
|
||||
shipping_data, shipment_id = self._gls_canada_send_shipment(picks, pickups.get(sender))
|
||||
picking_shipment_data_dict.update(
|
||||
{pick: shipping_data for pick in picks})
|
||||
pickup_id = pickups.get(sender)
|
||||
if pickup_id:
|
||||
self._add_shipment_to_pickup(shipment_id, pickup_id)
|
||||
if not pickups.get(sender):
|
||||
pickup_time = self._get_pickup_time(sender)
|
||||
pickup_id = self._gls_canada_schedule_pickup(picks[0].shipper,
|
||||
picks.mapped('shipment_id'),
|
||||
pickup_time)
|
||||
picks.pickup_id = pickup_id
|
||||
return [picking_shipment_data_dict[picking] for picking in pickings]
|
||||
|
||||
def _add_shipment_to_pickup(self, shipment_id, pickup_id):
|
||||
endpoint = f'/pickup/{pickup_id}/add-shipment/{shipment_id}'
|
||||
response = self._gls_canada_api_call(endpoint, "POST", {})
|
||||
if response.status_code != 200:
|
||||
self._raise_gls_error(endpoint, response)
|
||||
|
||||
def gls_canada_cancel_shipment(self, pickings):
|
||||
"""This Method is used for cancel shipment from odoo to matkahuolto"""
|
||||
api_url = "%s/shipment/%s" % (
|
||||
self.company_id.gls_canada_api_url, pickings.shipment_id)
|
||||
username = self.company_id.gls_canada_username
|
||||
password = self.company_id.gls_canada_password
|
||||
headers = {"Content-Type": "application/json"}
|
||||
response_body = requests.request(method="DELETE", url=api_url, headers=headers,
|
||||
auth=HTTPBasicAuth(username, password))
|
||||
_logger.info("Cancel Shipment Response %s" % response_body.content)
|
||||
if response_body.status_code in [200, 201]:
|
||||
_logger.info("Shipment Cancel Successfully")
|
||||
return True
|
||||
else:
|
||||
raise ValidationError(response_body.content)
|
||||
|
||||
def _gls_canada_shipping_request_data(self, pickings):
|
||||
parcel_data = []
|
||||
counter = 0
|
||||
for package in pickings.package_ids:
|
||||
counter += 1
|
||||
parcel_data.append({
|
||||
"id": counter,
|
||||
"parcelType": self.parcel_type,
|
||||
"quantity": "1",
|
||||
"weight": package.shipping_weight,
|
||||
"length": package.package_type_id.packaging_length or '',
|
||||
"depth": package.package_type_id.height or '',
|
||||
"width": package.package_type_id.width or '',
|
||||
# "requestReturnLabel": self.return_label,
|
||||
})
|
||||
if pickings.weight_bulk:
|
||||
counter += 1
|
||||
parcel_data.append({
|
||||
"id": counter,
|
||||
"parcelType": self.parcel_type,
|
||||
"quantity": "1",
|
||||
"weight": pickings.shipping_weight,
|
||||
"length": self.gls_canada_packaging_id.packaging_length or '',
|
||||
"depth": self.gls_canada_packaging_id.height or '',
|
||||
"width": self.gls_canada_packaging_id.width or '',
|
||||
# "requestReturnLabel": self.return_label,
|
||||
})
|
||||
|
||||
receiver_address = pickings.partner_id
|
||||
shipper_address = pickings.picking_type_id.warehouse_id.partner_id
|
||||
sender_contact = pickings[0].shipper
|
||||
request_data = {
|
||||
"category": self.category,
|
||||
"paymentType": self.gls_canada_payment_type,
|
||||
"billingAccount": self.company_id.gls_canada_billing_number,
|
||||
"sender": self._gls_canada_make_sender_data(shipper_address, sender_contact),
|
||||
"consignee": self._gls_canada_make_consignee_data(receiver_address),
|
||||
"unitOfMeasurement": self.gls_canada_unit_measurement,
|
||||
"parcels": parcel_data,
|
||||
"deliveryType": self.gls_canada_delivery_type,
|
||||
}
|
||||
# if self.return_label == True:
|
||||
# request_data.update({"returnAddress": {"contact": {
|
||||
# "fullName": shipper_address.name or '',
|
||||
# "language": "en",
|
||||
# "email": "example@dicom.com",
|
||||
# "department": "",
|
||||
# "telephone": shipper_address.phone or '',
|
||||
# "extension": ""
|
||||
# }
|
||||
# }})
|
||||
# if shipper_address.country_id and shipper_address.country_id.code != receiver_address.country_id.code:
|
||||
# request_data.update(
|
||||
# {"internationalDetails": {"descriptionOfGoods": 'Workspace123', "isDicomBroker": 'true', "products": [
|
||||
# {
|
||||
# "id": "17451",
|
||||
# "Quantity": 1
|
||||
# }
|
||||
# ]
|
||||
# }})
|
||||
_logger.info("Shipping Request Data %s" % request_data)
|
||||
return request_data
|
||||
|
||||
def _gls_canada_get_pickups_for_senders(self, senders) -> dict:
|
||||
"""
|
||||
Get a dict matching each sender to any already existing pickups today (or next
|
||||
business day). If no matching pickup exists, the sender is not added to the keys
|
||||
of the dict and a pickup will need to be created for it elsewhere.
|
||||
:param senders: The recordset of partner_ids matching the pickup locations
|
||||
:return: dict of the form {sender: "pickup ID"}
|
||||
"""
|
||||
endpoint = "/pickup/list"
|
||||
# First, group the senders by country and state so that holidays can be properly
|
||||
# handled for each location.
|
||||
locales = set()
|
||||
for sender in senders:
|
||||
locales.add((sender.country_id, sender.state_id))
|
||||
sender_groups = [[sender for sender in senders if
|
||||
(sender.country_id, sender.state_id) == locale] for locale in
|
||||
locales]
|
||||
senders_to_pickups_dict = {}
|
||||
# For each group sharing a locale, get pickups for the next working day
|
||||
for sender_group in sender_groups:
|
||||
pickup_date = self._get_pickup_time(sender_group[0])
|
||||
request_data = {
|
||||
'category': self.category,
|
||||
'pickupDate': pickup_date.strftime("%Y-%m-%d"),
|
||||
}
|
||||
response = self._gls_canada_api_call(endpoint, "GET", request_data)
|
||||
_logger.info("GLS Response status code:%s" % response.status_code)
|
||||
if response.status_code == 401:
|
||||
raise ValidationError(
|
||||
_("Username or password is incorrect for GLS Canada."))
|
||||
elif response.status_code == 204: # No pickups scheduled
|
||||
return {}
|
||||
pickups = response.json() if response else []
|
||||
for pickup in pickups:
|
||||
street = pickup['sender']['addressLine1']
|
||||
zip = pickup['sender']['postalCode'].replace(' ', '')
|
||||
matching_senders = [sender for sender in sender_group if
|
||||
(sender.street == street
|
||||
and sender.zip.replace(' ', '') == zip)]
|
||||
senders_to_pickups_dict.update({
|
||||
sender: pickup for sender in matching_senders
|
||||
})
|
||||
return senders_to_pickups_dict
|
||||
|
||||
@api.model
|
||||
def _group_pickings_by_warehouse_and_recipient(self, pickings) -> dict:
|
||||
"""
|
||||
Transform a list of pickings into a dict of the form::
|
||||
|
||||
{ sender_partner : { recipient_partner : recordset(pickings) } }
|
||||
:param pickings: The recordset of pickings to be arranged
|
||||
:return: dict as described above
|
||||
"""
|
||||
shipments = {pick.picking_type_id.warehouse_id.partner_id: {} for pick in
|
||||
pickings}
|
||||
for sender in shipments.keys():
|
||||
matching_pickings = pickings.filtered(lambda p:
|
||||
p.picking_type_id.warehouse_id.partner_id == sender)
|
||||
for pick in matching_pickings:
|
||||
recipient = pick.partner_id
|
||||
if recipient in shipments[sender]:
|
||||
shipments[sender][recipient] |= pick
|
||||
else:
|
||||
shipments[sender].update({recipient: pick})
|
||||
return shipments
|
||||
|
||||
def _gls_canada_schedule_pickup(self, sender_contact, shipments: List[str],
|
||||
pickup_time: datetime, office_close='16:00'):
|
||||
shipment = self._load_shipment(shipments[0])
|
||||
sender = shipment['sender']
|
||||
contact = shipment['sender']['contact']
|
||||
endpoint = '/pickup'
|
||||
ready_time = pickup_time - timedelta(hours=1)
|
||||
request_data = {
|
||||
"shipments": [{'id': shipment} for shipment in shipments],
|
||||
"contact": self._gls_canada_make_contact_data(sender_contact)['contact'],
|
||||
"officeClose": office_close,
|
||||
"date": pickup_time.strftime("%Y-%m-%d"),
|
||||
"ready": pickup_time.strftime("%H:%M"),
|
||||
"category": self.category,
|
||||
"location": "SH",
|
||||
}
|
||||
""" Location is pickup location:
|
||||
* SS: Basement
|
||||
* RC: ground floor
|
||||
* PH: home
|
||||
* MR: mail room
|
||||
* MB: mailbox
|
||||
* BU: office
|
||||
* OT: other
|
||||
* SH: shipping
|
||||
"""
|
||||
response = self._gls_canada_api_call(endpoint, 'POST', request_data)
|
||||
if response.status_code == 201: # pickup created
|
||||
response_body = response.json()
|
||||
return response_body.get('ID')
|
||||
else:
|
||||
self._raise_gls_error(endpoint, response)
|
||||
|
||||
def _load_shipment(self, shipment):
|
||||
endpoint = f"/shipment/{shipment}"
|
||||
response = self._gls_canada_api_call(endpoint, "GET", {})
|
||||
return response.json()
|
||||
|
||||
def _gls_canada_send_shipment(self, pickings, pickup_id):
|
||||
"""
|
||||
Create a new shipment for all the packages in the pickings and assign this
|
||||
shipment to the pickup_id submitted. If successful, the shipping label is
|
||||
retrieved from GLS and attached to each picking as a PDF.
|
||||
|
||||
:param pickings: The iterable of pickings to be shipped. These must all be from
|
||||
the sender matching the pickup_id submitted and to the same
|
||||
recipient.
|
||||
:param pickups: The pickups that are already scheduled for the sender. The first will be used.
|
||||
:param pickup: The pickup_id of the pickup that is already scheduled with GLS.
|
||||
:return: Tuple containing The shipment data (see _gls_canada_send_shipping)
|
||||
and the shipment ID returned from GLS
|
||||
"""
|
||||
endpoint = "/shipment"
|
||||
request_data = self._gls_canada_shipping_request_data(pickings)
|
||||
response = self._gls_canada_api_call(endpoint, "POST", request_data)
|
||||
|
||||
_logger.info("Shipping Response %s" % response.content)
|
||||
# response_body = self.shipment_response()
|
||||
if response.status_code in [200, 201]:
|
||||
response_body = response.json()
|
||||
_logger.info("Json Response %s " % response_body)
|
||||
parcel_tracking_numbers = []
|
||||
for parcel_tracking_number in response_body.get(
|
||||
'parcelTrackingNumbers'):
|
||||
parcel_tracking_numbers.append(parcel_tracking_number)
|
||||
pickings.parcel_tracking_numbers = ', '.join(
|
||||
parcel_tracking_numbers)
|
||||
pickings.shipment_id = response_body.get('ID')
|
||||
master_tracking_number = response_body.get('trackingNumber')
|
||||
# print label code
|
||||
self._gls_canada_get_label(master_tracking_number, pickings)
|
||||
return {'exact_price': 0.0,
|
||||
'tracking_number': master_tracking_number}, response_body.get("ID")
|
||||
else:
|
||||
self._raise_gls_error(endpoint, response)
|
||||
|
||||
def _gls_canada_get_label(self, master_tracking_number, pickings):
|
||||
endpoint = f"/shipment/label/{pickings.shipment_id}"
|
||||
headers = {
|
||||
'Accept': 'application/pdf', # TODO: implement application/zpl here
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
response = self._gls_canada_api_call(endpoint, "GET", {},
|
||||
headers=headers)
|
||||
_logger.info("Label Response %s" % response)
|
||||
log_message = f"<b>Tracking Numbers:</b> {master_tracking_number}"
|
||||
pickings.message_post(body=log_message,
|
||||
attachments=[(f"Label GLS Canada {pickings.name}.pdf",
|
||||
response.content)])
|
||||
|
||||
@classmethod
|
||||
def _gls_canada_make_sender_data(cls, sender, sender_contact):
|
||||
phone_parsed = phonenumbers.parse(
|
||||
sender_contact.phone) if sender_contact.phone else ''
|
||||
contact_data = cls._gls_canada_make_contact_data(sender_contact)
|
||||
return {
|
||||
"addressLine1": sender.street or '',
|
||||
"addressLine2": sender.street2 or '',
|
||||
"city": sender.city or '',
|
||||
"provinceCode": sender.state_id and sender.state_id.code or '',
|
||||
"postalCode": sender.zip or '',
|
||||
"countryCode": sender.country_id and sender.country_id.code or '',
|
||||
"customerName": sender.name or '',
|
||||
"contact": contact_data['contact']
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _gls_canada_make_contact_data(self, partner):
|
||||
"""
|
||||
Build a dict of the form { "contact": { ... GLS contact params ... } } from
|
||||
the given partner.
|
||||
:param partner: A single res_partner record.
|
||||
:return:
|
||||
"""
|
||||
phone_parsed = phonenumbers.parse(partner.phone) if partner.phone else ''
|
||||
return {
|
||||
"contact": {
|
||||
"fullName": partner.name or '',
|
||||
"language": partner.lang and partner.lang.split('_')[0] or '',
|
||||
"email": partner.email or '',
|
||||
"telephone": phone_parsed and phone_parsed.national_number or '',
|
||||
"extension": phone_parsed and phone_parsed.extension or '',
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _gls_canada_make_consignee_data(cls, partner_id):
|
||||
if partner_id.parent_id and partner_id.parent_id.name:
|
||||
parent = partner_id.parent_id
|
||||
receiver_company_name = parent.name
|
||||
receiver_contact_name = partner_id.name or ''
|
||||
else:
|
||||
receiver_company_name = partner_id.name or ''
|
||||
receiver_contact_name = ''
|
||||
return {
|
||||
"addressLine1": partner_id.street or '',
|
||||
"addressLine2": partner_id.street2 or '',
|
||||
# "streetType": "AVE",
|
||||
# "streetDirection": "N",
|
||||
# "suite": "10",
|
||||
"city": partner_id.city or '',
|
||||
"provinceCode": partner_id.state_id and partner_id.state_id.code or '',
|
||||
"postalCode": partner_id.zip or '',
|
||||
"countryCode": partner_id.country_id and partner_id.country_id.code or '',
|
||||
"customerName": receiver_company_name,
|
||||
"contact": {
|
||||
"fullName": receiver_contact_name,
|
||||
"language": "en",
|
||||
"email": partner_id.email or '',
|
||||
"department": "",
|
||||
"telephone": partner_id.phone or '',
|
||||
"extension": ""
|
||||
}
|
||||
}
|
||||
|
||||
def _gls_canada_api_call(self, endpoint: str, method: str, request_data: dict,
|
||||
headers: dict = {"Content-Type": "application/json"},
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Make a call to the GLS Canada API using the credentials set on the current
|
||||
company_id
|
||||
|
||||
|
||||
:param endpoint: The endpoint to use, such as "/pickup/list"
|
||||
:param method: The request method to use, typically "GET" or "POST"
|
||||
:param headers: Request headers
|
||||
:param request_data:
|
||||
:return: A list of dicts matching the GLS Canada API response specifications
|
||||
"""
|
||||
request_data = json.dumps(request_data)
|
||||
try:
|
||||
api_url = "%s%s" % (
|
||||
self.company_id and self.company_id.gls_canada_api_url, endpoint)
|
||||
_logger.info("Sending GLS Canada request %s" % request_data)
|
||||
username = self.company_id.gls_canada_username
|
||||
password = self.company_id.gls_canada_password
|
||||
if method == "GET":
|
||||
response_body = requests.get(url=api_url, params=request_data,
|
||||
headers=headers,
|
||||
auth=HTTPBasicAuth(username, password))
|
||||
elif method == "DELETE":
|
||||
response_body = requests.delete(url=api_url, params=request_data,
|
||||
headers=headers,
|
||||
auth=HTTPBasicAuth(username, password))
|
||||
else:
|
||||
response_body = requests.request(method=method, url=api_url,
|
||||
data=request_data, headers=headers,
|
||||
auth=HTTPBasicAuth(username, password))
|
||||
_logger.info(f"GLS Canada Response Status Code: {response_body.status_code}")
|
||||
_logger.info("GLS Canada API Response: %s" % response_body.content)
|
||||
return response_body
|
||||
except Exception as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
@api.model
|
||||
def gls_canada_get_tracking_link(self, picking):
|
||||
"""This method is used for track you parcel"""
|
||||
return "https://www.gls-canada.com/en/express/tracking"
|
||||
|
||||
@api.model
|
||||
def _get_pickup_time(cls, sender, closing_time='16:00',
|
||||
earliest_pickup='12:00') -> datetime:
|
||||
"""
|
||||
Generate a pickup time within working hours and not on holidays, based on the
|
||||
sender's localization (time zone) and closing and starting times.
|
||||
|
||||
:param sender: res_partner representing the sender location, used for holidays
|
||||
:param closing_time: closing time to determine if datetime.now() is after the
|
||||
workday's closing time, as a string formatted '%H:%M'.
|
||||
:param earliest_pickup: earliest time of day for pickups at the sender location
|
||||
:return: Today if today is a business day, the next business day otherwise.
|
||||
"""
|
||||
# In theory all sender addresses are from Canada, but this is a more reusable
|
||||
# tool and doesn't introduce any extra dependencies for generalizing to all
|
||||
# locales.
|
||||
if not sender.partner_latitude or not sender.partner_longitude:
|
||||
if not sender.geo_localize():
|
||||
_logger.warning(f'Sender address for {sender.name} could not be '
|
||||
f'geolocated for time zone information. Server time zone'
|
||||
f' used instead.')
|
||||
localized = sender.partner_latitude and sender.partner_longitude
|
||||
now = datetime.now() + timedelta(minutes=30)
|
||||
if localized:
|
||||
tz_name = TimezoneFinder().timezone_at(lng=sender.partner_longitude,
|
||||
lat=sender.partner_latitude)
|
||||
tz = pytz.timezone(tz_name)
|
||||
now = now.astimezone(tz=tz)
|
||||
close_h, close_m = closing_time.split(':')
|
||||
start_h, start_m = earliest_pickup.split(':')
|
||||
closing = datetime(year=now.year, month=now.month, day=now.day,
|
||||
hour=int(close_h), minute=int(close_m)).astimezone(tz)
|
||||
starting = datetime(year=now.year, month=now.month, day=now.day,
|
||||
hour=int(start_h), minute=int(start_m)).astimezone(tz)
|
||||
# If past closing time, move to next day at starting time
|
||||
if now > closing:
|
||||
now = starting + timedelta(days=1)
|
||||
# If before starting time, move to current day starting time
|
||||
if now < starting:
|
||||
now = starting
|
||||
local_holidays = holidays.country_holidays(sender.country_id.code,
|
||||
sender.state_id.code)
|
||||
while now.date() in local_holidays or now.weekday() in (5, 6):
|
||||
now += timedelta(days=1)
|
||||
return now
|
||||
|
||||
@api.model
|
||||
def _raise_gls_error(cls, endpoint, response):
|
||||
raise ValidationError(_(f"Got an error from GLS endpoint {endpoint}\n"
|
||||
f"Response: {response.content}"))
|
||||
25
bemade_delivery_gls_canada/models/gls_canada_credentials.py
Normal file
25
bemade_delivery_gls_canada/models/gls_canada_credentials.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class GLSCanadaAccount(models.Model):
|
||||
_name = 'gls.canada.account'
|
||||
_inherit = 'delivery.account'
|
||||
_description = 'GLS Canada Account'
|
||||
|
||||
account_credentials = fields.Many2one('gls.canada.credentials', string='Login Credentials')
|
||||
account_type = fields.Selection([
|
||||
('Parcel', 'Parcel'),
|
||||
('Freight', 'Freight'),
|
||||
('Distribution', 'Distribution'),
|
||||
('Logistics', 'Logistics'),
|
||||
],
|
||||
default='Parcel')
|
||||
|
||||
|
||||
class GLSCanadaCredentials(models.Model):
|
||||
_name = 'gls.canada.credentials'
|
||||
_inherit = 'delivery.account.credentials'
|
||||
_description = 'Login credentials for GLS Canada'
|
||||
|
||||
production_url = fields.Char('Production URL', default='https://smart4i.dicom.com/v1')
|
||||
test_url = fields.Char('Test URL', default='https://sandbox-smart4i.dicom.com/v1')
|
||||
47
bemade_delivery_gls_canada/models/gls_canada_shipment.py
Normal file
47
bemade_delivery_gls_canada/models/gls_canada_shipment.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class GLSCanadaShipment(models.Model):
|
||||
_name = "gls.canada.shipment"
|
||||
_description = "A shipment booked with GLS Canada"
|
||||
|
||||
shipment_id = fields.Integer(help="The shipment ID on the GLS side")
|
||||
|
||||
sender_address_line_1 = fields.Char()
|
||||
sender_address_line_2 = fields.Char()
|
||||
sender_street_number = fields.Char()
|
||||
sender_street_name = fields.Char()
|
||||
sender_street_direction = fields.Char()
|
||||
sender_city = fields.Char()
|
||||
sender_state = fields.Char()
|
||||
sender_country = fields.Char()
|
||||
sender_postal_code = fields.Char()
|
||||
|
||||
consignee_address_line_1 = fields.Char()
|
||||
consignee_address_line_2 = fields.Char()
|
||||
consignee_street_number = fields.Char()
|
||||
consignee_street_name = fields.Char()
|
||||
consignee_street_direction = fields.Char()
|
||||
consignee_city = fields.Char()
|
||||
consignee_state = fields.Char()
|
||||
consignee_country = fields.Char()
|
||||
consignee_postal_code = fields.Char()
|
||||
|
||||
parcel_ids = fields.One2many('gls.canada.parcel', string='Parcels')
|
||||
|
||||
class GLSCanadaParcel(models.Model):
|
||||
_name = 'gls.canada.parcel'
|
||||
_description = 'A parcel in a shipment booked with GLS Canada'
|
||||
|
||||
shipment_id = fields.Many2one('gls.canada.shipment', string='Shipment')
|
||||
parcel_id = fields.Integer(help="The parcel ID on the GLS side")
|
||||
|
||||
weight = fields.Float()
|
||||
length = fields.Float()
|
||||
width = fields.Float()
|
||||
depth = fields.Float()
|
||||
note = fields.Char()
|
||||
status = fields.Integer()
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class GLSCanadaShippingEstimate(models.Model):
|
||||
_name = 'gls.canada.shipping.estimate'
|
||||
_description = 'GLS Canada Shipping Estimate'
|
||||
|
||||
account_id = fields.Many2one('gls.canada.account', string='GLS Canada Account')
|
||||
total_charge = fields.Monetary()
|
||||
sale_order_id = fields.Many2one('sale.order', string='Sales Order')
|
||||
|
||||
def set_service(self):
|
||||
self.ensure_one()
|
||||
carrier = self.sale_order_id.carrier_id
|
||||
self.sale_order_id._remove_delivery_line()
|
||||
self.sale_order_id.gls_canada_shipping_charge_id = self.id
|
||||
self.sale_order_id.set_delivery_line(carrier, self.total_charge)
|
||||
|
||||
class GLSCanadaRate(models.Model):
|
||||
_name = 'gls.canada.rate'
|
||||
_rec_name = 'rate_type'
|
||||
6
bemade_delivery_gls_canada/models/res_partner.py
Normal file
6
bemade_delivery_gls_canada/models/res_partner.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class Partner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
10
bemade_delivery_gls_canada/models/sale_order.py
Normal file
10
bemade_delivery_gls_canada/models/sale_order.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
gls_canada_shipping_estimate_ids = fields.One2many('gls.canada.shipping.estimate', 'sale_order_id',
|
||||
string='GLS Canada Rates')
|
||||
gls_canada_shipping_estimate_id = fields.Many2one('gls.canada.shipping.estimate', string='Shipping Estimate')
|
||||
delivery_billing_account = fields.Many2one('gls.canada.account', string='Delivery Billing Account')
|
||||
48
bemade_delivery_gls_canada/tools.py
Normal file
48
bemade_delivery_gls_canada/tools.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import requests
|
||||
import logging
|
||||
import json
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from models.gls_canada_credentials import GLSCanadaAccount
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def api_call(account: GLSCanadaAccount, endpoint: str, method: str, request_data: dict,
|
||||
headers: dict = {"Content-Type": "application/json"},
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Make a call to the GLS Canada API using the credentials set on the current
|
||||
company_id
|
||||
|
||||
|
||||
:param endpoint: The endpoint to use, such as "/pickup/list"
|
||||
:param method: The request method to use, typically "GET" or "POST"
|
||||
:param headers: Request headers
|
||||
:param request_data:
|
||||
:return: A list of dicts matching the GLS Canada API response specifications
|
||||
"""
|
||||
request_data = json.dumps(request_data)
|
||||
try:
|
||||
api_url = "%s%s" % (
|
||||
account.account_credentials.url, endpoint)
|
||||
_logger.info("Sending GLS Canada request %s" % request_data)
|
||||
username = account.account_credentials.username
|
||||
password = account.account_credentials.password
|
||||
if method == "GET":
|
||||
response_body = requests.get(url=api_url, params=request_data,
|
||||
headers=headers,
|
||||
auth=HTTPBasicAuth(username, password))
|
||||
elif method == "DELETE":
|
||||
response_body = requests.delete(url=api_url, params=request_data,
|
||||
headers=headers,
|
||||
auth=HTTPBasicAuth(username, password))
|
||||
else:
|
||||
response_body = requests.request(method=method, url=api_url,
|
||||
data=request_data, headers=headers,
|
||||
auth=HTTPBasicAuth(username, password))
|
||||
_logger.info(f"GLS Canada Response Status Code: {response_body.status_code}")
|
||||
_logger.info("GLS Canada API Response: %s" % response_body.content)
|
||||
return response_body
|
||||
except Exception as e:
|
||||
raise ValidationError(e)
|
||||
1
bemade_delivery_partner_accounts/__init__.py
Normal file
1
bemade_delivery_partner_accounts/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
38
bemade_delivery_partner_accounts/__manifest__.py
Normal file
38
bemade_delivery_partner_accounts/__manifest__.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#
|
||||
# Bemade Inc.
|
||||
#
|
||||
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
|
||||
# Author: Marc Durepos (Contact : mdurepos@durpro.com)
|
||||
#
|
||||
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
|
||||
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
|
||||
# or modified copies of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
# DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
{
|
||||
'name': 'Delivery Partner Accounts',
|
||||
'version': '15.0.1.0.0',
|
||||
'summary': 'Adds delivery accounts to partners.',
|
||||
'description': '''Technical module allowing the use of multiple delivery accounts per partner. Each account can
|
||||
include login credentials for the delivery service provider. This module is intended to be used with specific
|
||||
carrier integrations to allow for easier use of collect payments, different carrier accounts per warehouse location,
|
||||
etc.''',
|
||||
'category': 'Inventory/Delivery',
|
||||
'author': 'Bemade Inc.',
|
||||
'website': 'http://www.bemade.org',
|
||||
'license': 'OPL-1',
|
||||
'depends': [
|
||||
'delivery',
|
||||
],
|
||||
'data': [],
|
||||
'assets': {},
|
||||
'installable': True,
|
||||
'auto_install': False
|
||||
}
|
||||
3
bemade_delivery_partner_accounts/models/__init__.py
Normal file
3
bemade_delivery_partner_accounts/models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from . import delivery_account
|
||||
from . import res_partner
|
||||
from . import delivery_carrier
|
||||
66
bemade_delivery_partner_accounts/models/delivery_account.py
Normal file
66
bemade_delivery_partner_accounts/models/delivery_account.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class DeliveryAccount(models.Model):
|
||||
"""
|
||||
A delivery account is a set of credentials that can be used to access a delivery service. It can be as simple as
|
||||
an account number to use for a collect shipment and as complex as the full set of credentials required to make API
|
||||
calls to book shipments with a given carrier. This class is meant to be extended by other modules via classical
|
||||
inheritance to provide credentials that can be used for various partners, specially calibrated to different
|
||||
delivery carrier types.
|
||||
|
||||
If the account should use credentials information specific to a given delivery method (carrier), then the
|
||||
delivery.account.credentials model should be extended and the extended model should be used in the
|
||||
account_credentials field of the extended delivery.account model. Models that extend delivery.account should be
|
||||
set to _auto = False
|
||||
"""
|
||||
_name = 'delivery.account'
|
||||
_description = 'Delivery Account'
|
||||
_rec_name = 'account_number'
|
||||
|
||||
@api.model
|
||||
def _get_carrier_selection(self):
|
||||
return self.env['delivery.carrier'].fields_get(['delivery_type'])['delivery_type']['selection']
|
||||
|
||||
def _get_account_type_domain(self):
|
||||
if self.delivery_type:
|
||||
return [('delivery_type', '=', self.delivery_type)]
|
||||
else:
|
||||
return []
|
||||
|
||||
delivery_type = fields.Selection(selection='_get_carrier_selection', string='Applicable Carrier')
|
||||
account_number = fields.Char()
|
||||
account_credentials_id = fields.Many2one('delivery.account.credentials', string='Login Credentials')
|
||||
partner_ids = fields.Many2many(comodel_name='res.partner', string='Partners',
|
||||
relation='delivery_account_partner_rel',
|
||||
column1='account_id', column2='partner_id',
|
||||
help="Partners who can use this account.")
|
||||
account_type_id = fields.Many2one('delivery.account.type', string='Account Type', domain=_get_account_type_domain)
|
||||
|
||||
|
||||
class DeliveryAccountCredentials(models.Model):
|
||||
"""
|
||||
Login credentials for a given account. This model is meant to be extended using classic inheritance. Suggested usage
|
||||
is to extend the model and set the default production_url and test_url values to simplify the UI for the user. The
|
||||
extended model should be used in the account_credentials field of the DeliveryAccount model.
|
||||
"""
|
||||
_name = 'delivery.account.credentials'
|
||||
_description = 'Delivery Account Login Credentials'
|
||||
_rec_name = 'username'
|
||||
|
||||
username = fields.Char()
|
||||
password = fields.Char()
|
||||
production_url = fields.Char(string='Production URL')
|
||||
test_url = fields.Char(string='Test URL')
|
||||
delivery_account_id = fields.One2many(comodel_name='delivery.account', inverse_name='account_credentials_id', )
|
||||
|
||||
|
||||
class DeliveryAccountType(models.Model):
|
||||
_name = 'delivery.account.type'
|
||||
_description = 'Delivery Account Type'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
technical_name = fields.Char(help="The technical name used by the carrier to identify the account type.",
|
||||
required=True)
|
||||
carrier_id = fields.Many2one('delivery.carrier', string='Carrier', required=True)
|
||||
delivery_type = fields.Selection(related='carrier_id.delivery_type', string='Delivery Type', readonly=True)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class DeliveryCarrier(models.Model):
|
||||
_inherit = 'delivery.carrier'
|
||||
|
||||
account_type_ids = fields.One2many('delivery.account.type', 'carrier_id',
|
||||
string='Account Types')
|
||||
28
bemade_delivery_partner_accounts/models/res_partner.py
Normal file
28
bemade_delivery_partner_accounts/models/res_partner.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class Partner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
delivery_account_ids = fields.Many2many('delivery.account', string='Delivery Accounts',
|
||||
relation='delivery_account_partner_rel',
|
||||
column1='partner_id', column2='account_id')
|
||||
default_delivery_account_id = fields.Many2one('delivery.account', string='Default Delivery Account')
|
||||
|
||||
def write(self, values):
|
||||
super().write(values)
|
||||
if 'delivery_account_ids' in values:
|
||||
for rec in self.filtered(lambda r: not r.default_delivery_account_id and rec.delivery_account_ids):
|
||||
rec.default_delivery_account_id = rec.delivery_account_ids[0]
|
||||
if 'default_delivery_account_id' in values:
|
||||
for rec in self.filtered(lambda r: not r.delivery_account_ids and r.default_delivery_account_id):
|
||||
rec.delivery_account_ids = [(4, rec.default_delivery_account_id.id)]
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
res = super().create(vals_list)
|
||||
for rec in res.filtered(lambda r: not r.default_delivery_account_id and rec.delivery_account_ids):
|
||||
rec.default_delivery_account_id = rec.delivery_account_ids[0]
|
||||
for rec in res.filtered(lambda r: not r.delivery_account_ids and r.default_delivery_account_id):
|
||||
rec.delivery_account_ids = [(4, rec.default_delivery_account_id.id)]
|
||||
return res
|
||||
22
bemade_delivery_partner_accounts/views/res_partner_views.xml
Normal file
22
bemade_delivery_partner_accounts/views/res_partner_views.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="res_partner_view_form_delivery_accounts" model="ir.ui.view">
|
||||
<field name="name">res.partner.view.form.delivery.accounts</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<notebook position="inside">
|
||||
<page string="Delivery Accounts">
|
||||
<field name="default_delivery_account_id"/>
|
||||
<field name="delivery_account_ids" widget="one2many">
|
||||
<tree editable="bottom">
|
||||
<field name="delivery_type"/>
|
||||
<field name="account_number"/>
|
||||
<field name="account_id"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Reference in a new issue