From 08065d7c9973a43d5324ae49fcf45ed6b4973644 Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Thu, 2 Nov 2023 16:19:25 -0400 Subject: [PATCH] Work started on Bemade GLS module and partner carrier accounts module. --- bemade_delivery_gls_canada/__init__.py | 1 + bemade_delivery_gls_canada/__manifest__.py | 33 + bemade_delivery_gls_canada/models/__init__.py | 6 + .../models/delivery_carrier.py | 605 ++++++++++++++++++ .../models/gls_canada_credentials.py | 25 + .../models/gls_canada_shipment.py | 47 ++ .../models/gls_canada_shipping_charge.py | 21 + .../models/res_partner.py | 6 + .../models/sale_order.py | 10 + bemade_delivery_gls_canada/tools.py | 48 ++ bemade_delivery_partner_accounts/__init__.py | 1 + .../__manifest__.py | 38 ++ .../models/__init__.py | 3 + .../models/delivery_account.py | 66 ++ .../models/delivery_carrier.py | 8 + .../models/res_partner.py | 28 + .../views/res_partner_views.xml | 22 + 17 files changed, 968 insertions(+) create mode 100644 bemade_delivery_gls_canada/__init__.py create mode 100644 bemade_delivery_gls_canada/__manifest__.py create mode 100644 bemade_delivery_gls_canada/models/__init__.py create mode 100644 bemade_delivery_gls_canada/models/delivery_carrier.py create mode 100644 bemade_delivery_gls_canada/models/gls_canada_credentials.py create mode 100644 bemade_delivery_gls_canada/models/gls_canada_shipment.py create mode 100644 bemade_delivery_gls_canada/models/gls_canada_shipping_charge.py create mode 100644 bemade_delivery_gls_canada/models/res_partner.py create mode 100644 bemade_delivery_gls_canada/models/sale_order.py create mode 100644 bemade_delivery_gls_canada/tools.py create mode 100644 bemade_delivery_partner_accounts/__init__.py create mode 100644 bemade_delivery_partner_accounts/__manifest__.py create mode 100644 bemade_delivery_partner_accounts/models/__init__.py create mode 100644 bemade_delivery_partner_accounts/models/delivery_account.py create mode 100644 bemade_delivery_partner_accounts/models/delivery_carrier.py create mode 100644 bemade_delivery_partner_accounts/models/res_partner.py create mode 100644 bemade_delivery_partner_accounts/views/res_partner_views.xml diff --git a/bemade_delivery_gls_canada/__init__.py b/bemade_delivery_gls_canada/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/bemade_delivery_gls_canada/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/bemade_delivery_gls_canada/__manifest__.py b/bemade_delivery_gls_canada/__manifest__.py new file mode 100644 index 0000000..bd649e3 --- /dev/null +++ b/bemade_delivery_gls_canada/__manifest__.py @@ -0,0 +1,33 @@ +# +# Bemade Inc. +# +# Copyright (C) 2023-June Bemade Inc. (). +# 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 +} diff --git a/bemade_delivery_gls_canada/models/__init__.py b/bemade_delivery_gls_canada/models/__init__.py new file mode 100644 index 0000000..eb03a7e --- /dev/null +++ b/bemade_delivery_gls_canada/models/__init__.py @@ -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 diff --git a/bemade_delivery_gls_canada/models/delivery_carrier.py b/bemade_delivery_gls_canada/models/delivery_carrier.py new file mode 100644 index 0000000..69b3e7f --- /dev/null +++ b/bemade_delivery_gls_canada/models/delivery_carrier.py @@ -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"Tracking Numbers: {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}")) diff --git a/bemade_delivery_gls_canada/models/gls_canada_credentials.py b/bemade_delivery_gls_canada/models/gls_canada_credentials.py new file mode 100644 index 0000000..d5ff852 --- /dev/null +++ b/bemade_delivery_gls_canada/models/gls_canada_credentials.py @@ -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') diff --git a/bemade_delivery_gls_canada/models/gls_canada_shipment.py b/bemade_delivery_gls_canada/models/gls_canada_shipment.py new file mode 100644 index 0000000..90932fd --- /dev/null +++ b/bemade_delivery_gls_canada/models/gls_canada_shipment.py @@ -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() + + + diff --git a/bemade_delivery_gls_canada/models/gls_canada_shipping_charge.py b/bemade_delivery_gls_canada/models/gls_canada_shipping_charge.py new file mode 100644 index 0000000..1f0b58f --- /dev/null +++ b/bemade_delivery_gls_canada/models/gls_canada_shipping_charge.py @@ -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' diff --git a/bemade_delivery_gls_canada/models/res_partner.py b/bemade_delivery_gls_canada/models/res_partner.py new file mode 100644 index 0000000..b8ae0a6 --- /dev/null +++ b/bemade_delivery_gls_canada/models/res_partner.py @@ -0,0 +1,6 @@ +from odoo import models, fields, api, _ + + +class Partner(models.Model): + _inherit = 'res.partner' + diff --git a/bemade_delivery_gls_canada/models/sale_order.py b/bemade_delivery_gls_canada/models/sale_order.py new file mode 100644 index 0000000..f6f0a0c --- /dev/null +++ b/bemade_delivery_gls_canada/models/sale_order.py @@ -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') diff --git a/bemade_delivery_gls_canada/tools.py b/bemade_delivery_gls_canada/tools.py new file mode 100644 index 0000000..a8bbb3d --- /dev/null +++ b/bemade_delivery_gls_canada/tools.py @@ -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) diff --git a/bemade_delivery_partner_accounts/__init__.py b/bemade_delivery_partner_accounts/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/bemade_delivery_partner_accounts/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/bemade_delivery_partner_accounts/__manifest__.py b/bemade_delivery_partner_accounts/__manifest__.py new file mode 100644 index 0000000..1b72c91 --- /dev/null +++ b/bemade_delivery_partner_accounts/__manifest__.py @@ -0,0 +1,38 @@ +# +# Bemade Inc. +# +# Copyright (C) 2023-June Bemade Inc. (). +# 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 +} diff --git a/bemade_delivery_partner_accounts/models/__init__.py b/bemade_delivery_partner_accounts/models/__init__.py new file mode 100644 index 0000000..8cdd97e --- /dev/null +++ b/bemade_delivery_partner_accounts/models/__init__.py @@ -0,0 +1,3 @@ +from . import delivery_account +from . import res_partner +from . import delivery_carrier diff --git a/bemade_delivery_partner_accounts/models/delivery_account.py b/bemade_delivery_partner_accounts/models/delivery_account.py new file mode 100644 index 0000000..1177e1b --- /dev/null +++ b/bemade_delivery_partner_accounts/models/delivery_account.py @@ -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) diff --git a/bemade_delivery_partner_accounts/models/delivery_carrier.py b/bemade_delivery_partner_accounts/models/delivery_carrier.py new file mode 100644 index 0000000..5fdf805 --- /dev/null +++ b/bemade_delivery_partner_accounts/models/delivery_carrier.py @@ -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') diff --git a/bemade_delivery_partner_accounts/models/res_partner.py b/bemade_delivery_partner_accounts/models/res_partner.py new file mode 100644 index 0000000..0c8854e --- /dev/null +++ b/bemade_delivery_partner_accounts/models/res_partner.py @@ -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 diff --git a/bemade_delivery_partner_accounts/views/res_partner_views.xml b/bemade_delivery_partner_accounts/views/res_partner_views.xml new file mode 100644 index 0000000..62e0dbe --- /dev/null +++ b/bemade_delivery_partner_accounts/views/res_partner_views.xml @@ -0,0 +1,22 @@ + + + + res.partner.view.form.delivery.accounts + res.partner + + + + + + + + + + + + + + + + + \ No newline at end of file