diff --git a/.DS_Store b/.DS_Store index 07d3e0e7..b885b845 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/delivery_canadapost-19.0.1.0.0.zip b/delivery_canadapost-19.0.1.0.0.zip new file mode 100644 index 00000000..1fb51309 Binary files /dev/null and b/delivery_canadapost-19.0.1.0.0.zip differ diff --git a/delivery_canadapost/README.md b/delivery_canadapost/README.md new file mode 100644 index 00000000..f59beed3 --- /dev/null +++ b/delivery_canadapost/README.md @@ -0,0 +1,7 @@ +# Synodica Solutions + +## Changelog + +### 16.0.0.0.0 + +- Initial version diff --git a/delivery_canadapost/__init__.py b/delivery_canadapost/__init__.py new file mode 100644 index 00000000..c265faff --- /dev/null +++ b/delivery_canadapost/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import canada_post_api diff --git a/delivery_canadapost/__manifest__.py b/delivery_canadapost/__manifest__.py new file mode 100644 index 00000000..c59d24c7 --- /dev/null +++ b/delivery_canadapost/__manifest__.py @@ -0,0 +1,29 @@ +{ + # App information + "name": "Canada Post Shipping", + "version": "19.0.1.0.0", + "category": "Inventory/Delivery", + "summary": "Integration of Canada Post delivery services in Odoo " + "to handle shipment operations, including get " + "live rate, generating shipping labels, " + "retrieving tracking numbers", + "license": "OPL-1", + "depends": ["stock_delivery", "mail"], + # Views + "data": [ + "data/delivery_canada_post_data.xml", + "views/delivery_carrier_view.xml", + ], + "images": ["static/description/canada_post_banner.gif"], + # Author + "author": "Synodica Solutions Pvt. Ltd.", + "website": "https://synodica.com", + "maintainer": "Synodica Solutions Pvt. Ltd.", + "support": "support@synodica.com", + # Technical + "installable": True, + "auto_install": False, + "application": True, + "price": "149.00", + "currency": "USD", +} diff --git a/delivery_canadapost/canada_post_api/__init__.py b/delivery_canadapost/canada_post_api/__init__.py new file mode 100644 index 00000000..e3d3ecd3 --- /dev/null +++ b/delivery_canadapost/canada_post_api/__init__.py @@ -0,0 +1,2 @@ +from . import canada_post_response +from . import utils \ No newline at end of file diff --git a/delivery_canadapost/canada_post_api/canada_post_response.py b/delivery_canadapost/canada_post_api/canada_post_response.py new file mode 100644 index 00000000..960401b5 --- /dev/null +++ b/delivery_canadapost/canada_post_api/canada_post_response.py @@ -0,0 +1,168 @@ +import lxml +import datetime +import logging +from collections import defaultdict +from odoo.addons.delivery_canadapost.canada_post_api.utils import get_dom_tree, python_2_unicode_compatible +import json +_logger = logging.getLogger(__name__) + +@python_2_unicode_compatible +class ResponseDataObject(): + + def __init__(self, mydict, datetime_nodes=[]): + self._load_dict(mydict, list(datetime_nodes)) + + def __repr__(self): + return str(self) + + def __str__(self): + return "%s" % self.__dict__ + + def has_key(self, name): + try: + getattr(self, name) + return True + except AttributeError: + return False + + def get(self, name, default=None): + try: + return getattr(self, name) + except AttributeError: + return default + + def _setattr(self, name, value, datetime_nodes): + if name.lower() in datetime_nodes: + try: + ts = "%s %s" % (value.partition('T')[0], value.partition('T')[2].partition('.')[0]) + value = datetime.datetime.strptime(ts, '%Y-%m-%d %H:%M:%S') + except ValueError: + pass + + setattr(self, name, value) + + def _load_dict(self, mydict, datetime_nodes): + + for a in list(mydict.items()): + + if isinstance(a[1], dict): + o = ResponseDataObject(a[1], datetime_nodes) + setattr(self, a[0], o) + + elif isinstance(a[1], list): + objs = [] + for i in a[1]: + if i is None or isinstance(i, str) or isinstance(i, str): + objs.append(i) + else: + objs.append(ResponseDataObject(i, datetime_nodes)) + + setattr(self, a[0], objs) + else: + self._setattr(a[0], a[1], datetime_nodes) + +class Response(): + + def __init__(self, obj, verb=None, parse_response=True): + self._obj = obj + if parse_response: + try: + self._dom = self._parse_xml(obj.content) + self._dict = self._etree_to_dict(self._dom) + + # print(self._dict) + if verb and 'Envelope' in list(self._dict.keys()): + elem = self._dom.find('Body').find('%sResponse' % verb) + if elem is not None: + self._dom = elem + + self._dict = self._dict['Envelope']['Body'].get('%sResponse' % verb, self._dict) + elif verb: + elem = self._dom.find('%sResponse' % verb) + if elem is not None: + self._dom = elem + + self._dict = self._dict.get('%sResponse' % verb, self._dict) + + self.reply = ResponseDataObject(self._dict,[]) + + except lxml.etree.XMLSyntaxError as e: + _logger.debug('Response parse failed: %s' % e) + self.reply = ResponseDataObject({}, []) + else: + self.reply = ResponseDataObject({}, []) + + def _get_node_path(self, t): + i = t + path = [] + path.insert(0, i.tag) + while 1: + try: + path.insert(0, i.getparent().tag) + i = i.getparent() + except AttributeError: + break + + return '.'.join(path) + + @staticmethod + def _pullval(v): + if len(v) == 1: + return v[0] + else: + return v + + def _etree_to_dict(self, t): + if type(t) == lxml.etree._Comment: + return {} + + # remove xmlns from nodes, I find them meaningless + t.tag = self._get_node_tag(t) + + d = {t.tag: {} if t.attrib else None} + children = list(t) + if children: + dd = defaultdict(list) + for dc in map(self._etree_to_dict, children): + for k, v in list(dc.items()): + dd[k].append(v) + + d = {t.tag: dict((k, self._pullval(v)) for k, v in list(dd.items()))} + #d = {t.tag: {k:v[0] if len(v) == 1 else v for k, v in dd.items()}} + + # TODO: Optimizations? Forces a node to type list + parent_path = self._get_node_path(t) + for k in list(d[t.tag].keys()): + path = "%s.%s" % (parent_path, k) + + if t.attrib: + d[t.tag].update(('_' + k, v) for k, v in list(t.attrib.items())) + if t.text: + text = t.text.strip() + if children or t.attrib: + if text: + d[t.tag]['value'] = text + else: + d[t.tag] = text + return d + + def __getattr__(self, name): + return getattr(self._obj, name) + + def _parse_xml(self, xml): + return get_dom_tree(xml) + + def _get_node_tag(self, node): + return node.tag.replace('{' + node.nsmap.get(node.prefix, '') + '}', '') + + def dom(self, lxml=True): + if not lxml: + # create and return a cElementTree DOM + pass + return self._dom + + def dict(self): + return self._dict + + def json(self): + return json.dumps(self.dict()) \ No newline at end of file diff --git a/delivery_canadapost/canada_post_api/utils.py b/delivery_canadapost/canada_post_api/utils.py new file mode 100644 index 00000000..7f7c7f30 --- /dev/null +++ b/delivery_canadapost/canada_post_api/utils.py @@ -0,0 +1,219 @@ +import sys +from lxml import etree as ET + +def parse_yaml(yaml_file): + """ + This is simple approach to parsing a yaml config that is only + intended for this SDK as this only supports a very minimal subset + of yaml options. + """ + + with open(yaml_file) as f: + data = {None: {}} + current_key = None + + for line in f.readlines(): + + # ignore comments + if line.startswith('#'): + continue + + # parse the header + elif line[0].isalnum(): + key = line.strip().replace(':', '') + current_key = key + data[current_key] = {} + + # parse the key: value line + elif line[0].isspace(): + values = line.strip().split(':') + + if len(values) == 2: + cval = values[1].strip() + + if cval == '0': + cval = False + elif cval == '1': + cval = True + + data[current_key][values[0].strip()] = cval + return data + + +def python_2_unicode_compatible(klass): + """ + A decorator that defines __unicode__ and __str__ methods under Python 2. + Under Python 3 it does nothing. + + To support Python 2 and 3 with a single code base, define a __str__ method + returning text and apply this decorator to the class. + """ + if sys.version_info[0] < 3: + if '__str__' not in klass.__dict__: + raise ValueError("@python_2_unicode_compatible cannot be applied " + "to %s because it doesn't define __str__()." % + klass.__name__) + klass.__unicode__ = klass.__str__ + klass.__str__ = lambda self: self.__unicode__().encode('utf-8') + return klass + + +def get_dom_tree(xml): + tree = ET.fromstring(xml) + return tree.getroottree().getroot() + +def attribute_check(root): + attrs = [] + value = None + + if isinstance(root, dict): + if '#text' in root: + value = root['#text'] + if '@attrs' in root: + for ak, av in sorted(root.pop('@attrs').items()): + attrs.append(str('{0}="{1}"').format(ak, smart_encode(av))) + + return attrs, value + +def smart_encode(value): + try: + if sys.version_info[0] < 3: + return str(value).encode('utf-8') + else: + return value + #return str(value) + + except UnicodeDecodeError: + return value + + +def to_xml(root): + return dict2xml(root) + +def dict2xml(root): + xml = str('') + if root is None: + return xml + + if isinstance(root, dict): + for key in sorted(root.keys()): + + if isinstance(root[key], dict): + attrs, value = attribute_check(root[key]) + + if not value: + value = dict2xml(root[key]) + elif isinstance(value, dict): + value = dict2xml(value) + + attrs_sp = str('') + if len(attrs) > 0: + attrs_sp = str(' ') + + xml = str('{xml}<{tag}{attrs_sp}{attrs}>{value}') \ + .format(**{'tag': key, 'xml': str(xml), 'attrs': str(' ').join(attrs), + 'value': smart_encode(value), 'attrs_sp': attrs_sp}) + + elif isinstance(root[key], list): + + for item in root[key]: + attrs, value = attribute_check(item) + + if not value: + value = dict2xml(item) + elif isinstance(value, dict): + value = dict2xml(value) + + attrs_sp = '' + if len(attrs) > 0: + attrs_sp = ' ' + + xml = str('{xml}<{tag}{attrs_sp}{attrs}>{value}') \ + .format(**{'xml': str(xml), 'tag': key, 'attrs': ' '.join(attrs), 'value': smart_encode(value), + 'attrs_sp': attrs_sp}) + + else: + value = root[key] + xml = str('{xml}<{tag}>{value}') \ + .format(**{'xml': str(xml), 'tag': key, 'value': smart_encode(value)}) + + elif isinstance(root, str) or isinstance(root, int) \ + or isinstance(root, str) or isinstance(root, int) \ + or isinstance(root, float): + xml = str('{0}{1}').format(str(xml), root) + else: + raise Exception('Unable to serialize node of type %s (%s)' % \ + (type(root), root)) + + return xml + +def getValue(response_dict, *args, **kwargs): + args_a = [w for w in args] + first = args_a[0] + args_a.remove(first) + + h = kwargs.get('mydict', {}) + if h: + h = h.get(first, {}) + else: + h = response_dict.get(first, {}) + + if len(args) == 1: + try: + return h.get('value', None) + except: + return h + + last = args_a.pop() + + for a in args_a: + h = h.get(a, {}) + + h = h.get(last, {}) + + try: + return h.get('value', None) + except: + return h + +def getNodeText(node): + "Returns the node's text string." + + rc = [] + + if hasattr(node, 'childNodes'): + for cn in node.childNodes: + if cn.nodeType == cn.TEXT_NODE: + rc.append(cn.data) + elif cn.nodeType == cn.CDATA_SECTION_NODE: + rc.append(cn.data) + + return ''.join(rc) + +def perftest_dict2xml(): + sample_dict = { + 'searchFilter': {'categoryId': {'#text': 222, '@attrs': {'site': 'US'}}}, + 'paginationInput': { + 'pageNumber': '1', + 'pageSize': '25' + }, + 'itemFilter': [ + {'name': 'Condition', + 'value': 'Used'}, + {'name': 'LocatedIn', + 'value': 'GB'}, + ], + 'sortOrder': 'StartTimeNewest' + } + xml = dict2xml(sample_dict) + +if __name__ == '__main__': + + import timeit + # print(("perftest_dict2xml() %s" % \ + # timeit.timeit("perftest_dict2xml()", number=50000, + # setup="from __main__ import perftest_dict2xml"))) + + import doctest + failure_count, test_count = doctest.testmod() + sys.exit(failure_count) \ No newline at end of file diff --git a/delivery_canadapost/data/delivery_canada_post_data.xml b/delivery_canadapost/data/delivery_canada_post_data.xml new file mode 100644 index 00000000..0f30d752 --- /dev/null +++ b/delivery_canadapost/data/delivery_canada_post_data.xml @@ -0,0 +1,39 @@ + + + + + canadapost BOX + canada_post + 100 + 100 + 100 + 20.00 + + + + + Canada Post + Delivery + service + + + + 0.0 + + + + Canada Post + + canada_post + commercial + SO + DOM.RP + + + diff --git a/delivery_canadapost/models/__init__.py b/delivery_canadapost/models/__init__.py new file mode 100644 index 00000000..32b5f47d --- /dev/null +++ b/delivery_canadapost/models/__init__.py @@ -0,0 +1,3 @@ +from . import delivery_carrier +from . import product_packaging +from . import res_company diff --git a/delivery_canadapost/models/delivery_carrier.py b/delivery_canadapost/models/delivery_carrier.py new file mode 100644 index 00000000..93eb311e --- /dev/null +++ b/delivery_canadapost/models/delivery_carrier.py @@ -0,0 +1,509 @@ +import string +import random +from requests import request +import xml.etree.ElementTree as etree +import logging +_logger = logging.getLogger(__name__) +from odoo import models, fields, api, _ +from odoo.exceptions import RedirectWarning,ValidationError +from odoo.addons.delivery_canadapost.canada_post_api.canada_post_response import Response + + +class DeliveryCarrier(models.Model): + _inherit = 'delivery.carrier' + + delivery_type = fields.Selection(selection_add=[('canada_post', 'Canada Post')], ondelete={ + 'canada_post': lambda recs: recs.write({'delivery_type': 'fixed', 'fixed_price': 0})}) + option_code = fields.Selection([('SO','SO - Signature'), + ('COV','COV - Coverage'), + ('COD','COD - Collect on delivery'), + ('PA18','PA18 - Proof of Age Required - 18'), + ('PA19','PA19 - Proof of Age Required - 19'), + ('HFP','HFP - Card for pickup'), + ('DNS','DNS - Do not safe drop'), + ('LAD','LAD - Leave at door - do not card'), + ],string="Option Code", + help="Required if the corresponding parent XML element option exists. This is the option code indicating which option applies to this shipment.\n Note: The D2PO option indicates that the parcel will be delivered directly to a nearby Post Office. For the D2PO option, the following XML elements are required: \n name (under destination) \n client-voice-number (under destination) \n notification \n option-qualifier-2 \n Note: If you select Collect on Delivery (COD), specify Card for Pickup (HFP) or Deliver to Post Office (D2PO). This is to facilitate the collection of COD funds at a post office. If not specified, the system will default to HFP. \n Non-delivery handling codes (required for some U.S.A. and international shipments)\n RASE - Return at Sender’s Expense \n RTS - Return to Sender \n ABAN - Abandon") + service_type = fields.Selection([('DOM.RP','DOM.RP - Regular Parcel'), + ('DOM.EP','DOM.EP - Expedited Parcel'), + ('DOM.XP','DOM.XP - Xpresspost'), + ('DOM.PC','DOM.PC - Priority'), + ('USA.XP','USA.XP - Xpresspost USA'), + ('USA.EP','USA.EP - Expedited Parcel USA'), + ('INT.IP.SURF','INT.IP.SURF - International Parcel Surface'), + ('INT.PW.PARCEL','INT.PW.PARCEL - Priority Worldwide parcel Int’l'), + ('INT.XP','INT.XP - Xpresspost International'), + ], string="Service Type", + help="Canada Post delivery service used for shipping the item") + product_packaging_id = fields.Many2one('stock.package.type', string="Default Package Type",help="Selected packaging type, used in the request parameter") + + reason_for_export = fields.Selection([('DOC', 'DOC = document'), + ('SAM', 'SAM = commercial sample'), + ('REP', 'REP = repair or warranty'), + ('SOG', 'SOG = sale of goods'), + ('OTH', 'OTH = other')], string="Reason For Export",default="SOG", + help="This is a code that represents the reason for export, which assists with border crossing.") + + username = fields.Char("Username", copy=False, help="UserName provided by canada post.") + password = fields.Char("Password", copy=False, help="Password provided by canada post.") + customer_number = fields.Char("Customer Number", copy=False, help="The mailed by customer, Customer number provided by canada post.") + tracking_link = fields.Char(string="Tracking Link",help="Tracking link(URL) useful to track the shipment or package from this URL.",size=256) + + canadapost_type = fields.Selection(selection=[('commercial', 'Contract Shipping'), ('counter', 'Non-Contract Shipping')], string="Customer Type", required=False) + canadapost_contract_id = fields.Char(string="Contract ID") + canadapost_payment_method = fields.Selection([('CreditCard', 'Credit Card'), ('Account', 'Account')], + string="Payment Method", default='CreditCard', + help="This is the method of payment for the shipment. The default value is CreditCard.") + + #set default weight_uom_id + def _default_uom_in_delive(self): + weight_uom_id = self.env.ref('uom.product_uom_kgm', raise_if_not_found=False) + if not weight_uom_id: + uom_categ_id = self.env.ref('uom.product_uom_categ_kgm').id + weight_uom_id = self.env['uom.uom'].search([('category_id', '=', uom_categ_id), ('factor', '=', 1)], limit=1) + return weight_uom_id + + weight_uom_id = fields.Many2one('uom.uom', string='Shipping UoM according to API UoM',help="Set equivalent unit of measurement according to provider unit of measurement. For Example, if the provider unit of measurement is KG then you have to select KG unit of measurement in the Shipping Unit of Measurement field.",default=_default_uom_in_delive) + + #Get Canada post URL + @api.model + def get_canadapost_url(self): + if self.prod_environment: + return "https://soa-gw.canadapost.ca/rs/" + else: + return "https://ct.soa-gw.canadapost.ca/rs/" + + def _compute_can_generate_return(self): + super(DeliveryCarrier, self)._compute_can_generate_return() + for carrier in self: + if carrier.delivery_type == 'canada_post': + carrier.can_generate_return = True + + #Check Address filed is validating or not and return boolean value + @api.model + def validating_address(self, partner, additional_fields=[]): + missing_value = [] + mandatory_fields = ['country_id', 'city', 'zip'] + mandatory_fields.extend(additional_fields) + if not partner.street and not partner.street2 : + mandatory_fields.append('street') + for field in mandatory_fields : + if not getattr(partner, field) : + missing_value.append(field) + return missing_value + + #Check required value proper or not for Shipping + def check_required_value_to_ship(self, orders): + for order in orders : + if not order.order_line: + return _("You have not any item to ship. Please provide item first") + else : + order_lines_without_weight = order.order_line.filtered(lambda line_item: not line_item.product_id.type in ['service', 'digital'] and not line_item.product_id.weight and not line_item.is_delivery) + for order_line in order_lines_without_weight : + return _("Please define weight in product : \n %s") % order_line.product_id.name + + # validating customer address + missing_value = self.validating_address(order.partner_shipping_id) + if missing_value : + fields = ", ".join(missing_value) + return (_("Missing the values of the Customer address. \n Missing field(s) : %s ") % fields) + + # validation shipper address + missing_value = self.validating_address(order.warehouse_id.partner_id) + if missing_value : + fields = ", ".join(missing_value) + return (_("Missing the values of the Warehouse address. \n Missing field(s) : %s ") % fields) + return False + + # Return Weight + def convert_weight(self,from_uom_unit ,to_uom_unit, weight): + if not from_uom_unit: + from_uom_unit = self.env[ + "product.template" + ]._get_weight_uom_id_from_ir_config_parameter() + return from_uom_unit._compute_quantity(weight, to_uom_unit) + + #Check Validate weight or not + def check_max_weight(self, order, shipment_weight): + for order_line in order.order_line: + if order_line.product_id and order_line.product_id.weight > shipment_weight: + return (_("Product weight is more than maximum weight.")) + return False + + #Get Rate from API than set Rate + def canada_post_rate_shipment(self, order): + # check the address validation + check_value = self.check_required_value_to_ship(order) + # check the product weight is appropriate to maximum weight. + if check_value: + return {'success': False, 'price': 0.0, 'error_message': check_value, 'warning_message': False} + # check the product weight is appropriate to maximum weight. + shipment_weight = self.product_packaging_id.max_weight + check_weight = {} + if shipment_weight!=0.0: + check_weight = self.check_max_weight(order, shipment_weight) + if check_weight: + return {'success': False, 'price': 0.0, 'error_message': check_weight, 'warning_message': False} + + shipper_address = order.warehouse_id.partner_id or order.company_id.partner_id + recipient_address = order.partner_shipping_id or order.partner_id + # Convert weight in to the delivery method's weight UOM + carrier_ctx = self.env.context + weight = carrier_ctx.get("order_weight", 0.0) or order._get_estimated_weight() or 0.0 + total_weight = self.convert_weight(order.company_id and order.company_id.weight_unit_of_measurement_id, + self.weight_uom_id, + weight) + declared_value = round(order.amount_untaxed, 2) + declared_currency = order.currency_id.name + price=0.0 + rate_dict = self.canada_post_get_shipping_rate(shipper_address, recipient_address, total_weight, + picking_bulk_weight=False, packages=False, + declared_value=declared_value, declared_currency=declared_currency, + company_id=order.company_id) + _logger.info("Rate Response Data : %s" % (rate_dict)) + if rate_dict.get('messages', False): + return {'success': False, 'price':price, 'error_message': rate_dict['messages']['message']['description'], + 'warning_message': False} + if rate_dict.get('price-quotes',False) and rate_dict.get('price-quotes').get('price-quote',False) : + cnt = 0; + quotes = rate_dict['price-quotes']['price-quote'] + if isinstance(quotes, dict): + quotes = [quotes] + for quote in quotes: + if quote['service-code']==self.service_type: + price = quote['price-details']['due'] + cnt+=1 + if cnt==0: + return {'success': False, 'price': price, 'error_message': "Rate API dosen't provide this service type price", + 'warning_message': False} + + if self.canadapost_type == 'counter' and self.service_type in ['DOM.RP','DOM.EP','DOM.XP','DOM.PC'] and self.option_code == 'COD': + if order.amount_total + float(price)>= 999.99: + raise ValidationError( + _("Get Rate Request Fail : \n The COD amount cannot exceed 1000.00 in Non-Contract Shipping.") + ) + if self.canadapost_type == 'commercial' and self.service_type in ['DOM.RP','DOM.EP','DOM.XP','DOM.PC'] and self.option_code == 'COD': + if order.amount_total + float(price) >= 5000.00: + raise ValidationError( + _("Get Rate Request Fail : \n The COD amount cannot exceed 5000.00 in Contract Shipping.") + ) + return {'success': True, 'price': float(price), 'error_message': False, 'warning_message': False} + + #Send required XML data and than response from Canada Post API + def canada_post_get_shipping_rate(self, shipper_address, recipient_address, total_weight, picking_bulk_weight, + packages=False, declared_value=False, declared_currency=False, company_id=False): + result = {} + # built request data + service_root = etree.Element("mailing-scenario") + + if self.canadapost_type == 'commercial': + service_root.attrib["xmlns"] = "http://www.canadapost.ca/ws/ship/rate-v4" + else: + service_root.attrib["xmlns"] = "http://www.canadapost.ca/ws/ship/rate-v3" + etree.SubElement(service_root, "customer-number").text = self.customer_number + parcel = etree.SubElement(service_root, "parcel-characteristics") + etree.SubElement(parcel, "weight").text = str(total_weight) + + etree.SubElement(service_root, "origin-postal-code").text = "%s" % (shipper_address.zip.replace(" ","").upper()) + + destination = etree.SubElement(service_root, "destination") + if str(self.service_type[:3])=='DOM': + domestic = etree.SubElement(destination, "domestic") + etree.SubElement(domestic, "postal-code").text = "%s" % (recipient_address.zip.replace(" ","").upper()) + elif str(self.service_type[:3])=='USA': + united_states = etree.SubElement(destination, "united-states") + etree.SubElement(united_states, "zip-code").text = "%s" % (recipient_address.zip.upper()) + + elif str(self.service_type[:3])=='INT': + international = etree.SubElement(destination, "international") + etree.SubElement(international, "country-code").text = "%s" % (recipient_address.country_id and recipient_address.country_id.code) + + url='%sship/price'%(self.get_canadapost_url()) + + base_data = etree.tostring(service_root).decode('utf-8') + + if self.canadapost_type == 'commercial': + headers = {"Accept": "application/vnd.cpc.ship.rate-v4+xml","Content-Type":"application/vnd.cpc.ship.rate-v4+xml"} + else: + headers = {"Accept": "application/vnd.cpc.ship.rate-v3+xml","Content-Type":"application/vnd.cpc.ship.rate-v3+xml"} + _logger.info("Rate Request Data : %s" % (base_data)) + try: + response_body = request(method='POST', url=url, data=base_data, headers=headers, auth=(self.username,self.password)) + api = Response(response_body) + result = api.dict() + _logger.info("Rate Response Data : %s" % (result)) + except Exception as e: + result['error_message'] = e.message + return result + return result + + # Random generate string and return + def get_group_id(self): + size = 15 + chars = string.ascii_uppercase + return ''.join(random.choice(chars) for _ in range(size)) + + # Create Shipment and return generate label and receipt from Canada post API + def canada_post_send_shipping(self, pickings): + response = [] + for picking in pickings: + weight_get = picking.shipping_weight or picking.weight + from_unit = picking.company_id and picking.company_id.weight_unit_of_measurement_id or "" + total_weight = round(self.convert_weight(from_unit, + self.weight_uom_id, + weight_get), 2) + # get package type + package_type = self.product_packaging_id + for stock_quant in picking.move_line_ids.result_package_id: + if not stock_quant.package_type_id: + package_type = self.product_packaging_id + else: + package_type = stock_quant.package_type_id + break + package_info = self.get_canadapost_parcel(package_type) + # Get the address of the sender and recipient + destination_address = picking.partner_id + sender_address = picking.picking_type_id and picking.picking_type_id.warehouse_id and picking.picking_type_id.warehouse_id.partner_id + if self.canadapost_type == 'commercial': + root_node = etree.Element("shipment") + root_node.attrib["xmlns"] = "http://www.canadapost.ca/ws/shipment-v8" + + etree.SubElement(root_node, "transmit-shipment").text ="true" + etree.SubElement(root_node, "provide-receipt-info").text ="true" + else: + root_node = etree.Element("non-contract-shipment") + root_node.attrib["xmlns"] = "http://www.canadapost.ca/ws/ncshipment-v4" + + etree.SubElement(root_node, "requested-shipping-point").text="%s"%(sender_address.zip.replace(" ","").upper() or "") + + delivery_spec_node=etree.SubElement(root_node, "delivery-spec") + etree.SubElement(delivery_spec_node, "service-code").text="%s"%(self.service_type) + + sender_node = etree.SubElement(delivery_spec_node, "sender") + etree.SubElement(sender_node, "company").text=sender_address.name + etree.SubElement(sender_node, "contact-phone").text ="%s"%(sender_address.phone or "") + address_details=etree.SubElement(sender_node, "address-details") + etree.SubElement(address_details, "address-line-1").text =sender_address.street or "" + etree.SubElement(address_details, "city").text =sender_address.city or "" + etree.SubElement(address_details, "prov-state").text ="%s"%(sender_address.state_id and sender_address.state_id.code or "") + if self.canadapost_type == 'commercial': + etree.SubElement(address_details , "country-code").text = "%s" % (sender_address.country_id and sender_address.country_id.code or "") + etree.SubElement(address_details, "postal-zip-code").text ="%s"%(sender_address.zip.replace(" ","").upper() or "") + + destination_node = etree.SubElement(delivery_spec_node, "destination") + etree.SubElement(destination_node, "name").text =destination_address.name + etree.SubElement(destination_node, "company").text =destination_address.name + etree.SubElement(destination_node, "client-voice-number").text = destination_address.phone + destination_address_details = etree.SubElement(destination_node, "address-details") + etree.SubElement(destination_address_details , "address-line-1").text =destination_address.street or "" + etree.SubElement(destination_address_details , "city").text =destination_address.city or "" + etree.SubElement(destination_address_details , "prov-state").text ="%s"%(destination_address.state_id and destination_address.state_id.code or "") + etree.SubElement(destination_address_details , "country-code").text = "%s" % (destination_address.country_id and destination_address.country_id.code or "") + etree.SubElement(destination_address_details , "postal-zip-code").text ="%s"%(destination_address.zip.replace(" ","").upper() or "") + + if self.option_code and not self.service_type in ['USA.XP','USA.EP','INT.IP.SURF','INT.PW.PARCEL','INT.XP']: + options = etree.SubElement(delivery_spec_node, "options") + option = etree.SubElement(options, "option") + etree.SubElement(option , "option-code").text = str(self.option_code or "") + + + if self.option_code in ['PA18','PA19']: + option_pa = etree.SubElement(options, "option") + etree.SubElement(option_pa , "option-code").text = 'SO' + + if self.option_code == 'COD' or self.option_code == 'COV': + etree.SubElement(option , "option-amount").text = str(picking.sale_id.amount_total or "") + etree.SubElement(option , "option-qualifier-1").text ="true" + else: + options = etree.SubElement(delivery_spec_node, "options") + option_usa = etree.SubElement(options, "option") + etree.SubElement(option_usa , "option-code").text = 'RASE' + + + parcel_characteristics= etree.SubElement(delivery_spec_node, "parcel-characteristics") + etree.SubElement(parcel_characteristics, "weight").text ="%s"%(total_weight) + + dimensions= etree.SubElement(parcel_characteristics, "dimensions ") + etree.SubElement(dimensions, "length").text ="%s"%(package_info.get('length', 1)) + etree.SubElement(dimensions, "width").text ="%s"%(package_info.get('width', 1)) + etree.SubElement(dimensions, "height").text ="%s"%(package_info.get('height', 1)) + + preferences= etree.SubElement(delivery_spec_node, "preferences") + etree.SubElement(preferences, "show-packing-instructions").text ="true" + + customs = etree.SubElement(delivery_spec_node, "customs") + etree.SubElement(customs, "currency").text = str(picking.sale_id.currency_id.name) + if picking.sale_id.currency_id.rate: + rate=picking.sale_id.currency_id.rate + rate=round(rate,2) + etree.SubElement(customs, "conversion-from-cad").text = str(rate or '') + etree.SubElement(customs, "reason-for-export").text = "%s"%(self.reason_for_export) + + sku_list = etree.SubElement(customs, "sku-list") + for move_line in picking.move_ids: + item = etree.SubElement(sku_list,"item") + etree.SubElement(item, "customs-description").text = str(move_line.product_id.name) + etree.SubElement(item, "unit-weight").text = str(move_line.weight) + etree.SubElement(item, "customs-value-per-unit").text = str(move_line.product_id.lst_price) + etree.SubElement(item, "customs-number-of-units").text = str(int(move_line.product_uom_qty)) + + if self.canadapost_type == 'commercial': + settlement_info = etree.SubElement(delivery_spec_node, "settlement-info") + etree.SubElement(settlement_info, "contract-id").text = self.canadapost_contract_id + etree.SubElement(settlement_info, "intended-method-of-payment").text = self.canadapost_payment_method + api_url=self.get_canadapost_url() + if self.canadapost_type == 'commercial': + url="%s%s/%s/shipment"%(api_url,self.customer_number,self.customer_number) + else: + url="%s%s/ncshipment"%(api_url,self.customer_number) + + base_data= etree.tostring(root_node).decode('utf-8') + + if self.canadapost_type == 'commercial': + headers = {"Accept": "application/vnd.cpc.shipment-v8+xml","Content-Type":"application/vnd.cpc.shipment-v8+xml","Accept-language":"en-CA"} + else: + headers = {"Accept": "application/vnd.cpc.ncshipment-v4+xml","Content-Type":"application/vnd.cpc.ncshipment-v4+xml","Accept-language":"en-CA"} + #try: + _logger.info("Create Shipment Request Data : %s" % (base_data)) + response_body = request(method='POST', url=url, data=base_data, headers=headers, auth=(self.username,self.password)) + if response_body.status_code == 200: + api = Response(response_body) + result = api.dict() + _logger.info("Create Shipment Response Data : %s" % (result)) + else: + error_code = "%s" % (response_body.status_code) + error_message = response_body.reason + message = error_code + " " + error_message + + api = Response(response_body) + result = api.dict() + + if result['messages']['message']['description']: + raise ValidationError(_("ShipmentRequest Fail : \n %s" % (result['messages']['message']['description']))) + else: + raise ValidationError(_("ShipmentRequest Fail : %s \n More Information \n %s" % (message, response_body.text))) + if self.canadapost_type == 'commercial': + if not result['shipment-info']['shipment-id'] or not result['shipment-info']['links']['link']: + raise RedirectWarning("ShipmentRequest Fail \n More Information \n %s" % (result)) + + shipment_id=str(result['shipment-info']['shipment-id']) + else: + if not result['non-contract-shipment-info']['shipment-id'] or not result['non-contract-shipment-info']['links']['link']: + raise RedirectWarning("ShipmentRequest Fail \n More Information \n %s" % (result)) + + shipment_id=str(result['non-contract-shipment-info']['shipment-id']) + commercial_invoice_url_attchment="" + commercial_invoice = False + url_attchment="" + + if self.canadapost_type == 'commercial': + for link in result['shipment-info']['links']['link']: + if link['_rel'] == 'label': + url_attchment = link['_href'] + if link['_rel'] =='commercialInvoice': + commercial_invoice_url_attchment = link['_href'] + commercial_invoice=True + else: + for link in result['non-contract-shipment-info']['links']['link']: + if link['_rel'] == 'label': + url_attchment = link['_href'] + if link['_rel'] =='commercialInvoice': + commercial_invoice_url_attchment = link['_href'] + commercial_invoice=True + headers_attchment = {'Accept': 'application/pdf'} + try: + attachment_response = request(method='GET', url=url_attchment, headers=headers_attchment, auth=( + self.username, self.password)) + _logger.info("Label Response Data : %s" % (attachment_response)) + picking.message_post(attachments=[ + ('Shipment Label - %s.PDF' % (shipment_id), + attachment_response.content)]) + + if commercial_invoice: + commercial_invoice_attachment_response = request(method='GET', url=commercial_invoice_url_attchment, headers=headers_attchment, auth=( + self.username, self.password)) + picking.message_post(attachments=[ + ('Shipment Commercial Invoice - %s.PDF' % (shipment_id), + commercial_invoice_attachment_response.content)]) + except Exception as e: + raise RedirectWarning(e) + if self.canadapost_type == 'commercial': + headers = {"Accept": "application/vnd.cpc.shipment-v8+xml","Accept-language":"en-CA"} + url_receipt = "%s"%(self.get_canadapost_url())+ str(self.customer_number)+"/"+str(self.customer_number)+"/shipment/" + str(shipment_id) + "/receipt" + else: + url_receipt = "%s"%(self.get_canadapost_url())+ str(self.customer_number) +"/ncshipment/" + str(shipment_id) + "/receipt" + try: + receipt_response= request(method='GET', url=url_receipt, headers=headers, auth=(self.username,self.password)) + if receipt_response.status_code == 200: + api_receipt = Response(receipt_response) + result_receipt = api_receipt.dict() + _logger.info("Get shipment Detail Response Data : %s" % (result_receipt)) + else: + result_receipt = {} + error_code = "%s" % (receipt_response.status_code) + error_message = response_body.reason + message = error_code + " " + error_message + mesage="ShipmentAcceptRequest Fail : %s \n More Information \n %s" % (message, response_body.text) + picking.message_post(body=mesage) + except Exception as e: + picking.message_post(body=e) + if self.canadapost_type == 'commercial': + if result_receipt: + extra_price = result_receipt['shipment-receipt'] and result_receipt['shipment-receipt']['cc-receipt-details'] and result_receipt['shipment-receipt']['cc-receipt-details']['charge-amount'] or 0.0 + else: + extra_price = 0.0 + tracking_pin= result['shipment-info'].get('tracking-pin',False) + else: + if result_receipt: + extra_price = result_receipt['non-contract-shipment-receipt'] and result_receipt['non-contract-shipment-receipt']['cc-receipt-details'] and result_receipt['non-contract-shipment-receipt']['cc-receipt-details']['charge-amount'] or 0.0 + else: + extra_price = 0.0 + tracking_pin= result['non-contract-shipment-info'].get('tracking-pin',False) + if not tracking_pin: + _logger.info("This Service not provide the tracking no.Service is : %s"%self.service_type) + shipping_data = { + 'exact_price': float(extra_price) or 0.0, + 'tracking_number': tracking_pin} + response += [shipping_data] + return response + + # Tracking link return + def canada_post_get_tracking_link(self, picking): + link = picking.carrier_id.tracking_link or 'https://www.canadapost.ca/trackweb/en#/resultList?searchFor=' + res = '%s %s' % (link, picking.carrier_tracking_ref) + return res + + def canada_post_cancel_shipment(self, picking): + raise ValidationError(_("Canada Post does not provide Shipment Cancel API!")) + + def get_canadapost_parcel(self, package): + packaging_length = ( + self.sudo()._canadapost_convert_dimension_to_uom( + package.packaging_length, package.length_uom_name + ) + ) + width = ( + self.sudo()._canadapost_convert_dimension_to_uom( + package.width, package.length_uom_name + ) + ) + height = ( + self.sudo()._canadapost_convert_dimension_to_uom( + package.height, package.length_uom_name + ) + ) + return { + "length": packaging_length, + "width": width, + "height": height + } + + def _canadapost_convert_dimension_to_uom(self, dimension, length_uom_name): + target_uom = self.env.ref("uom.product_uom_cm") + from_uom = self.env["uom.uom"].sudo().search([("name", "=", length_uom_name)]) + if not from_uom: + from_uom = self.env[ + "product.template" + ]._get_length_uom_id_from_ir_config_parameter() + # Convert dimensions + return from_uom._compute_quantity(dimension, target_uom) \ No newline at end of file diff --git a/delivery_canadapost/models/product_packaging.py b/delivery_canadapost/models/product_packaging.py new file mode 100755 index 00000000..75430b08 --- /dev/null +++ b/delivery_canadapost/models/product_packaging.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class PackageType(models.Model): + _inherit = "stock.package.type" + + package_carrier_type = fields.Selection([('canada_post', 'Canada Post')]) \ No newline at end of file diff --git a/delivery_canadapost/models/res_company.py b/delivery_canadapost/models/res_company.py new file mode 100644 index 00000000..63f53d5c --- /dev/null +++ b/delivery_canadapost/models/res_company.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields + +class ResCompany(models.Model): + _inherit = "res.company" + + def _default_uom_setting(self): + weight_uom_id = self.env.ref('uom.product_uom_kgm', raise_if_not_found=False) + if not weight_uom_id: + uom_categ_id = self.env.ref('uom.product_uom_categ_kgm').id + weight_uom_id = self.env['uom.uom'].search([('category_id', '=', uom_categ_id), ('factor', '=', 1)], limit=1) + return weight_uom_id + + + weight_unit_of_measurement_id = fields.Many2one('uom.uom',string="Weight Unit Of Measurement",help="Unit of measure for item weight",default=_default_uom_setting) + \ No newline at end of file diff --git a/delivery_canadapost/pyproject.toml b/delivery_canadapost/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/delivery_canadapost/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/delivery_canadapost/static/description/GoShippo.gif b/delivery_canadapost/static/description/GoShippo.gif new file mode 100644 index 00000000..03706a79 Binary files /dev/null and b/delivery_canadapost/static/description/GoShippo.gif differ diff --git a/delivery_canadapost/static/description/S1.png b/delivery_canadapost/static/description/S1.png new file mode 100644 index 00000000..616b44d8 Binary files /dev/null and b/delivery_canadapost/static/description/S1.png differ diff --git a/delivery_canadapost/static/description/S2.png b/delivery_canadapost/static/description/S2.png new file mode 100644 index 00000000..63aabb19 Binary files /dev/null and b/delivery_canadapost/static/description/S2.png differ diff --git a/delivery_canadapost/static/description/S3.png b/delivery_canadapost/static/description/S3.png new file mode 100644 index 00000000..e471bfc6 Binary files /dev/null and b/delivery_canadapost/static/description/S3.png differ diff --git a/delivery_canadapost/static/description/Shippit.gif b/delivery_canadapost/static/description/Shippit.gif new file mode 100644 index 00000000..5fa90955 Binary files /dev/null and b/delivery_canadapost/static/description/Shippit.gif differ diff --git a/delivery_canadapost/static/description/advanced_search.gif b/delivery_canadapost/static/description/advanced_search.gif new file mode 100644 index 00000000..9bd02bc9 Binary files /dev/null and b/delivery_canadapost/static/description/advanced_search.gif differ diff --git a/delivery_canadapost/static/description/canada_post_005.png b/delivery_canadapost/static/description/canada_post_005.png new file mode 100644 index 00000000..704c0f69 Binary files /dev/null and b/delivery_canadapost/static/description/canada_post_005.png differ diff --git a/delivery_canadapost/static/description/canada_post_banner.gif b/delivery_canadapost/static/description/canada_post_banner.gif new file mode 100644 index 00000000..37ebfc25 Binary files /dev/null and b/delivery_canadapost/static/description/canada_post_banner.gif differ diff --git a/delivery_canadapost/static/description/canadapost_thumbnail.png b/delivery_canadapost/static/description/canadapost_thumbnail.png new file mode 100644 index 00000000..eec64f0e Binary files /dev/null and b/delivery_canadapost/static/description/canadapost_thumbnail.png differ diff --git a/delivery_canadapost/static/description/chitchat_shipping.gif b/delivery_canadapost/static/description/chitchat_shipping.gif new file mode 100644 index 00000000..57b62261 Binary files /dev/null and b/delivery_canadapost/static/description/chitchat_shipping.gif differ diff --git a/delivery_canadapost/static/description/company_icon.png b/delivery_canadapost/static/description/company_icon.png new file mode 100644 index 00000000..e539a4a4 Binary files /dev/null and b/delivery_canadapost/static/description/company_icon.png differ diff --git a/delivery_canadapost/static/description/custom.css b/delivery_canadapost/static/description/custom.css new file mode 100644 index 00000000..221547a5 --- /dev/null +++ b/delivery_canadapost/static/description/custom.css @@ -0,0 +1,95 @@ +html { + padding: 50px; + font-family: sans-serif; +} +html h1 { + text-align: center; +} +html h2 { + text-align: center; + color: grey; +} +#exampleSlider { + position: relative; +} +@media (max-width: 767px) { + #exampleSlider { + border-color: transparent; + } +} +#exampleSlider .MS-content { + margin: 15px 5%; + overflow: hidden; + white-space: nowrap; + border: 1px solid red; +} +@media (max-width: 767px) { + #exampleSlider .MS-content { + margin: 0; + } +} +#exampleSlider .MS-content .item { + display: inline-block; + height: 30%; + overflow: hidden; + position: relative; + vertical-align: top; + border: 1px solid green; + border-right: none; + width: 33.33%; +} +@media (max-width: 1200px) { + #exampleSlider .MS-content .item { + width: 25%; + } +} +@media (max-width: 992px) { + #exampleSlider .MS-content .item { + width: 33.3333%; + } +} +@media (max-width: 767px) { + #exampleSlider .MS-content .item { + width: 50%; + } +} +#exampleSlider .MS-content .item p { + font-size: 30px; + text-align: center; + line-height: 1; + vertical-align: middle; + margin: 0; + padding: 10px 0; +} +#exampleSlider .MS-controls button { + position: absolute; + border: none; + background: transparent; + font-size: 30px; + outline: 0; + top: 35px; +} +@media (max-width: 767px) { + #exampleSlider .MS-controls button { + display: none; + } +} +#exampleSlider .MS-controls button:hover { + cursor: pointer; +} +#exampleSlider .MS-controls .MS-left { + left: 10px; +} +@media (max-width: 992px) { + #exampleSlider .MS-controls .MS-left { + left: -2px; + } +} +#exampleSlider .MS-controls .MS-right { + right: 10px; +} +@media (max-width: 992px) { + #exampleSlider .MS-controls .MS-right { + right: -2px; + } +} diff --git a/delivery_canadapost/static/description/dash.png b/delivery_canadapost/static/description/dash.png new file mode 100644 index 00000000..c576708b Binary files /dev/null and b/delivery_canadapost/static/description/dash.png differ diff --git a/delivery_canadapost/static/description/dpd.gif b/delivery_canadapost/static/description/dpd.gif new file mode 100644 index 00000000..d326b372 Binary files /dev/null and b/delivery_canadapost/static/description/dpd.gif differ diff --git a/delivery_canadapost/static/description/dsv_banner.gif b/delivery_canadapost/static/description/dsv_banner.gif new file mode 100644 index 00000000..8efeddcc Binary files /dev/null and b/delivery_canadapost/static/description/dsv_banner.gif differ diff --git a/delivery_canadapost/static/description/icon.png b/delivery_canadapost/static/description/icon.png new file mode 100644 index 00000000..4e9f7f83 Binary files /dev/null and b/delivery_canadapost/static/description/icon.png differ diff --git a/delivery_canadapost/static/description/images/icons_live_price.png b/delivery_canadapost/static/description/images/icons_live_price.png new file mode 100644 index 00000000..57194a52 Binary files /dev/null and b/delivery_canadapost/static/description/images/icons_live_price.png differ diff --git a/delivery_canadapost/static/description/images/icons_shipping_label.png b/delivery_canadapost/static/description/images/icons_shipping_label.png new file mode 100644 index 00000000..a7fb1e35 Binary files /dev/null and b/delivery_canadapost/static/description/images/icons_shipping_label.png differ diff --git a/delivery_canadapost/static/description/images/icons_track_and_trace.png b/delivery_canadapost/static/description/images/icons_track_and_trace.png new file mode 100644 index 00000000..1bae861b Binary files /dev/null and b/delivery_canadapost/static/description/images/icons_track_and_trace.png differ diff --git a/delivery_canadapost/static/description/index.html b/delivery_canadapost/static/description/index.html new file mode 100644 index 00000000..a37d2be2 --- /dev/null +++ b/delivery_canadapost/static/description/index.html @@ -0,0 +1,321 @@ + + + + + + + + Canada Post Shipping Integration with Odoo + + + + + + + + + + + + + + + + + +
+
+
+
+ +

+ Odoo Canada Post Shipping Integration

+ +
+
+
+ +
+
+
+

+ Why Canada Post Odoo Shipping Integration App From Synodica? +

+

+ Integrate Canada Post streamline shipping processes by obtaining real-time courier quotes for + various services, generating shipping labels based on order details, and providing tracking + numbers with + links for shipment updates + ensuring a seamless and efficient logistics workflow +

+
+
+
+

+ WATCH QUICK DEMO HERE +

+ + + + Play Button + + + + + + +
+
+
+
+
+
+ +
+
+

+ Features +

+
+
+
+
+
+
+

Live Shipping + Price

+

Get live price to ensure the best price for + your shipment

+
+
+
+
+
+
+
+
+

Shipping + Label

+

Generate shipping label using order information

+
+
+
+
+
+
+
+
+
+
+

Track & + Trace

+

Live track your shipment using the Canada + Post tracking number

+
+
+
+
+
+
+ + +
+
+

Canada Post Shipping In Configuration

+
+ +
+
+
+ +
+
+

Create Sale Order With Canada Post Delivery Method

+
+ +
+
+
+ +
+
+

Generate Shipment With Tracking Number And Shipping Label

+
+ +
+
+
+ +
+
+

Generated Shipment Label

+
+ +
+
+
+ + +
+
+
+ +
+
+
+ +
+
+

+ Suggested Products +

+ +
+
+ + +
+
+

+ Contact Us +

+
+ Suggestions & Feedback to: support@synodica.com +
+
+
+
+
+ + up-arrow + +
+
+ + + diff --git a/delivery_canadapost/static/description/label.png b/delivery_canadapost/static/description/label.png new file mode 100644 index 00000000..6ac340ba Binary files /dev/null and b/delivery_canadapost/static/description/label.png differ diff --git a/delivery_canadapost/static/description/play_button.png b/delivery_canadapost/static/description/play_button.png new file mode 100644 index 00000000..8a5a6cae Binary files /dev/null and b/delivery_canadapost/static/description/play_button.png differ diff --git a/delivery_canadapost/static/description/synodica_services.png b/delivery_canadapost/static/description/synodica_services.png new file mode 100644 index 00000000..d4b0e65f Binary files /dev/null and b/delivery_canadapost/static/description/synodica_services.png differ diff --git a/delivery_canadapost/static/description/up_arrow.png b/delivery_canadapost/static/description/up_arrow.png new file mode 100644 index 00000000..630bd3fb Binary files /dev/null and b/delivery_canadapost/static/description/up_arrow.png differ diff --git a/delivery_canadapost/views/delivery_carrier_view.xml b/delivery_canadapost/views/delivery_carrier_view.xml new file mode 100644 index 00000000..b472508c --- /dev/null +++ b/delivery_canadapost/views/delivery_carrier_view.xml @@ -0,0 +1,45 @@ + + + + delivery.carrier.form.view.canada.post + delivery.carrier + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_canada_post/README.md b/fusion_canada_post/README.md new file mode 100644 index 00000000..b47eb2d2 --- /dev/null +++ b/fusion_canada_post/README.md @@ -0,0 +1,7 @@ +# Nexa Systems Inc + +## Changelog + +### 19.0.1.0.0 + +- Initial version diff --git a/fusion_canada_post/__init__.py b/fusion_canada_post/__init__.py new file mode 100644 index 00000000..b8fa12fa --- /dev/null +++ b/fusion_canada_post/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import wizard +from . import fusion_cp_api diff --git a/fusion_canada_post/__manifest__.py b/fusion_canada_post/__manifest__.py new file mode 100644 index 00000000..0dd92450 --- /dev/null +++ b/fusion_canada_post/__manifest__.py @@ -0,0 +1,36 @@ +{ + # App information + "name": "Canada Post", + "version": "19.0.2.0.0", + "category": "Inventory/Delivery", + "summary": "Canada Post shipping integration — live pricing, " + "label generation, shipment tracking, and " + "multi-package support.", + "license": "OPL-1", + "depends": ["stock_delivery", "sale_management", "mail"], + # Views + "data": [ + "security/ir.model.access.csv", + "data/ir_sequence_data.xml", + "data/fusion_canada_post_data.xml", + "views/fusion_cp_shipment_views.xml", + "views/delivery_carrier_view.xml", + "views/choose_delivery_carrier_views.xml", + "views/sale_order_views.xml", + "views/stock_picking_views.xml", + "views/menus.xml", + "views/res_config_settings_views.xml", + ], + "images": ["static/description/canada_post_banner.gif"], + # Author + "author": "Nexa Systems Inc", + "website": "https://nexasystems.ca", + "maintainer": "Nexa Systems Inc", + "support": "support@nexasystems.ca", + # Technical + "installable": True, + "auto_install": False, + "application": True, + "price": "149.00", + "currency": "USD", +} diff --git a/fusion_canada_post/data/fusion_canada_post_data.xml b/fusion_canada_post/data/fusion_canada_post_data.xml new file mode 100644 index 00000000..c55646d4 --- /dev/null +++ b/fusion_canada_post/data/fusion_canada_post_data.xml @@ -0,0 +1,169 @@ + + + + + + + CP Box 26×20×20 in, 35 lb + fusion_canada_post + 26 + 20 + 20 + 35 + + + + CP Box 26×26×9 in, 15 lb + fusion_canada_post + 26 + 26 + 9 + 15 + + + + CP Box 32×28×10 in, 12 lb + fusion_canada_post + 32 + 28 + 10 + 12 + + + + CP Box 34×28×10 in, 10 lb + fusion_canada_post + 34 + 28 + 10 + 10 + + + + CP Box 9×25×27 in, 16 lb + fusion_canada_post + 9 + 25 + 27 + 16 + + + + CP Box 9×25×29 in, 17 lb + fusion_canada_post + 9 + 25 + 29 + 17 + + + + CP Box 10×10×6 in, 35 lb + fusion_canada_post + 10 + 10 + 6 + 35 + + + + CP Box 25×15×30 in, 25 lb + fusion_canada_post + 25 + 15 + 30 + 25 + + + + CP Box 10×10×10 in, 2 lb + fusion_canada_post + 10 + 10 + 10 + 2 + + + + CP Box 28×15×30 in, 40 lb + fusion_canada_post + 28 + 15 + 30 + 40 + + + + CP Box 28×10×30 in, 16 lb + fusion_canada_post + 28 + 10 + 30 + 16 + + + + CP Box 22×10×6 cm, 10 kg + fusion_canada_post + 22 + 10 + 6 + 10 + + + + CP Box 22×13×6 in, 10 lb + fusion_canada_post + 22 + 13 + 6 + 10 + + + + CP Box 12×10×5 in, 2 lb + fusion_canada_post + 12 + 10 + 5 + 2 + + + + + CP Box 10×10×10 in (Legacy) + fusion_canada_post + 10 + 10 + 10 + 2 + + + + + + SHIPPING BY CANADA POST + CPSHIPPING + service + + + + 0.0 + + + + + + Canada Post + + fusion_canada_post + commercial + in + SO + DOM.RP + + + diff --git a/fusion_canada_post/data/ir_sequence_data.xml b/fusion_canada_post/data/ir_sequence_data.xml new file mode 100644 index 00000000..24e5649a --- /dev/null +++ b/fusion_canada_post/data/ir_sequence_data.xml @@ -0,0 +1,12 @@ + + + + + Fusion CP Shipment + fusion.cp.shipment + FCP- + 5 + + + + diff --git a/fusion_canada_post/fusion_cp_api/__init__.py b/fusion_canada_post/fusion_cp_api/__init__.py new file mode 100644 index 00000000..83831eec --- /dev/null +++ b/fusion_canada_post/fusion_cp_api/__init__.py @@ -0,0 +1,2 @@ +from . import fusion_cp_response +from . import utils diff --git a/fusion_canada_post/fusion_cp_api/fusion_cp_response.py b/fusion_canada_post/fusion_cp_api/fusion_cp_response.py new file mode 100644 index 00000000..ffb3d420 --- /dev/null +++ b/fusion_canada_post/fusion_cp_api/fusion_cp_response.py @@ -0,0 +1,164 @@ +import lxml +import datetime +import logging +from collections import defaultdict +from odoo.addons.fusion_canada_post.fusion_cp_api.utils import get_dom_tree, python_2_unicode_compatible +import json +_logger = logging.getLogger(__name__) + +@python_2_unicode_compatible +class ResponseDataObject(): + + def __init__(self, mydict, datetime_nodes=[]): + self._load_dict(mydict, list(datetime_nodes)) + + def __repr__(self): + return str(self) + + def __str__(self): + return "%s" % self.__dict__ + + def has_key(self, name): + try: + getattr(self, name) + return True + except AttributeError: + return False + + def get(self, name, default=None): + try: + return getattr(self, name) + except AttributeError: + return default + + def _setattr(self, name, value, datetime_nodes): + if name.lower() in datetime_nodes: + try: + ts = "%s %s" % (value.partition('T')[0], value.partition('T')[2].partition('.')[0]) + value = datetime.datetime.strptime(ts, '%Y-%m-%d %H:%M:%S') + except ValueError: + pass + + setattr(self, name, value) + + def _load_dict(self, mydict, datetime_nodes): + + for a in list(mydict.items()): + + if isinstance(a[1], dict): + o = ResponseDataObject(a[1], datetime_nodes) + setattr(self, a[0], o) + + elif isinstance(a[1], list): + objs = [] + for i in a[1]: + if i is None or isinstance(i, str) or isinstance(i, str): + objs.append(i) + else: + objs.append(ResponseDataObject(i, datetime_nodes)) + + setattr(self, a[0], objs) + else: + self._setattr(a[0], a[1], datetime_nodes) + +class Response(): + + def __init__(self, obj, verb=None, parse_response=True): + self._obj = obj + if parse_response: + try: + self._dom = self._parse_xml(obj.content) + self._dict = self._etree_to_dict(self._dom) + + if verb and 'Envelope' in list(self._dict.keys()): + elem = self._dom.find('Body').find('%sResponse' % verb) + if elem is not None: + self._dom = elem + + self._dict = self._dict['Envelope']['Body'].get('%sResponse' % verb, self._dict) + elif verb: + elem = self._dom.find('%sResponse' % verb) + if elem is not None: + self._dom = elem + + self._dict = self._dict.get('%sResponse' % verb, self._dict) + + self.reply = ResponseDataObject(self._dict,[]) + + except lxml.etree.XMLSyntaxError as e: + _logger.debug('Response parse failed: %s' % e) + self.reply = ResponseDataObject({}, []) + else: + self.reply = ResponseDataObject({}, []) + + def _get_node_path(self, t): + i = t + path = [] + path.insert(0, i.tag) + while 1: + try: + path.insert(0, i.getparent().tag) + i = i.getparent() + except AttributeError: + break + + return '.'.join(path) + + @staticmethod + def _pullval(v): + if len(v) == 1: + return v[0] + else: + return v + + def _etree_to_dict(self, t): + if type(t) == lxml.etree._Comment: + return {} + + # remove xmlns from nodes + t.tag = self._get_node_tag(t) + + d = {t.tag: {} if t.attrib else None} + children = list(t) + if children: + dd = defaultdict(list) + for dc in map(self._etree_to_dict, children): + for k, v in list(dc.items()): + dd[k].append(v) + + d = {t.tag: dict((k, self._pullval(v)) for k, v in list(dd.items()))} + + parent_path = self._get_node_path(t) + for k in list(d[t.tag].keys()): + path = "%s.%s" % (parent_path, k) + + if t.attrib: + d[t.tag].update(('_' + k, v) for k, v in list(t.attrib.items())) + if t.text: + text = t.text.strip() + if children or t.attrib: + if text: + d[t.tag]['value'] = text + else: + d[t.tag] = text + return d + + def __getattr__(self, name): + return getattr(self._obj, name) + + def _parse_xml(self, xml): + return get_dom_tree(xml) + + def _get_node_tag(self, node): + return node.tag.replace('{' + node.nsmap.get(node.prefix, '') + '}', '') + + def dom(self, lxml=True): + if not lxml: + pass + return self._dom + + def dict(self): + return self._dict + + def json(self): + return json.dumps(self.dict()) diff --git a/fusion_canada_post/fusion_cp_api/utils.py b/fusion_canada_post/fusion_cp_api/utils.py new file mode 100644 index 00000000..cad803bd --- /dev/null +++ b/fusion_canada_post/fusion_cp_api/utils.py @@ -0,0 +1,215 @@ +import sys +from lxml import etree as ET + +def parse_yaml(yaml_file): + """ + This is simple approach to parsing a yaml config that is only + intended for this SDK as this only supports a very minimal subset + of yaml options. + """ + + with open(yaml_file) as f: + data = {None: {}} + current_key = None + + for line in f.readlines(): + + # ignore comments + if line.startswith('#'): + continue + + # parse the header + elif line[0].isalnum(): + key = line.strip().replace(':', '') + current_key = key + data[current_key] = {} + + # parse the key: value line + elif line[0].isspace(): + values = line.strip().split(':') + + if len(values) == 2: + cval = values[1].strip() + + if cval == '0': + cval = False + elif cval == '1': + cval = True + + data[current_key][values[0].strip()] = cval + return data + + +def python_2_unicode_compatible(klass): + """ + A decorator that defines __unicode__ and __str__ methods under Python 2. + Under Python 3 it does nothing. + + To support Python 2 and 3 with a single code base, define a __str__ method + returning text and apply this decorator to the class. + """ + if sys.version_info[0] < 3: + if '__str__' not in klass.__dict__: + raise ValueError("@python_2_unicode_compatible cannot be applied " + "to %s because it doesn't define __str__()." % + klass.__name__) + klass.__unicode__ = klass.__str__ + klass.__str__ = lambda self: self.__unicode__().encode('utf-8') + return klass + + +def get_dom_tree(xml): + tree = ET.fromstring(xml) + return tree.getroottree().getroot() + +def attribute_check(root): + attrs = [] + value = None + + if isinstance(root, dict): + if '#text' in root: + value = root['#text'] + if '@attrs' in root: + for ak, av in sorted(root.pop('@attrs').items()): + attrs.append(str('{0}="{1}"').format(ak, smart_encode(av))) + + return attrs, value + +def smart_encode(value): + try: + if sys.version_info[0] < 3: + return str(value).encode('utf-8') + else: + return value + + except UnicodeDecodeError: + return value + + +def to_xml(root): + return dict2xml(root) + +def dict2xml(root): + xml = str('') + if root is None: + return xml + + if isinstance(root, dict): + for key in sorted(root.keys()): + + if isinstance(root[key], dict): + attrs, value = attribute_check(root[key]) + + if not value: + value = dict2xml(root[key]) + elif isinstance(value, dict): + value = dict2xml(value) + + attrs_sp = str('') + if len(attrs) > 0: + attrs_sp = str(' ') + + xml = str('{xml}<{tag}{attrs_sp}{attrs}>{value}') \ + .format(**{'tag': key, 'xml': str(xml), 'attrs': str(' ').join(attrs), + 'value': smart_encode(value), 'attrs_sp': attrs_sp}) + + elif isinstance(root[key], list): + + for item in root[key]: + attrs, value = attribute_check(item) + + if not value: + value = dict2xml(item) + elif isinstance(value, dict): + value = dict2xml(value) + + attrs_sp = '' + if len(attrs) > 0: + attrs_sp = ' ' + + xml = str('{xml}<{tag}{attrs_sp}{attrs}>{value}') \ + .format(**{'xml': str(xml), 'tag': key, 'attrs': ' '.join(attrs), 'value': smart_encode(value), + 'attrs_sp': attrs_sp}) + + else: + value = root[key] + xml = str('{xml}<{tag}>{value}') \ + .format(**{'xml': str(xml), 'tag': key, 'value': smart_encode(value)}) + + elif isinstance(root, str) or isinstance(root, int) \ + or isinstance(root, str) or isinstance(root, int) \ + or isinstance(root, float): + xml = str('{0}{1}').format(str(xml), root) + else: + raise Exception('Unable to serialize node of type %s (%s)' % \ + (type(root), root)) + + return xml + +def getValue(response_dict, *args, **kwargs): + args_a = [w for w in args] + first = args_a[0] + args_a.remove(first) + + h = kwargs.get('mydict', {}) + if h: + h = h.get(first, {}) + else: + h = response_dict.get(first, {}) + + if len(args) == 1: + try: + return h.get('value', None) + except: + return h + + last = args_a.pop() + + for a in args_a: + h = h.get(a, {}) + + h = h.get(last, {}) + + try: + return h.get('value', None) + except: + return h + +def getNodeText(node): + "Returns the node's text string." + + rc = [] + + if hasattr(node, 'childNodes'): + for cn in node.childNodes: + if cn.nodeType == cn.TEXT_NODE: + rc.append(cn.data) + elif cn.nodeType == cn.CDATA_SECTION_NODE: + rc.append(cn.data) + + return ''.join(rc) + +def perftest_dict2xml(): + sample_dict = { + 'searchFilter': {'categoryId': {'#text': 222, '@attrs': {'site': 'US'}}}, + 'paginationInput': { + 'pageNumber': '1', + 'pageSize': '25' + }, + 'itemFilter': [ + {'name': 'Condition', + 'value': 'Used'}, + {'name': 'LocatedIn', + 'value': 'GB'}, + ], + 'sortOrder': 'StartTimeNewest' + } + xml = dict2xml(sample_dict) + +if __name__ == '__main__': + + import timeit + + import doctest + failure_count, test_count = doctest.testmod() + sys.exit(failure_count) diff --git a/fusion_canada_post/models/__init__.py b/fusion_canada_post/models/__init__.py new file mode 100644 index 00000000..e632858a --- /dev/null +++ b/fusion_canada_post/models/__init__.py @@ -0,0 +1,8 @@ +from . import delivery_carrier +from . import product_packaging +from . import res_company +from . import fusion_cp_shipment +from . import fusion_cp_tracking_event +from . import fusion_cp_order_package +from . import sale_order +from . import stock_picking diff --git a/fusion_canada_post/models/delivery_carrier.py b/fusion_canada_post/models/delivery_carrier.py new file mode 100644 index 00000000..76c8f7b6 --- /dev/null +++ b/fusion_canada_post/models/delivery_carrier.py @@ -0,0 +1,1185 @@ +import string +import random +import base64 +from requests import request +import xml.etree.ElementTree as etree +import logging +_logger = logging.getLogger(__name__) +from odoo import models, fields, api, _ +from odoo.exceptions import RedirectWarning,ValidationError +from odoo.addons.fusion_canada_post.fusion_cp_api.fusion_cp_response import Response + + +class DeliveryCarrier(models.Model): + _inherit = 'delivery.carrier' + + delivery_type = fields.Selection(selection_add=[('fusion_canada_post', 'Canada Post')], ondelete={ + 'fusion_canada_post': lambda recs: recs.write({'delivery_type': 'fixed', 'fixed_price': 0})}) + option_code = fields.Selection([ + ('SO', 'Signature Required'), + ('COV', 'Coverage (Insurance)'), + ('COD', 'Collect on Delivery'), + ('PA18', 'Proof of Age – 18+'), + ('PA19', 'Proof of Age – 19+'), + ('HFP', 'Card for Pickup'), + ('DNS', 'Do Not Safe Drop'), + ('LAD', 'Leave at Door'), + ], string="Shipping Option", + help="Optional delivery option applied to all shipments using this carrier.") + service_type = fields.Selection([ + ('DOM.RP', 'Regular Parcel'), + ('DOM.EP', 'Expedited Parcel'), + ('DOM.XP', 'Xpresspost'), + ('DOM.PC', 'Priority'), + ('USA.XP', 'Xpresspost USA'), + ('USA.EP', 'Expedited Parcel USA'), + ('INT.IP.SURF', 'International Parcel – Surface'), + ('INT.PW.PARCEL', 'Priority Worldwide Parcel'), + ('INT.XP', 'Xpresspost International'), + ], string="Default Service", + help="Default Canada Post service. Users can select a different service when adding shipping to a sale order.") + product_packaging_id = fields.Many2one('stock.package.type', string="Default Package Type",help="Optional default package type. Dimensions will pre-fill in the shipping wizard but can be overridden per shipment.") + + reason_for_export = fields.Selection([ + ('DOC', 'Document'), + ('SAM', 'Commercial Sample'), + ('REP', 'Repair or Warranty'), + ('SOG', 'Sale of Goods'), + ('OTH', 'Other'), + ], string="Reason for Export", default="SOG", + help="Required for international and cross-border shipments.") + + username = fields.Char("Username", copy=False, help="API key from Canada Post Developer Program.") + password = fields.Char("Password", copy=False, help="API password from Canada Post Developer Program.") + customer_number = fields.Char("Customer Number", copy=False, help="Your Canada Post customer number (mailed-by number).") + tracking_link = fields.Char(string="Tracking Link",help="Base URL for tracking shipments. Leave blank to use the default Canada Post tracking page.",size=256) + + fusion_cp_type = fields.Selection(selection=[('commercial', 'Contract Shipping'), ('counter', 'Non-Contract Shipping')], string="Customer Type", required=False) + fusion_cp_contract_id = fields.Char(string="Contract ID") + fusion_cp_payment_method = fields.Selection([('CreditCard', 'Credit Card'), ('Account', 'Account')], + string="Payment Method", default='CreditCard', + help="How Canada Post charges for shipments on this account.") + fusion_cp_output_format = fields.Selection( + [('4x6', '4×6 Thermal Label'), ('8.5x11', '8.5×11 Full Page')], + string="Label Output Format", + default='4x6', + help="Label format returned by Canada Post. " + "4×6 is for thermal label printers, " + "8.5×11 is the full page format.", + ) + fusion_cp_dimension_unit = fields.Selection( + [('cm', 'Centimeters (cm)'), ('in', 'Inches (in)')], + string="Package Dimension Unit", + default='cm', + help="Unit of measurement for package dimensions (length, width, height). " + "Choose the unit you use when entering dimensions on your package types. " + "Dimensions will be automatically converted to centimeters for the Canada Post API.", + ) + + def _default_uom_in_delive(self): + weight_uom_id = self.env.ref('uom.product_uom_kgm', raise_if_not_found=False) + if not weight_uom_id: + uom_categ_id = self.env.ref('uom.product_uom_categ_kgm').id + weight_uom_id = self.env['uom.uom'].search([('category_id', '=', uom_categ_id), ('factor', '=', 1)], limit=1) + return weight_uom_id + + weight_uom_id = fields.Many2one('uom.uom', string='API Weight Unit',help="Weight unit expected by the Canada Post API (kg). Used internally for converting weights before sending requests.",default=_default_uom_in_delive) + + @api.model + def get_fusion_cp_url(self): + if self.prod_environment: + return "https://soa-gw.canadapost.ca/rs/" + else: + return "https://ct.soa-gw.canadapost.ca/rs/" + + def _compute_can_generate_return(self): + super(DeliveryCarrier, self)._compute_can_generate_return() + for carrier in self: + if carrier.delivery_type == 'fusion_canada_post': + carrier.can_generate_return = True + + @api.model + def validating_address(self, partner, additional_fields=[]): + missing_value = [] + mandatory_fields = ['country_id', 'city', 'zip'] + mandatory_fields.extend(additional_fields) + if not partner.street and not partner.street2 : + mandatory_fields.append('street') + for field in mandatory_fields : + if not getattr(partner, field) : + missing_value.append(field) + return missing_value + + def check_required_value_to_ship(self, orders): + for order in orders : + if not order.order_line: + return _("There are no items to ship. Please add products to the order.") + else : + order_lines_without_weight = order.order_line.filtered(lambda line_item: not line_item.product_id.type in ['service', 'digital'] and not line_item.product_id.weight and not line_item.is_delivery) + for order_line in order_lines_without_weight : + return _("Product '%s' has no weight defined. Please set the weight before shipping.") % order_line.product_id.name + + # validating customer address + missing_value = self.validating_address(order.partner_shipping_id) + if missing_value : + fields = ", ".join(missing_value) + return (_("Incomplete customer address. Missing field(s): %s") % fields) + + # validation shipper address + missing_value = self.validating_address(order.warehouse_id.partner_id) + if missing_value : + fields = ", ".join(missing_value) + return (_("Incomplete warehouse address. Missing field(s): %s") % fields) + return False + + def convert_weight(self, from_uom_unit, to_uom_unit, weight): + if not from_uom_unit: + from_uom_unit = self.env[ + "product.template" + ]._get_weight_uom_id_from_ir_config_parameter() + return from_uom_unit._compute_quantity(weight, to_uom_unit) + + def check_max_weight(self, order, shipment_weight): + for order_line in order.order_line: + if order_line.product_id and order_line.product_id.weight > shipment_weight: + return (_("Product weight exceeds the maximum allowed weight for this package type.")) + return False + + def fusion_canada_post_rate_shipment(self, order): + # check the address validation + check_value = self.check_required_value_to_ship(order) + # check the product weight is appropriate to maximum weight. + if check_value: + return {'success': False, 'price': 0.0, 'error_message': check_value, 'warning_message': False} + # check the product weight is appropriate to maximum weight. + if self.product_packaging_id: + shipment_weight = self.product_packaging_id.max_weight + check_weight = {} + if shipment_weight != 0.0: + check_weight = self.check_max_weight(order, shipment_weight) + if check_weight: + return {'success': False, 'price': 0.0, 'error_message': check_weight, 'warning_message': False} + + shipper_address = order.warehouse_id.partner_id or order.company_id.partner_id + recipient_address = order.partner_shipping_id or order.partner_id + # Convert weight in to the delivery method's weight UOM + carrier_ctx = self.env.context + weight = carrier_ctx.get("order_weight", 0.0) or order._get_estimated_weight() or 0.0 + total_weight = self.convert_weight(order.company_id and order.company_id.weight_unit_of_measurement_id, + self.weight_uom_id, + weight) + declared_value = round(order.amount_untaxed, 2) + declared_currency = order.currency_id.name + + # Build package_info from SO dimensions or default package type + package_info = None + if (order.fusion_cp_package_length + and order.fusion_cp_package_width + and order.fusion_cp_package_height): + package_info = { + 'length': round(self._fusion_cp_convert_dimension_to_cm( + order.fusion_cp_package_length), 1), + 'width': round(self._fusion_cp_convert_dimension_to_cm( + order.fusion_cp_package_width), 1), + 'height': round(self._fusion_cp_convert_dimension_to_cm( + order.fusion_cp_package_height), 1), + } + elif self.product_packaging_id: + package_info = self.get_fusion_cp_parcel( + self.product_packaging_id) + + price = 0.0 + rate_dict = self.fusion_canada_post_get_shipping_rate(shipper_address, recipient_address, total_weight, + picking_bulk_weight=False, packages=False, + declared_value=declared_value, declared_currency=declared_currency, + company_id=order.company_id, + package_info=package_info) + _logger.info("Rate Response Data : %s" % (rate_dict)) + if rate_dict.get('messages', False): + return {'success': False, 'price':price, 'error_message': rate_dict['messages']['message']['description'], + 'warning_message': False} + if rate_dict.get('price-quotes',False) and rate_dict.get('price-quotes').get('price-quote',False) : + cnt = 0; + quotes = rate_dict['price-quotes']['price-quote'] + if isinstance(quotes, dict): + quotes = [quotes] + for quote in quotes: + if quote['service-code']==self.service_type: + price = quote['price-details']['due'] + cnt+=1 + if cnt==0: + return {'success': False, 'price': price, 'error_message': "Canada Post does not offer pricing for the selected service type to this destination.", + 'warning_message': False} + + if self.fusion_cp_type == 'counter' and self.service_type in ['DOM.RP','DOM.EP','DOM.XP','DOM.PC'] and self.option_code == 'COD': + if order.amount_total + float(price)>= 999.99: + raise ValidationError( + _("The COD amount cannot exceed $1,000.00 for non-contract shipping.") + ) + if self.fusion_cp_type == 'commercial' and self.service_type in ['DOM.RP','DOM.EP','DOM.XP','DOM.PC'] and self.option_code == 'COD': + if order.amount_total + float(price) >= 5000.00: + raise ValidationError( + _("The COD amount cannot exceed $5,000.00 for contract shipping.") + ) + return {'success': True, 'price': float(price), 'error_message': False, 'warning_message': False} + + def fusion_canada_post_get_shipping_rate(self, shipper_address, recipient_address, total_weight, picking_bulk_weight, + packages=False, declared_value=False, declared_currency=False, company_id=False, + package_info=None): + result = {} + # built request data + service_root = etree.Element("mailing-scenario") + + if self.fusion_cp_type == 'commercial': + service_root.attrib["xmlns"] = "http://www.canadapost.ca/ws/ship/rate-v4" + else: + service_root.attrib["xmlns"] = "http://www.canadapost.ca/ws/ship/rate-v3" + etree.SubElement(service_root, "customer-number").text = self.customer_number + parcel = etree.SubElement(service_root, "parcel-characteristics") + etree.SubElement(parcel, "weight").text = str(total_weight) + + # Include dimensions if provided (improves cubing/volumetric accuracy) + if package_info: + dims = etree.SubElement(parcel, "dimensions") + etree.SubElement(dims, "length").text = str( + package_info.get('length', 1)) + etree.SubElement(dims, "width").text = str( + package_info.get('width', 1)) + etree.SubElement(dims, "height").text = str( + package_info.get('height', 1)) + + etree.SubElement(service_root, "origin-postal-code").text = "%s" % (shipper_address.zip.replace(" ","").upper()) + + # Determine destination type from recipient country (not service_type) + destination = etree.SubElement(service_root, "destination") + country_code = recipient_address.country_id and recipient_address.country_id.code or '' + if country_code == 'CA': + domestic = etree.SubElement(destination, "domestic") + etree.SubElement(domestic, "postal-code").text = "%s" % (recipient_address.zip.replace(" ","").upper()) + elif country_code == 'US': + united_states = etree.SubElement(destination, "united-states") + etree.SubElement(united_states, "zip-code").text = "%s" % (recipient_address.zip.upper()) + else: + international = etree.SubElement(destination, "international") + etree.SubElement(international, "country-code").text = "%s" % (country_code) + + url='%sship/price'%(self.get_fusion_cp_url()) + + base_data = etree.tostring(service_root).decode('utf-8') + + if self.fusion_cp_type == 'commercial': + headers = {"Accept": "application/vnd.cpc.ship.rate-v4+xml","Content-Type":"application/vnd.cpc.ship.rate-v4+xml"} + else: + headers = {"Accept": "application/vnd.cpc.ship.rate-v3+xml","Content-Type":"application/vnd.cpc.ship.rate-v3+xml"} + _logger.info("Rate Request Data : %s" % (base_data)) + try: + response_body = request(method='POST', url=url, data=base_data, headers=headers, auth=(self.username,self.password)) + api = Response(response_body) + result = api.dict() + _logger.info("Rate Response Data : %s" % (result)) + except Exception as e: + result['error_message'] = e.message + return result + return result + + def fusion_canada_post_rate_shipment_all(self, order): + """Return ALL available Canada Post service rates for the order.""" + check_value = self.check_required_value_to_ship(order) + if check_value: + return {'success': False, 'error_message': check_value} + + shipper_address = order.warehouse_id.partner_id or order.company_id.partner_id + recipient_address = order.partner_shipping_id or order.partner_id + carrier_ctx = self.env.context + weight = carrier_ctx.get("order_weight", 0.0) or order._get_estimated_weight() or 0.0 + total_weight = self.convert_weight( + order.company_id and order.company_id.weight_unit_of_measurement_id, + self.weight_uom_id, weight) + declared_value = round(order.amount_untaxed, 2) + declared_currency = order.currency_id.name + + # Get package dimensions from context (set by wizard) + package_info = self.env.context.get('cp_package_info') + + rate_dict = self.fusion_canada_post_get_shipping_rate( + shipper_address, recipient_address, total_weight, + picking_bulk_weight=False, packages=False, + declared_value=declared_value, declared_currency=declared_currency, + company_id=order.company_id, + package_info=package_info) + + if rate_dict.get('messages', False): + return { + 'success': False, + 'error_message': rate_dict['messages']['message']['description'], + } + + rates = [] + if rate_dict.get('price-quotes') and rate_dict['price-quotes'].get('price-quote'): + quotes = rate_dict['price-quotes']['price-quote'] + if isinstance(quotes, dict): + quotes = [quotes] + for quote in quotes: + expected_delivery = '' + service_standard = quote.get('service-standard', {}) + if isinstance(service_standard, dict): + expected_delivery = service_standard.get( + 'expected-delivery-date', '') + rates.append({ + 'service_code': quote.get('service-code', ''), + 'service_name': quote.get('service-name', ''), + 'price': float(quote.get('price-details', {}).get('due', 0)), + 'expected_delivery': expected_delivery, + }) + + if not rates: + return { + 'success': False, + 'error_message': _("No shipping prices available for this destination."), + } + + return rates + + def get_group_id(self): + size = 15 + chars = string.ascii_uppercase + return ''.join(random.choice(chars) for _ in range(size)) + + def fusion_canada_post_send_shipping(self, pickings): + response = [] + for picking in pickings: + # Determine service code (order-level selection or carrier default) + service_code = self.service_type + if picking.sale_id and picking.sale_id.fusion_cp_service_code: + service_code = picking.sale_id.fusion_cp_service_code + + sale_order = picking.sale_id + so_packages = ( + sale_order.fusion_cp_package_ids.sorted('sequence') + if sale_order else False) + + from_unit = (picking.company_id + and picking.company_id + .weight_unit_of_measurement_id or "") + + all_tracking = [] + all_results = [] + total_price = 0.0 + + # ── Decide what drives the shipment loop ── + # If the SO has a package list (from the wizard), use it so we + # create one shipment per SO-defined package — even when the + # picking itself has only one "bulk" package. + if so_packages: + for so_pkg in so_packages: + pkg_weight = round(self.convert_weight( + from_unit, self.weight_uom_id, + so_pkg.weight), 2) + + package_info = { + 'length': round( + self._fusion_cp_convert_dimension_to_cm( + so_pkg.package_length), 1), + 'width': round( + self._fusion_cp_convert_dimension_to_cm( + so_pkg.package_width), 1), + 'height': round( + self._fusion_cp_convert_dimension_to_cm( + so_pkg.package_height), 1), + } + + svc = so_pkg.service_code or service_code + + self._fusion_cp_validate_package( + pkg_weight, package_info) + + result = self._fusion_cp_create_single_shipment( + picking, pkg_weight, package_info, svc) + + self._fusion_cp_record_shipment( + result, picking, sale_order, svc, pkg_weight, + pkg_name="Package %d" % ( + so_packages.ids.index(so_pkg.id) + 1)) + + all_tracking.append( + result.get('tracking_number', '')) + total_price += result.get('exact_price', 0.0) + all_results.append(result) + + else: + # Fall back to physical packages from the picking + packages = self._get_packages_from_picking( + picking, self.product_packaging_id) + + for idx, pkg in enumerate(packages): + pkg_weight = round(self.convert_weight( + from_unit, self.weight_uom_id, + pkg.weight), 2) + + # Dimensions: legacy SO fields → package type → default + package_info = None + + if sale_order: + if (sale_order.fusion_cp_package_length + and sale_order.fusion_cp_package_width + and sale_order.fusion_cp_package_height): + package_info = { + 'length': round( + self._fusion_cp_convert_dimension_to_cm( + sale_order.fusion_cp_package_length + ), 1), + 'width': round( + self._fusion_cp_convert_dimension_to_cm( + sale_order.fusion_cp_package_width + ), 1), + 'height': round( + self._fusion_cp_convert_dimension_to_cm( + sale_order.fusion_cp_package_height + ), 1), + } + + if not package_info: + pkg_type = ( + pkg.package_type + if hasattr(pkg, 'package_type') + and pkg.package_type + else self.product_packaging_id) + if pkg_type: + package_info = self.get_fusion_cp_parcel( + pkg_type) + else: + raise ValidationError(_( + "No package dimensions available for " + "'%s'. Please set dimensions when " + "adding shipping to the sale order.") + % (pkg.name or 'package')) + + self._fusion_cp_validate_package( + pkg_weight, package_info) + + result = self._fusion_cp_create_single_shipment( + picking, pkg_weight, package_info, + service_code) + + self._fusion_cp_record_shipment( + result, picking, sale_order, service_code, + pkg_weight, + pkg_name=(pkg.name + if hasattr(pkg, 'name') else '')) + + all_tracking.append( + result.get('tracking_number', '')) + total_price += result.get('exact_price', 0.0) + all_results.append(result) + + # ── Post combined chatter message with ALL labels ── + self._fusion_cp_post_shipping_documents( + picking, all_results) + + shipping_data = { + 'exact_price': total_price, + 'tracking_number': ','.join( + filter(None, all_tracking)), + } + response.append(shipping_data) + return response + + def _fusion_cp_create_single_shipment( + self, picking, pkg_weight, package_info, service_code): + """Create one CP shipment for a single package. + + Returns dict with tracking_number, shipment_id, exact_price, + and attachment records. + """ + destination_address = picking.partner_id + sender_address = (picking.picking_type_id + and picking.picking_type_id.warehouse_id + and picking.picking_type_id.warehouse_id.partner_id) + + # --- Build XML --- + if self.fusion_cp_type == 'commercial': + root_node = etree.Element("shipment") + root_node.attrib["xmlns"] = ( + "http://www.canadapost.ca/ws/shipment-v8") + etree.SubElement( + root_node, "transmit-shipment").text = "true" + etree.SubElement( + root_node, "provide-receipt-info").text = "true" + else: + root_node = etree.Element("non-contract-shipment") + root_node.attrib["xmlns"] = ( + "http://www.canadapost.ca/ws/ncshipment-v4") + + etree.SubElement( + root_node, "requested-shipping-point" + ).text = "%s" % (sender_address.zip.replace(" ", "").upper() or "") + + delivery_spec_node = etree.SubElement(root_node, "delivery-spec") + etree.SubElement( + delivery_spec_node, "service-code").text = "%s" % service_code + + # Sender + sender_node = etree.SubElement(delivery_spec_node, "sender") + etree.SubElement(sender_node, "company").text = sender_address.name + etree.SubElement( + sender_node, "contact-phone" + ).text = "%s" % (sender_address.phone or "") + address_details = etree.SubElement(sender_node, "address-details") + etree.SubElement( + address_details, "address-line-1" + ).text = sender_address.street or "" + etree.SubElement( + address_details, "city").text = sender_address.city or "" + etree.SubElement( + address_details, "prov-state" + ).text = "%s" % (sender_address.state_id + and sender_address.state_id.code or "") + if self.fusion_cp_type == 'commercial': + etree.SubElement( + address_details, "country-code" + ).text = "%s" % (sender_address.country_id + and sender_address.country_id.code or "") + etree.SubElement( + address_details, "postal-zip-code" + ).text = "%s" % (sender_address.zip.replace(" ", "").upper() or "") + + # Destination + destination_node = etree.SubElement( + delivery_spec_node, "destination") + etree.SubElement( + destination_node, "name").text = destination_address.name + etree.SubElement( + destination_node, "company").text = destination_address.name + etree.SubElement( + destination_node, "client-voice-number" + ).text = destination_address.phone + dest_addr = etree.SubElement( + destination_node, "address-details") + etree.SubElement( + dest_addr, "address-line-1" + ).text = destination_address.street or "" + etree.SubElement( + dest_addr, "city").text = destination_address.city or "" + etree.SubElement( + dest_addr, "prov-state" + ).text = "%s" % (destination_address.state_id + and destination_address.state_id.code or "") + etree.SubElement( + dest_addr, "country-code" + ).text = "%s" % (destination_address.country_id + and destination_address.country_id.code or "") + etree.SubElement( + dest_addr, "postal-zip-code" + ).text = "%s" % (destination_address.zip.replace( + " ", "").upper() or "") + + # Options + if (self.option_code + and self.service_type not in [ + 'USA.XP', 'USA.EP', 'INT.IP.SURF', + 'INT.PW.PARCEL', 'INT.XP']): + options = etree.SubElement(delivery_spec_node, "options") + option = etree.SubElement(options, "option") + etree.SubElement( + option, "option-code").text = str(self.option_code or "") + if self.option_code in ['PA18', 'PA19']: + option_pa = etree.SubElement(options, "option") + etree.SubElement(option_pa, "option-code").text = 'SO' + if self.option_code in ('COD', 'COV'): + etree.SubElement( + option, "option-amount" + ).text = str(picking.sale_id.amount_total or "") + etree.SubElement( + option, "option-qualifier-1").text = "true" + else: + options = etree.SubElement(delivery_spec_node, "options") + option_usa = etree.SubElement(options, "option") + etree.SubElement(option_usa, "option-code").text = 'RASE' + + # Parcel characteristics + parcel_chars = etree.SubElement( + delivery_spec_node, "parcel-characteristics") + etree.SubElement( + parcel_chars, "weight").text = "%s" % pkg_weight + dimensions = etree.SubElement(parcel_chars, "dimensions") + etree.SubElement( + dimensions, "length" + ).text = "%s" % package_info.get('length', 1) + etree.SubElement( + dimensions, "width" + ).text = "%s" % package_info.get('width', 1) + etree.SubElement( + dimensions, "height" + ).text = "%s" % package_info.get('height', 1) + + # Preferences + preferences = etree.SubElement(delivery_spec_node, "preferences") + etree.SubElement( + preferences, "show-packing-instructions").text = "true" + if self.fusion_cp_type != 'commercial': + etree.SubElement( + preferences, "output-format" + ).text = self.fusion_cp_output_format or "4x6" + else: + print_prefs = etree.SubElement( + delivery_spec_node, "print-preferences") + etree.SubElement( + print_prefs, "output-format" + ).text = self.fusion_cp_output_format or "4x6" + + # Customs + customs = etree.SubElement(delivery_spec_node, "customs") + etree.SubElement( + customs, "currency" + ).text = str(picking.sale_id.currency_id.name) + rate = 1.0 + if picking.sale_id.currency_id.rate: + rate = round(picking.sale_id.currency_id.rate, 2) + etree.SubElement( + customs, "conversion-from-cad").text = str(rate or '') + etree.SubElement( + customs, "reason-for-export" + ).text = "%s" % self.reason_for_export + + sku_list = etree.SubElement(customs, "sku-list") + for move_line in picking.move_ids: + item = etree.SubElement(sku_list, "item") + etree.SubElement( + item, "customs-description" + ).text = str(move_line.product_id.name) + etree.SubElement( + item, "unit-weight").text = str(move_line.weight) + etree.SubElement( + item, "customs-value-per-unit" + ).text = str(move_line.product_id.lst_price) + etree.SubElement( + item, "customs-number-of-units" + ).text = str(int(move_line.product_uom_qty)) + + # Settlement info (commercial only) + if self.fusion_cp_type == 'commercial': + settlement_info = etree.SubElement( + delivery_spec_node, "settlement-info") + etree.SubElement( + settlement_info, "contract-id" + ).text = self.fusion_cp_contract_id + etree.SubElement( + settlement_info, "intended-method-of-payment" + ).text = self.fusion_cp_payment_method + + # --- Make API call --- + api_url = self.get_fusion_cp_url() + if self.fusion_cp_type == 'commercial': + url = "%s%s/%s/shipment" % ( + api_url, self.customer_number, self.customer_number) + else: + url = "%s%s/ncshipment" % (api_url, self.customer_number) + + base_data = etree.tostring(root_node).decode('utf-8') + + if self.fusion_cp_type == 'commercial': + headers = { + "Accept": "application/vnd.cpc.shipment-v8+xml", + "Content-Type": "application/vnd.cpc.shipment-v8+xml", + "Accept-language": "en-CA", + } + else: + headers = { + "Accept": "application/vnd.cpc.ncshipment-v4+xml", + "Content-Type": "application/vnd.cpc.ncshipment-v4+xml", + "Accept-language": "en-CA", + } + + _logger.info("Create Shipment Request Data: %s", base_data) + response_body = request( + method='POST', url=url, data=base_data, + headers=headers, auth=(self.username, self.password)) + + if response_body.status_code == 200: + api = Response(response_body) + result = api.dict() + _logger.info("Create Shipment Response Data: %s", result) + else: + error_code = "%s" % response_body.status_code + error_message = response_body.reason + message = error_code + " " + error_message + api = Response(response_body) + result = api.dict() + if result['messages']['message']['description']: + raise ValidationError( + _("Canada Post: %s") + % result['messages']['message']['description']) + else: + raise ValidationError( + _("Canada Post shipment request failed (%s).\n%s") + % (message, response_body.text[:500])) + + # --- Extract shipment ID --- + if self.fusion_cp_type == 'commercial': + if (not result['shipment-info']['shipment-id'] + or not result['shipment-info']['links']['link']): + raise RedirectWarning( + "ShipmentRequest Fail \n More Information \n %s" + % result) + shipment_id = str(result['shipment-info']['shipment-id']) + else: + if (not result['non-contract-shipment-info']['shipment-id'] + or not result['non-contract-shipment-info'] + ['links']['link']): + raise RedirectWarning( + "ShipmentRequest Fail \n More Information \n %s" + % result) + shipment_id = str( + result['non-contract-shipment-info']['shipment-id']) + + # --- Extract artifact URLs --- + label_url = "" + receipt_link_url = "" + commercial_invoice_url = "" + commercial_invoice = False + links = (result['shipment-info']['links']['link'] + if self.fusion_cp_type == 'commercial' + else result['non-contract-shipment-info'] + ['links']['link']) + if isinstance(links, dict): + links = [links] + for link in links: + if link['_rel'] == 'label': + label_url = link['_href'] + elif link['_rel'] == 'receipt': + receipt_link_url = link['_href'] + elif link['_rel'] == 'commercialInvoice': + commercial_invoice_url = link['_href'] + commercial_invoice = True + + # --- Download artifacts --- + pdf_headers = {'Accept': 'application/pdf'} + label_attachment = False + receipt_attachment = False + full_label_attachment = False + ci_attachment = False + label_content = b'' + receipt_pdf_content = b'' + + try: + if label_url: + label_resp = request( + method='GET', url=label_url, headers=pdf_headers, + auth=(self.username, self.password)) + _logger.info( + "Label Response Status: %s", label_resp.status_code) + if label_resp.status_code == 200: + label_content = label_resp.content + label_attachment = self.env['ir.attachment'].create({ + 'name': 'Label-%s.pdf' % shipment_id, + 'type': 'binary', + 'datas': base64.b64encode(label_content), + 'res_model': 'stock.picking', + 'res_id': picking.id, + 'mimetype': 'application/pdf', + }) + + if not receipt_link_url: + if self.fusion_cp_type == 'commercial': + receipt_link_url = ( + "%s%s/%s/shipment/%s/receipt" + % (self.get_fusion_cp_url(), + self.customer_number, + self.customer_number, shipment_id)) + else: + receipt_link_url = ( + "%s%s/ncshipment/%s/receipt" + % (self.get_fusion_cp_url(), + self.customer_number, shipment_id)) + + if receipt_link_url: + receipt_pdf_resp = request( + method='GET', url=receipt_link_url, + headers=pdf_headers, + auth=(self.username, self.password)) + if receipt_pdf_resp.status_code == 200: + receipt_pdf_content = receipt_pdf_resp.content + receipt_attachment = ( + self.env['ir.attachment'].create({ + 'name': 'Receipt-%s.pdf' % shipment_id, + 'type': 'binary', + 'datas': base64.b64encode( + receipt_pdf_content), + 'res_model': 'stock.picking', + 'res_id': picking.id, + 'mimetype': 'application/pdf', + })) + + if label_content and receipt_pdf_content: + try: + from odoo.tools import pdf as pdf_tools + full_content = pdf_tools.merge_pdf( + [label_content, receipt_pdf_content]) + except Exception: + full_content = label_content + full_label_attachment = ( + self.env['ir.attachment'].create({ + 'name': 'FullLabel-%s.pdf' % shipment_id, + 'type': 'binary', + 'datas': base64.b64encode(full_content), + 'res_model': 'stock.picking', + 'res_id': picking.id, + 'mimetype': 'application/pdf', + })) + + if commercial_invoice and commercial_invoice_url: + ci_resp = request( + method='GET', url=commercial_invoice_url, + headers=pdf_headers, + auth=(self.username, self.password)) + if ci_resp.status_code == 200: + ci_attachment = self.env['ir.attachment'].create({ + 'name': 'CommercialInvoice-%s.pdf' % shipment_id, + 'type': 'binary', + 'datas': base64.b64encode(ci_resp.content), + 'res_model': 'stock.picking', + 'res_id': picking.id, + 'mimetype': 'application/pdf', + }) + + except Exception as e: + _logger.error( + "Error downloading Canada Post artifacts: %s", e) + picking.message_post( + body=_('Error downloading shipping documents ' + 'for shipment %s: %s') % (shipment_id, str(e))) + + # --- Fetch receipt XML for pricing info --- + if self.fusion_cp_type == 'commercial': + receipt_xml_headers = { + "Accept": "application/vnd.cpc.shipment-v8+xml", + "Accept-language": "en-CA", + } + url_receipt = "%s%s/%s/shipment/%s/receipt" % ( + self.get_fusion_cp_url(), self.customer_number, + self.customer_number, shipment_id) + else: + receipt_xml_headers = { + "Accept": "application/vnd.cpc.ncshipment-v4+xml", + "Accept-language": "en-CA", + } + url_receipt = "%s%s/ncshipment/%s/receipt" % ( + self.get_fusion_cp_url(), self.customer_number, + shipment_id) + + result_receipt = {} + try: + receipt_response = request( + method='GET', url=url_receipt, + headers=receipt_xml_headers, + auth=(self.username, self.password)) + if receipt_response.status_code == 200: + api_receipt = Response(receipt_response) + result_receipt = api_receipt.dict() + _logger.info("Receipt XML Data: %s", result_receipt) + else: + _logger.warning( + "Receipt fetch: %s %s", + receipt_response.status_code, + receipt_response.reason) + except Exception as e: + _logger.warning("Error fetching receipt XML: %s", e) + + # Extract pricing and tracking + extra_price = 0.0 + tracking_pin = False + if self.fusion_cp_type == 'commercial': + if result_receipt: + try: + extra_price = float( + result_receipt.get('shipment-receipt', {}) + .get('cc-receipt-details', {}) + .get('charge-amount', 0.0)) + except (TypeError, ValueError): + extra_price = 0.0 + tracking_pin = result['shipment-info'].get( + 'tracking-pin', False) + else: + if result_receipt: + try: + extra_price = float( + result_receipt + .get('non-contract-shipment-receipt', {}) + .get('cc-receipt-details', {}) + .get('charge-amount', 0.0)) + except (TypeError, ValueError): + extra_price = 0.0 + tracking_pin = ( + result['non-contract-shipment-info'] + .get('tracking-pin', False)) + + if not tracking_pin: + _logger.info( + "This service does not provide tracking. Service: %s", + service_code) + + return { + 'tracking_number': tracking_pin or '', + 'shipment_id': shipment_id, + 'exact_price': float(extra_price) or 0.0, + 'label_attachment': label_attachment, + 'full_label_attachment': full_label_attachment, + 'receipt_attachment': receipt_attachment, + 'ci_attachment': ci_attachment, + 'label_content': label_content, + 'receipt_content': receipt_pdf_content, + } + + def _fusion_cp_post_shipping_documents(self, picking, all_results): + """Post a single chatter message with combined shipping documents. + + Merges all label PDFs into one printable file so the user can + print every label at once. Individual labels remain on the + ``fusion.cp.shipment`` records for per-shipment access. + """ + from odoo.tools import pdf as pdf_tools + + shipment_ids = [ + r.get('shipment_id', '') for r in all_results + if r.get('shipment_id')] + + # ── Merge all label PDFs ── + label_pages = [ + r['label_content'] for r in all_results + if r.get('label_content')] + receipt_pages = [ + r['receipt_content'] for r in all_results + if r.get('receipt_content')] + + attach_ids = [] + + if label_pages: + try: + if len(label_pages) > 1: + merged = pdf_tools.merge_pdf(label_pages) + else: + merged = label_pages[0] + att = self.env['ir.attachment'].create({ + 'name': 'Labels-All.pdf', + 'type': 'binary', + 'datas': base64.b64encode(merged), + 'res_model': 'stock.picking', + 'res_id': picking.id, + 'mimetype': 'application/pdf', + }) + attach_ids.append(att.id) + except Exception as e: + _logger.warning( + "Could not merge label PDFs: %s", e) + # Fall back: attach each label individually + for r in all_results: + if r.get('label_attachment'): + attach_ids.append( + r['label_attachment'].id) + + if receipt_pages: + try: + if len(receipt_pages) > 1: + merged_rcpt = pdf_tools.merge_pdf(receipt_pages) + else: + merged_rcpt = receipt_pages[0] + att_r = self.env['ir.attachment'].create({ + 'name': 'Receipts-All.pdf', + 'type': 'binary', + 'datas': base64.b64encode(merged_rcpt), + 'res_model': 'stock.picking', + 'res_id': picking.id, + 'mimetype': 'application/pdf', + }) + attach_ids.append(att_r.id) + except Exception as e: + _logger.warning( + "Could not merge receipt PDFs: %s", e) + for r in all_results: + if r.get('receipt_attachment'): + attach_ids.append( + r['receipt_attachment'].id) + + # Commercial invoices (one per shipment, not merged) + for r in all_results: + if r.get('ci_attachment'): + attach_ids.append(r['ci_attachment'].id) + + # ── Post to chatter ── + if len(shipment_ids) == 1: + body = _('Canada Post shipment %s created.') % ( + shipment_ids[0]) + elif shipment_ids: + body = _('Canada Post — %d shipments created: %s') % ( + len(shipment_ids), ', '.join(shipment_ids)) + else: + body = _('Canada Post shipment created.') + + if attach_ids: + picking.message_post( + body=body, attachment_ids=attach_ids) + else: + picking.message_post(body=body) + + def _fusion_cp_record_shipment( + self, result, picking, sale_order, service_code, + pkg_weight, pkg_name=''): + """Create a ``fusion.cp.shipment`` record from shipment result dict. + + Called once per package by ``fusion_canada_post_send_shipping`` + after ``_fusion_cp_create_single_shipment`` returns successfully. + """ + shipment_vals = { + 'tracking_number': result.get('tracking_number', ''), + 'shipment_id': result.get('shipment_id', ''), + 'carrier_id': self.id, + 'sale_order_id': (sale_order.id if sale_order else False), + 'picking_id': picking.id, + 'service_type': service_code, + 'shipment_date': fields.Datetime.now(), + 'shipping_cost': result.get('exact_price', 0.0), + 'weight': pkg_weight, + 'status': 'confirmed', + 'company_id': picking.company_id.id, + 'package_name': pkg_name, + } + + # Attach label / receipt / CI documents + if result.get('label_attachment'): + shipment_vals['label_attachment_id'] = ( + result['label_attachment'].id) + if result.get('full_label_attachment'): + shipment_vals['full_label_attachment_id'] = ( + result['full_label_attachment'].id) + if result.get('receipt_attachment'): + shipment_vals['receipt_attachment_id'] = ( + result['receipt_attachment'].id) + if result.get('ci_attachment'): + shipment_vals['commercial_invoice_attachment_id'] = ( + result['ci_attachment'].id) + + self.env['fusion.cp.shipment'].create(shipment_vals) + + def fusion_canada_post_get_tracking_link(self, picking): + link = picking.carrier_id.tracking_link or 'https://www.canadapost.ca/trackweb/en#/resultList?searchFor=' + res = '%s %s' % (link, picking.carrier_tracking_ref) + return res + + def fusion_canada_post_cancel_shipment(self, picking): + """Cancel all CP shipments linked to this picking via void API.""" + shipments = self.env['fusion.cp.shipment'].search([ + ('picking_id', '=', picking.id), + ('status', '!=', 'cancelled'), + ]) + for shipment in shipments: + try: + shipment.action_void_shipment() + except ValidationError as e: + _logger.warning( + "Failed to void CP shipment %s: %s", + shipment.name, e) + raise + + def _fusion_cp_validate_package(self, weight_kg, package_info): + """Validate package dimensions and weight against Canada Post limits. + + ``package_info`` values are already in **cm** (converted by + ``get_fusion_cp_parcel``). + + CP limits: + - Max weight: 30 kg + - Max longest dimension: 100 cm + - Max second longest dimension: 76 cm + - Volumetric weight: L × W × H (cm) / 5000 + - Billable weight = max(actual, volumetric) + + Raises ValidationError if hard limits exceeded. + Logs warning if volumetric weight exceeds actual weight. + """ + errors = [] + length_cm = float(package_info.get('length', 0)) + width_cm = float(package_info.get('width', 0)) + height_cm = float(package_info.get('height', 0)) + + # Sort dimensions to find longest and second longest + dims = sorted([length_cm, width_cm, height_cm], reverse=True) + + # Determine user's preferred display unit + dim_unit = self.fusion_cp_dimension_unit or 'cm' + if dim_unit == 'in': + unit_label = 'in' + # Convert cm limits to inches for the error message + max_longest = 39.4 # 100 cm ≈ 39.4 in + max_second = 29.9 # 76 cm ≈ 29.9 in + factor = 1 / 2.54 # cm → inches + else: + unit_label = 'cm' + max_longest = 100 + max_second = 76 + factor = 1 + + if weight_kg > 30: + errors.append( + _("Weight %.2f kg exceeds Canada Post maximum of 30 kg.") + % weight_kg) + if dims[0] > 100: + errors.append( + _("Longest dimension %.1f %s exceeds Canada Post " + "maximum of %.1f %s.") % ( + dims[0] * factor, unit_label, + max_longest, unit_label)) + if dims[1] > 76: + errors.append( + _("Second longest dimension %.1f %s exceeds Canada Post " + "maximum of %.1f %s.") % ( + dims[1] * factor, unit_label, + max_second, unit_label)) + + if errors: + raise ValidationError('\n'.join(errors)) + + # Volumetric weight warning (CP cubing) + if length_cm and width_cm and height_cm: + vol_weight = (length_cm * width_cm * height_cm) / 5000 + if vol_weight > weight_kg: + _logger.warning( + "CP cubing: volumetric weight %.2f kg > actual %.2f kg " + "for package (%.1f × %.1f × %.1f cm). Canada Post will " + "bill at the higher volumetric weight.", + vol_weight, weight_kg, length_cm, width_cm, height_cm) + + def get_fusion_cp_parcel(self, package): + """Get package dimensions in cm for Canada Post API. + + Uses the carrier's ``fusion_cp_dimension_unit`` setting to determine + the source unit (cm or inches) instead of relying on the company's + default length UOM, which may be set to feet or another unit. + """ + packaging_length = self.sudo()._fusion_cp_convert_dimension_to_cm( + package.packaging_length) + width = self.sudo()._fusion_cp_convert_dimension_to_cm( + package.width) + height = self.sudo()._fusion_cp_convert_dimension_to_cm( + package.height) + return { + "length": round(packaging_length, 1), + "width": round(width, 1), + "height": round(height, 1), + } + + def _fusion_cp_convert_dimension_to_cm(self, dimension): + """Convert a dimension value to centimeters using the carrier's + configured dimension unit (``fusion_cp_dimension_unit``). + + If the carrier is set to *cm*, the value is returned as-is. + If set to *in* (inches), it is converted from inches → cm. + """ + if not dimension: + return 0.0 + dim_unit = self.fusion_cp_dimension_unit or 'cm' + if dim_unit == 'cm': + return dimension + # inches → cm + target_uom = self.env.ref("uom.product_uom_cm") + from_uom = self.env.ref("uom.product_uom_inch") + return from_uom._compute_quantity(dimension, target_uom) diff --git a/fusion_canada_post/models/fusion_cp_order_package.py b/fusion_canada_post/models/fusion_cp_order_package.py new file mode 100644 index 00000000..f8290e15 --- /dev/null +++ b/fusion_canada_post/models/fusion_cp_order_package.py @@ -0,0 +1,41 @@ +from odoo import models, fields + + +class FusionCPOrderPackage(models.Model): + """Stores per-package dimensions and service info on a sale order. + + Created when the user confirms the "Add Shipping" wizard after + defining one or more packages with dimensions. + Used by ``send_shipping`` to apply the correct dimensions + to each physical package on the delivery order. + """ + + _name = 'fusion.cp.order.package' + _description = 'Canada Post Order Package' + _order = 'sequence, id' + + sale_order_id = fields.Many2one( + 'sale.order', + string='Sale Order', + ondelete='cascade', + required=True, + index=True, + ) + sequence = fields.Integer(default=10) + package_type_id = fields.Many2one( + 'stock.package.type', + string='Box Type', + domain="[('package_carrier_type', '=', 'fusion_canada_post')]", + ) + package_length = fields.Float(string='Length') + package_width = fields.Float(string='Width') + package_height = fields.Float(string='Height') + weight = fields.Float(string='Weight') + service_code = fields.Char(string='Service Code') + service_name = fields.Char(string='Service') + price = fields.Float(string='Shipping Cost', digits='Product Price') + expected_delivery = fields.Char(string='Expected Delivery') + currency_id = fields.Many2one( + 'res.currency', + related='sale_order_id.currency_id', + ) diff --git a/fusion_canada_post/models/fusion_cp_shipment.py b/fusion_canada_post/models/fusion_cp_shipment.py new file mode 100644 index 00000000..35ee7f93 --- /dev/null +++ b/fusion_canada_post/models/fusion_cp_shipment.py @@ -0,0 +1,506 @@ +import logging +from datetime import datetime as dt_mod +from lxml import etree +from requests import request +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError +from odoo.addons.fusion_canada_post.fusion_cp_api.fusion_cp_response import Response + +_logger = logging.getLogger(__name__) + + +class FusionCpShipment(models.Model): + _name = 'fusion.cp.shipment' + _description = 'Canada Post Shipment' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'shipment_date desc, id desc' + _rec_name = 'name' + + name = fields.Char( + string='Reference', + required=True, + readonly=True, + default=lambda self: _('New'), + copy=False, + ) + tracking_number = fields.Char( + string='Tracking Number', + index=True, + readonly=True, + copy=False, + tracking=True, + ) + shipment_id = fields.Char( + string='CP Shipment ID', + readonly=True, + copy=False, + help='Canada Post internal shipment identifier', + ) + sale_order_id = fields.Many2one( + 'sale.order', + string='Sale Order', + index=True, + ondelete='set null', + tracking=True, + ) + picking_id = fields.Many2one( + 'stock.picking', + string='Transfer', + index=True, + ondelete='set null', + tracking=True, + ) + carrier_id = fields.Many2one( + 'delivery.carrier', + string='Carrier', + ondelete='set null', + ) + shipment_date = fields.Datetime( + string='Shipment Date', + default=fields.Datetime.now, + tracking=True, + ) + status = fields.Selection( + [ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('shipped', 'Shipped'), + ('delivered', 'Delivered'), + ('cancelled', 'Cancelled'), + ], + string='Status', + default='confirmed', + tracking=True, + ) + # Label attachments (Many2one for storage) + label_attachment_id = fields.Many2one( + 'ir.attachment', + string='Printable Label (4x6)', + ondelete='set null', + help='The thermal printer label (4x6 format)', + ) + full_label_attachment_id = fields.Many2one( + 'ir.attachment', + string='Full Label (8.5x11)', + ondelete='set null', + help='Full page label with instructions and receipt', + ) + receipt_attachment_id = fields.Many2one( + 'ir.attachment', + string='Receipt', + ondelete='set null', + ) + commercial_invoice_attachment_id = fields.Many2one( + 'ir.attachment', + string='Commercial Invoice', + ondelete='set null', + ) + # Shipment details + shipping_cost = fields.Float( + string='Shipping Cost', + digits='Product Price', + readonly=True, + ) + service_type = fields.Char( + string='Service Type', + readonly=True, + ) + weight = fields.Float( + string='Weight', + digits='Stock Weight', + readonly=True, + ) + package_name = fields.Char( + string='Package', + readonly=True, + help='Name of the package this shipment covers', + ) + # Address fields (computed) + sender_name = fields.Char( + string='Sender', + compute='_compute_sender_fields', + store=True, + ) + sender_address = fields.Char( + string='Sender Address', + compute='_compute_sender_fields', + store=True, + ) + recipient_name = fields.Char( + string='Recipient', + compute='_compute_recipient_fields', + store=True, + ) + recipient_address = fields.Char( + string='Recipient Address', + compute='_compute_recipient_fields', + store=True, + ) + company_id = fields.Many2one( + 'res.company', + string='Company', + default=lambda self: self.env.company, + ) + # Tracking history + tracking_event_ids = fields.One2many( + 'fusion.cp.tracking.event', + 'shipment_id', + string='Tracking Events', + ) + tracking_event_count = fields.Integer( + string='Tracking Events', + compute='_compute_tracking_event_count', + ) + last_tracking_update = fields.Datetime( + string='Last Tracking Update', + readonly=True, + ) + delivery_date = fields.Datetime( + string='Delivery Date', + readonly=True, + ) + + def _compute_tracking_event_count(self): + for rec in self: + rec.tracking_event_count = len(rec.tracking_event_ids) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', _('New')) == _('New'): + vals['name'] = self.env['ir.sequence'].next_by_code( + 'fusion.cp.shipment' + ) or _('New') + return super().create(vals_list) + + @api.depends('picking_id', 'picking_id.picking_type_id.warehouse_id.partner_id') + def _compute_sender_fields(self): + for rec in self: + partner = ( + rec.picking_id.picking_type_id.warehouse_id.partner_id + if rec.picking_id + else False + ) + if partner: + rec.sender_name = partner.name or '' + parts = filter(None, [ + partner.street, + partner.city, + partner.state_id.name if partner.state_id else '', + partner.zip, + ]) + rec.sender_address = ', '.join(parts) + else: + rec.sender_name = '' + rec.sender_address = '' + + @api.depends('picking_id', 'picking_id.partner_id') + def _compute_recipient_fields(self): + for rec in self: + partner = rec.picking_id.partner_id if rec.picking_id else False + if partner: + rec.recipient_name = partner.name or '' + parts = filter(None, [ + partner.street, + partner.city, + partner.state_id.name if partner.state_id else '', + partner.zip, + ]) + rec.recipient_address = ', '.join(parts) + else: + rec.recipient_name = '' + rec.recipient_address = '' + + def action_open_sale_order(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Sale Order', + 'res_model': 'sale.order', + 'res_id': self.sale_order_id.id, + 'view_mode': 'form', + 'target': 'current', + } + + def action_open_picking(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Transfer', + 'res_model': 'stock.picking', + 'res_id': self.picking_id.id, + 'view_mode': 'form', + 'target': 'current', + } + + def _action_open_attachment(self, attachment): + """Open an attachment PDF in the browser viewer (new tab).""" + self.ensure_one() + if not attachment: + return False + return { + 'type': 'ir.actions.act_url', + 'url': '/web/content/%s?download=false' % attachment.id, + 'target': 'new', + } + + def action_view_label(self): + return self._action_open_attachment(self.label_attachment_id) + + def action_view_full_label(self): + return self._action_open_attachment(self.full_label_attachment_id) + + def action_view_receipt(self): + return self._action_open_attachment(self.receipt_attachment_id) + + def action_view_commercial_invoice(self): + return self._action_open_attachment(self.commercial_invoice_attachment_id) + + # ── Tracking ────────────────────────────────────────────── + + def action_refresh_tracking(self): + """Fetch latest tracking events from Canada Post VIS API.""" + self.ensure_one() + if not self.tracking_number: + raise ValidationError( + _("No tracking number available for this shipment.")) + carrier = self.carrier_id + if not carrier: + raise ValidationError( + _("No carrier linked to this shipment.")) + + # VIS tracking uses /vis/ path, not /rs/ + if carrier.prod_environment: + base = "https://soa-gw.canadapost.ca" + else: + base = "https://ct.soa-gw.canadapost.ca" + + url = "%s/vis/track/pin/%s/detail" % (base, self.tracking_number) + headers = { + 'Accept': 'application/vnd.cpc.track-v2+xml', + 'Accept-language': 'en-CA', + } + + try: + resp = request( + method='GET', url=url, headers=headers, + auth=(carrier.username, carrier.password)) + _logger.info("Tracking API %s → %s", url, resp.status_code) + + if resp.status_code == 404: + # "No Pin History" — normal for new/sandbox shipments + self.last_tracking_update = fields.Datetime.now() + self.message_post( + body=_("No tracking history available yet for %s.") % + self.tracking_number) + return + + if resp.status_code != 200: + raise ValidationError( + _("Canada Post tracking error: %s %s\n%s") % ( + resp.status_code, resp.reason, + resp.text[:500] if resp.text else '')) + api_resp = Response(resp) + result = api_resp.dict() + except ValidationError: + raise + except Exception as e: + raise ValidationError( + _("Failed to fetch tracking: %s") % str(e)) + + self._process_tracking_events(result) + + def _process_tracking_events(self, result): + """Parse CP tracking detail response and store events.""" + detail = result.get('tracking-detail', result) + sig_events = detail.get('significant-events', {}) + occurrences = sig_events.get('occurrence', []) + + # Single event returns dict, multiple returns list + if isinstance(occurrences, dict): + occurrences = [occurrences] + + # Replace existing events + self.tracking_event_ids.unlink() + + vals_list = [] + for occ in occurrences: + event_date_str = occ.get('event-date', '') + event_time_str = occ.get('event-time', '') + + # Build combined datetime for sorting + event_datetime = False + if event_date_str: + try: + if event_time_str: + event_datetime = dt_mod.strptime( + '%s %s' % (event_date_str, event_time_str), + '%Y-%m-%d %H:%M:%S') + else: + event_datetime = dt_mod.strptime( + event_date_str, '%Y-%m-%d') + except (ValueError, TypeError): + pass + + vals_list.append({ + 'shipment_id': self.id, + 'event_date': event_date_str or False, + 'event_time': event_time_str or '', + 'event_datetime': event_datetime, + 'event_description': occ.get('event-description', ''), + 'event_type': occ.get('event-type', ''), + 'event_site': occ.get('event-site', ''), + 'event_province': occ.get('event-province', ''), + 'signatory_name': occ.get('signatory-name', ''), + }) + + if vals_list: + self.env['fusion.cp.tracking.event'].create(vals_list) + + self.last_tracking_update = fields.Datetime.now() + self._update_status_from_tracking(detail) + self.message_post( + body=_("Tracking refreshed: %d events loaded.") % len(vals_list)) + + def _update_status_from_tracking(self, detail): + """Auto-update shipment status based on tracking data.""" + if self.status == 'cancelled': + return + + delivered_date = detail.get('actual-delivery-date', '') + if delivered_date: + self.status = 'delivered' + try: + self.delivery_date = dt_mod.strptime( + delivered_date, '%Y-%m-%d') + except (ValueError, TypeError): + self.delivery_date = fields.Datetime.now() + elif self.status == 'confirmed' and self.tracking_event_ids: + self.status = 'shipped' + + # ── Void & Reissue ───────────────────────────────────── + + def action_void_shipment(self): + """Void this shipment via Canada Post API (DELETE endpoint).""" + self.ensure_one() + if self.status == 'cancelled': + raise ValidationError( + _("This shipment is already cancelled.")) + if not self.shipment_id: + raise ValidationError( + _("No CP shipment ID — cannot void.")) + + carrier = self.carrier_id + if not carrier: + raise ValidationError( + _("No carrier linked to this shipment.")) + + if carrier.prod_environment: + base = "https://soa-gw.canadapost.ca" + else: + base = "https://ct.soa-gw.canadapost.ca" + customer = carrier.customer_number + + if carrier.fusion_cp_type == 'commercial': + url = "%s/rs/%s/%s/shipment/%s" % ( + base, customer, customer, self.shipment_id) + accept_header = 'application/vnd.cpc.shipment-v8+xml' + else: + url = "%s/rs/%s/ncshipment/%s" % ( + base, customer, self.shipment_id) + accept_header = 'application/vnd.cpc.ncshipment-v4+xml' + + try: + resp = request( + method='DELETE', url=url, + headers={ + 'Accept': accept_header, + 'Accept-language': 'en-CA', + }, + auth=(carrier.username, carrier.password)) + _logger.info( + "Void Shipment %s → %s", url, resp.status_code) + + if resp.status_code == 204: + self.status = 'cancelled' + self.message_post( + body=_("Shipment voided successfully via " + "Canada Post API.")) + else: + # Parse CP error XML for a clean message + error_msg = self._parse_cp_error_response(resp) + except ValidationError: + raise + except Exception as e: + raise ValidationError( + _("Failed to void shipment: %s") % str(e)) + + def _parse_cp_error_response(self, resp): + """Parse a Canada Post error XML response and raise a clean + ValidationError with the human-readable description. + + If the response cannot be parsed, falls back to the raw text. + """ + description = '' + try: + root = etree.fromstring(resp.content) + # Strip namespace for easy tag matching + ns = {'cp': root.nsmap.get(None, '')} + if ns['cp']: + msgs = root.findall('.//cp:message', ns) + else: + msgs = root.findall('.//message') + parts = [] + for msg in msgs: + desc_el = msg.find( + 'cp:description', ns) if ns['cp'] else msg.find( + 'description') + if desc_el is not None and desc_el.text: + parts.append(desc_el.text.strip()) + if parts: + description = '\n'.join(parts) + except Exception: + description = '' + + if not description: + description = (resp.text[:500] + if resp.text else 'Unknown error') + + raise ValidationError( + _("Canada Post: %s") % description) + + def action_reissue_shipment(self): + """Void current shipment and create a new one.""" + self.ensure_one() + if self.status != 'cancelled': + self.action_void_shipment() + if not self.picking_id: + raise ValidationError( + _("No transfer linked — cannot reissue.")) + picking = self.picking_id + carrier = picking.carrier_id or self.carrier_id + if not carrier: + raise ValidationError( + _("No carrier found for reissue.")) + result = carrier.send_shipping(picking) + if result: + picking.carrier_tracking_ref = result[0].get( + 'tracking_number', '') + self.message_post( + body=_("Shipment reissued. See new shipment record.")) + + def action_track_on_canada_post(self): + """Open the Canada Post tracking website in a new tab.""" + self.ensure_one() + if not self.tracking_number: + raise ValidationError( + _("No tracking number available for this shipment.")) + base_link = ( + self.carrier_id.tracking_link + if self.carrier_id and self.carrier_id.tracking_link + else 'https://www.canadapost.ca/trackweb/en#/resultList?searchFor=') + return { + 'type': 'ir.actions.act_url', + 'url': '%s%s' % (base_link, self.tracking_number), + 'target': 'new', + } diff --git a/fusion_canada_post/models/fusion_cp_tracking_event.py b/fusion_canada_post/models/fusion_cp_tracking_event.py new file mode 100644 index 00000000..4ba85709 --- /dev/null +++ b/fusion_canada_post/models/fusion_cp_tracking_event.py @@ -0,0 +1,44 @@ +from odoo import models, fields + + +class FusionCpTrackingEvent(models.Model): + _name = 'fusion.cp.tracking.event' + _description = 'Canada Post Tracking Event' + _order = 'event_datetime desc, id desc' + _rec_name = 'event_description' + + shipment_id = fields.Many2one( + 'fusion.cp.shipment', + string='Shipment', + required=True, + ondelete='cascade', + index=True, + ) + event_date = fields.Date( + string='Event Date', + ) + event_time = fields.Char( + string='Event Time', + help='Time from Canada Post (HH:MM:SS)', + ) + event_datetime = fields.Datetime( + string='Date/Time', + help='Combined date and time for sorting', + ) + event_description = fields.Char( + string='Description', + ) + event_type = fields.Char( + string='Event Type', + help='Canada Post event type code', + ) + event_site = fields.Char( + string='Location', + ) + event_province = fields.Char( + string='Province', + ) + signatory_name = fields.Char( + string='Signatory', + help='Name of person who signed for delivery', + ) diff --git a/fusion_canada_post/models/product_packaging.py b/fusion_canada_post/models/product_packaging.py new file mode 100644 index 00000000..cc8d7f1b --- /dev/null +++ b/fusion_canada_post/models/product_packaging.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class PackageType(models.Model): + _inherit = "stock.package.type" + + package_carrier_type = fields.Selection([('fusion_canada_post', 'Canada Post')]) diff --git a/fusion_canada_post/models/res_company.py b/fusion_canada_post/models/res_company.py new file mode 100644 index 00000000..461fd1ca --- /dev/null +++ b/fusion_canada_post/models/res_company.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields + +class ResCompany(models.Model): + _inherit = "res.company" + + def _default_uom_setting(self): + weight_uom_id = self.env.ref('uom.product_uom_kgm', raise_if_not_found=False) + if not weight_uom_id: + uom_categ_id = self.env.ref('uom.product_uom_categ_kgm').id + weight_uom_id = self.env['uom.uom'].search([('category_id', '=', uom_categ_id), ('factor', '=', 1)], limit=1) + return weight_uom_id + + + weight_unit_of_measurement_id = fields.Many2one('uom.uom',string="Weight Unit Of Measurement",help="Unit of measure for item weight",default=_default_uom_setting) diff --git a/fusion_canada_post/models/sale_order.py b/fusion_canada_post/models/sale_order.py new file mode 100644 index 00000000..36c32c2d --- /dev/null +++ b/fusion_canada_post/models/sale_order.py @@ -0,0 +1,57 @@ +from odoo import models, fields + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + fusion_cp_service_code = fields.Char( + string='CP Service Code', + copy=False, + help='Canada Post service code selected during shipping method selection', + ) + # Per-package dimensions & service info (replaces single-dim fields). + fusion_cp_package_ids = fields.One2many( + 'fusion.cp.order.package', + 'sale_order_id', + string='CP Packages', + copy=False, + ) + + # Legacy single-package dimension fields (kept for backward + # compatibility with existing orders — new orders populate the + # One2many above and fill these from the first package). + fusion_cp_package_length = fields.Float( + string='Package Length', copy=False) + fusion_cp_package_width = fields.Float( + string='Package Width', copy=False) + fusion_cp_package_height = fields.Float( + string='Package Height', copy=False) + + fusion_cp_shipment_count = fields.Integer( + string='CP Shipments', + compute='_compute_fusion_cp_shipment_count', + ) + + def _compute_fusion_cp_shipment_count(self): + Shipment = self.env['fusion.cp.shipment'] + for order in self: + order.fusion_cp_shipment_count = Shipment.search_count( + [('sale_order_id', '=', order.id)] + ) + + def action_view_fusion_cp_shipments(self): + self.ensure_one() + shipments = self.env['fusion.cp.shipment'].search( + [('sale_order_id', '=', self.id)] + ) + action = { + 'type': 'ir.actions.act_window', + 'name': 'Canada Post Shipments', + 'res_model': 'fusion.cp.shipment', + 'view_mode': 'list,form', + 'domain': [('sale_order_id', '=', self.id)], + } + if len(shipments) == 1: + action['view_mode'] = 'form' + action['res_id'] = shipments.id + return action diff --git a/fusion_canada_post/models/stock_picking.py b/fusion_canada_post/models/stock_picking.py new file mode 100644 index 00000000..e4e7794d --- /dev/null +++ b/fusion_canada_post/models/stock_picking.py @@ -0,0 +1,34 @@ +from odoo import models, fields + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + fusion_cp_shipment_count = fields.Integer( + string='CP Shipments', + compute='_compute_fusion_cp_shipment_count', + ) + + def _compute_fusion_cp_shipment_count(self): + Shipment = self.env['fusion.cp.shipment'] + for picking in self: + picking.fusion_cp_shipment_count = Shipment.search_count( + [('picking_id', '=', picking.id)] + ) + + def action_view_fusion_cp_shipments(self): + self.ensure_one() + shipments = self.env['fusion.cp.shipment'].search( + [('picking_id', '=', self.id)] + ) + action = { + 'type': 'ir.actions.act_window', + 'name': 'Canada Post Shipments', + 'res_model': 'fusion.cp.shipment', + 'view_mode': 'list,form', + 'domain': [('picking_id', '=', self.id)], + } + if len(shipments) == 1: + action['view_mode'] = 'form' + action['res_id'] = shipments.id + return action diff --git a/fusion_canada_post/pyproject.toml b/fusion_canada_post/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/fusion_canada_post/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fusion_canada_post/security/ir.model.access.csv b/fusion_canada_post/security/ir.model.access.csv new file mode 100644 index 00000000..cf8cd538 --- /dev/null +++ b/fusion_canada_post/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_cp_shipment_user,fusion.cp.shipment.user,model_fusion_cp_shipment,base.group_user,1,0,0,0 +access_fusion_cp_shipment_stock_user,fusion.cp.shipment.stock.user,model_fusion_cp_shipment,stock.group_stock_user,1,1,1,0 +access_fusion_cp_shipment_manager,fusion.cp.shipment.manager,model_fusion_cp_shipment,stock.group_stock_manager,1,1,1,1 +access_fusion_cp_tracking_event_user,fusion.cp.tracking.event.user,model_fusion_cp_tracking_event,base.group_user,1,0,0,0 +access_fusion_cp_tracking_event_stock_user,fusion.cp.tracking.event.stock.user,model_fusion_cp_tracking_event,stock.group_stock_user,1,1,1,0 +access_fusion_cp_tracking_event_manager,fusion.cp.tracking.event.manager,model_fusion_cp_tracking_event,stock.group_stock_manager,1,1,1,1 +access_choose_delivery_cp_rate_user,choose.delivery.cp.rate.user,model_choose_delivery_cp_rate,base.group_user,1,1,1,1 +access_choose_delivery_cp_package_user,choose.delivery.cp.package.user,model_choose_delivery_cp_package,base.group_user,1,1,1,1 +access_fusion_cp_order_package_user,fusion.cp.order.package.user,model_fusion_cp_order_package,base.group_user,1,0,0,0 +access_fusion_cp_order_package_stock_user,fusion.cp.order.package.stock.user,model_fusion_cp_order_package,stock.group_stock_user,1,1,1,0 +access_fusion_cp_order_package_manager,fusion.cp.order.package.manager,model_fusion_cp_order_package,stock.group_stock_manager,1,1,1,1 diff --git a/fusion_canada_post/static/description/S1.png b/fusion_canada_post/static/description/S1.png new file mode 100644 index 00000000..616b44d8 Binary files /dev/null and b/fusion_canada_post/static/description/S1.png differ diff --git a/fusion_canada_post/static/description/S2.png b/fusion_canada_post/static/description/S2.png new file mode 100644 index 00000000..63aabb19 Binary files /dev/null and b/fusion_canada_post/static/description/S2.png differ diff --git a/fusion_canada_post/static/description/S3.png b/fusion_canada_post/static/description/S3.png new file mode 100644 index 00000000..e471bfc6 Binary files /dev/null and b/fusion_canada_post/static/description/S3.png differ diff --git a/fusion_canada_post/static/description/canada_post_banner.gif b/fusion_canada_post/static/description/canada_post_banner.gif new file mode 100644 index 00000000..37ebfc25 Binary files /dev/null and b/fusion_canada_post/static/description/canada_post_banner.gif differ diff --git a/fusion_canada_post/static/description/custom.css b/fusion_canada_post/static/description/custom.css new file mode 100644 index 00000000..221547a5 --- /dev/null +++ b/fusion_canada_post/static/description/custom.css @@ -0,0 +1,95 @@ +html { + padding: 50px; + font-family: sans-serif; +} +html h1 { + text-align: center; +} +html h2 { + text-align: center; + color: grey; +} +#exampleSlider { + position: relative; +} +@media (max-width: 767px) { + #exampleSlider { + border-color: transparent; + } +} +#exampleSlider .MS-content { + margin: 15px 5%; + overflow: hidden; + white-space: nowrap; + border: 1px solid red; +} +@media (max-width: 767px) { + #exampleSlider .MS-content { + margin: 0; + } +} +#exampleSlider .MS-content .item { + display: inline-block; + height: 30%; + overflow: hidden; + position: relative; + vertical-align: top; + border: 1px solid green; + border-right: none; + width: 33.33%; +} +@media (max-width: 1200px) { + #exampleSlider .MS-content .item { + width: 25%; + } +} +@media (max-width: 992px) { + #exampleSlider .MS-content .item { + width: 33.3333%; + } +} +@media (max-width: 767px) { + #exampleSlider .MS-content .item { + width: 50%; + } +} +#exampleSlider .MS-content .item p { + font-size: 30px; + text-align: center; + line-height: 1; + vertical-align: middle; + margin: 0; + padding: 10px 0; +} +#exampleSlider .MS-controls button { + position: absolute; + border: none; + background: transparent; + font-size: 30px; + outline: 0; + top: 35px; +} +@media (max-width: 767px) { + #exampleSlider .MS-controls button { + display: none; + } +} +#exampleSlider .MS-controls button:hover { + cursor: pointer; +} +#exampleSlider .MS-controls .MS-left { + left: 10px; +} +@media (max-width: 992px) { + #exampleSlider .MS-controls .MS-left { + left: -2px; + } +} +#exampleSlider .MS-controls .MS-right { + right: 10px; +} +@media (max-width: 992px) { + #exampleSlider .MS-controls .MS-right { + right: -2px; + } +} diff --git a/fusion_canada_post/static/description/dash.png b/fusion_canada_post/static/description/dash.png new file mode 100644 index 00000000..c576708b Binary files /dev/null and b/fusion_canada_post/static/description/dash.png differ diff --git a/fusion_canada_post/static/description/icon.png b/fusion_canada_post/static/description/icon.png new file mode 100644 index 00000000..6025652a Binary files /dev/null and b/fusion_canada_post/static/description/icon.png differ diff --git a/fusion_canada_post/static/description/images/icons_live_price.png b/fusion_canada_post/static/description/images/icons_live_price.png new file mode 100644 index 00000000..57194a52 Binary files /dev/null and b/fusion_canada_post/static/description/images/icons_live_price.png differ diff --git a/fusion_canada_post/static/description/images/icons_shipping_label.png b/fusion_canada_post/static/description/images/icons_shipping_label.png new file mode 100644 index 00000000..a7fb1e35 Binary files /dev/null and b/fusion_canada_post/static/description/images/icons_shipping_label.png differ diff --git a/fusion_canada_post/static/description/images/icons_track_and_trace.png b/fusion_canada_post/static/description/images/icons_track_and_trace.png new file mode 100644 index 00000000..1bae861b Binary files /dev/null and b/fusion_canada_post/static/description/images/icons_track_and_trace.png differ diff --git a/fusion_canada_post/static/description/index.html b/fusion_canada_post/static/description/index.html new file mode 100644 index 00000000..27cbcc44 --- /dev/null +++ b/fusion_canada_post/static/description/index.html @@ -0,0 +1,173 @@ + + + + + + + Canada Post Shipping Integration with Odoo + + + + + + + + + + + +
+
+
+
+ +

+ Odoo Canada Post Shipping Integration

+ +
+
+
+ +
+
+
+

+ Why Canada Post Odoo Shipping Integration? +

+

+ Integrate Canada Post to streamline shipping processes by obtaining real-time courier quotes for + various services, generating shipping labels based on order details, and providing tracking + numbers with + links for shipment updates + ensuring a seamless and efficient logistics workflow +

+
+
+
+ +
+
+

+ Features +

+
+
+
+
+
+
+

Live Shipping + Price

+

Get live price to ensure the best price for + your shipment

+
+
+
+
+
+
+
+
+

Shipping + Label

+

Generate shipping label using order information

+
+
+
+
+
+
+
+
+
+
+

Track & + Trace

+

Live track your shipment using the Canada + Post tracking number

+
+
+
+
+
+
+ + +
+
+

Canada Post Shipping Configuration

+
+ +
+
+
+ +
+
+

Create Sale Order With Canada Post Delivery Method

+
+ +
+
+
+ +
+
+

Generate Shipment With Tracking Number And Shipping Label

+
+ +
+
+
+ +
+
+

Generated Shipment Label

+
+ +
+
+
+ + +
+
+

+ Contact Us +

+
+ Suggestions & Feedback to: support@nexasystems.ca +
+
+
+
+
+ + up-arrow + +
+
+ + diff --git a/fusion_canada_post/static/description/label.png b/fusion_canada_post/static/description/label.png new file mode 100644 index 00000000..6ac340ba Binary files /dev/null and b/fusion_canada_post/static/description/label.png differ diff --git a/fusion_canada_post/static/description/up_arrow.png b/fusion_canada_post/static/description/up_arrow.png new file mode 100644 index 00000000..630bd3fb Binary files /dev/null and b/fusion_canada_post/static/description/up_arrow.png differ diff --git a/fusion_canada_post/views/choose_delivery_carrier_views.xml b/fusion_canada_post/views/choose_delivery_carrier_views.xml new file mode 100644 index 00000000..93824fd1 --- /dev/null +++ b/fusion_canada_post/views/choose_delivery_carrier_views.xml @@ -0,0 +1,89 @@ + + + + + choose.delivery.carrier.form.cp + choose.delivery.carrier + + + + + + + + + + + + delivery_type in ('fixed', 'base_on_rule', 'fusion_canada_post') + + + + + + + + + + +
+ Packages + + + + + + + + + + + + +
+ + +
+ Select a Service + + + + + + + +
+
+
+
+ +
diff --git a/fusion_canada_post/views/delivery_carrier_view.xml b/fusion_canada_post/views/delivery_carrier_view.xml new file mode 100644 index 00000000..14e90c95 --- /dev/null +++ b/fusion_canada_post/views/delivery_carrier_view.xml @@ -0,0 +1,77 @@ + + + + delivery.carrier.form.view.fusion.canada.post + delivery.carrier + + + + + + delivery_type == 'fusion_canada_post' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_canada_post/views/fusion_cp_shipment_views.xml b/fusion_canada_post/views/fusion_cp_shipment_views.xml new file mode 100644 index 00000000..aa941d0b --- /dev/null +++ b/fusion_canada_post/views/fusion_cp_shipment_views.xml @@ -0,0 +1,266 @@ + + + + + + fusion.cp.shipment.search + fusion.cp.shipment + + + + + + + + + + + + + + + + + + + + + + + + fusion.cp.shipment.list + fusion.cp.shipment + + + + + + + + + + + + + + + + + + + + fusion.cp.shipment.form + fusion.cp.shipment + +
+
+
+ +
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Printable Label (4x6) + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/fusion_canada_post/views/stock_picking_views.xml b/fusion_canada_post/views/stock_picking_views.xml new file mode 100644 index 00000000..0ac55ad2 --- /dev/null +++ b/fusion_canada_post/views/stock_picking_views.xml @@ -0,0 +1,18 @@ + + + + stock.picking.form.fusion.cp + stock.picking + + + + + + + + diff --git a/fusion_canada_post/wizard/__init__.py b/fusion_canada_post/wizard/__init__.py new file mode 100644 index 00000000..f6036921 --- /dev/null +++ b/fusion_canada_post/wizard/__init__.py @@ -0,0 +1 @@ +from . import choose_delivery_cp_rate diff --git a/fusion_canada_post/wizard/choose_delivery_cp_rate.py b/fusion_canada_post/wizard/choose_delivery_cp_rate.py new file mode 100644 index 00000000..4dbe8cae --- /dev/null +++ b/fusion_canada_post/wizard/choose_delivery_cp_rate.py @@ -0,0 +1,446 @@ +import json + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class ChooseDeliveryCPPackage(models.TransientModel): + """One package row in the Add Shipping wizard for Canada Post.""" + + _name = 'choose.delivery.cp.package' + _description = 'Canada Post Package (Wizard)' + _order = 'sequence, id' + + wizard_id = fields.Many2one( + 'choose.delivery.carrier', + string='Wizard', + ondelete='cascade', + ) + sequence = fields.Integer(default=10) + package_type_id = fields.Many2one( + 'stock.package.type', + string='Box Type', + domain="[('package_carrier_type', '=', 'fusion_canada_post')]", + ) + package_length = fields.Float(string='Length') + package_width = fields.Float(string='Width') + package_height = fields.Float(string='Height') + weight = fields.Float(string='Weight') + + # Per-package cost for the selected service (updated on service select) + selected_price = fields.Float( + string='Cost', digits='Product Price', readonly=True) + currency_id = fields.Many2one( + 'res.currency', + related='wizard_id.currency_id', + ) + + @api.onchange('package_type_id') + def _onchange_package_type_id(self): + """Pre-fill dimensions from selected box type.""" + if self.package_type_id: + self.package_length = self.package_type_id.packaging_length + self.package_width = self.package_type_id.width + self.package_height = self.package_type_id.height + + +class ChooseDeliveryCPRate(models.TransientModel): + _name = 'choose.delivery.cp.rate' + _description = 'Canada Post Rate Option' + + wizard_id = fields.Many2one( + 'choose.delivery.carrier', + string='Wizard', + ondelete='cascade', + ) + service_code = fields.Char(string='Service Code') + service_name = fields.Char(string='Service') + price = fields.Float(string='Shipping Cost', digits='Product Price') + expected_delivery = fields.Char(string='Expected Delivery Date') + is_selected = fields.Boolean(string='Selected', default=False) + currency_id = fields.Many2one( + 'res.currency', + related='wizard_id.currency_id', + ) + # JSON: [{"pkg_id": , "price": }, ...] + per_package_prices = fields.Text(string='Per-Package Prices') + + def action_select(self): + """Select this rate and deselect others. Update per-package costs.""" + self.ensure_one() + # Deselect all, then select this one + self.wizard_id.fusion_cp_rate_ids.write({'is_selected': False}) + self.is_selected = True + + # Update per-package costs from stored JSON + if self.per_package_prices: + try: + pkg_prices = json.loads(self.per_package_prices) + for pp in pkg_prices: + pkg = self.env['choose.delivery.cp.package'].browse( + pp['pkg_id']) + if pkg.exists(): + pkg.selected_price = pp['price'] + except (json.JSONDecodeError, KeyError): + pass + + # Apply margin from the carrier + carrier = self.wizard_id.carrier_id + price = self.price + if carrier: + price = carrier._apply_margins(price, self.wizard_id.order_id) + # Check free_over + if carrier.free_over: + order = self.wizard_id.order_id + amount = order.currency_id._convert( + order.amount_untaxed, + order.company_id.currency_id, + order.company_id, + fields.Date.today(), + ) + if amount >= carrier.amount: + price = 0.0 + + self.wizard_id.write({ + 'delivery_price': price, + 'display_price': price, + 'fusion_cp_selected_service': self.service_code, + 'fusion_cp_selected_service_name': self.service_name, + 'fusion_cp_selected_expected_delivery': self.expected_delivery, + }) + # Re-open the wizard to show updated selection + return { + 'name': _('Add a shipping method'), + 'type': 'ir.actions.act_window', + 'res_model': 'choose.delivery.carrier', + 'res_id': self.wizard_id.id, + 'view_mode': 'form', + 'target': 'new', + } + + +class ChooseDeliveryCarrier(models.TransientModel): + _inherit = 'choose.delivery.carrier' + + fusion_cp_rate_ids = fields.One2many( + 'choose.delivery.cp.rate', + 'wizard_id', + string='Available Services', + ) + fusion_cp_selected_service = fields.Char( + string='Selected CP Service Code', + ) + fusion_cp_selected_service_name = fields.Char( + string='Selected CP Service Name', + ) + fusion_cp_selected_expected_delivery = fields.Char( + string='Selected CP Expected Delivery', + ) + + # ── Package list ── + fusion_cp_package_ids = fields.One2many( + 'choose.delivery.cp.package', + 'wizard_id', + string='Packages', + ) + + # ── Unit labels ── + fusion_cp_dimension_unit_label = fields.Char( + string='Dimension Unit', + compute='_compute_fusion_cp_dimension_unit_label', + ) + fusion_cp_weight_unit_label = fields.Char( + string='Weight Unit', + compute='_compute_fusion_cp_weight_unit_label', + ) + + @api.depends('carrier_id') + def _compute_fusion_cp_dimension_unit_label(self): + for rec in self: + if (rec.carrier_id + and rec.carrier_id.delivery_type == 'fusion_canada_post'): + rec.fusion_cp_dimension_unit_label = ( + rec.carrier_id.fusion_cp_dimension_unit or 'cm') + else: + rec.fusion_cp_dimension_unit_label = '' + + @api.depends('carrier_id') + def _compute_fusion_cp_weight_unit_label(self): + for rec in self: + if (rec.carrier_id + and rec.carrier_id.delivery_type == 'fusion_canada_post'): + uom = (rec.order_id.company_id + .weight_unit_of_measurement_id) + rec.fusion_cp_weight_unit_label = uom.name if uom else 'kg' + else: + rec.fusion_cp_weight_unit_label = '' + + @api.onchange('carrier_id') + def _onchange_carrier_id_cp_packages(self): + """When a CP carrier is selected, create one default package.""" + if (self.carrier_id + and self.carrier_id.delivery_type == 'fusion_canada_post' + and not self.fusion_cp_package_ids): + vals = { + 'sequence': 10, + 'weight': self.total_weight or 0.0, + } + # Pre-fill from default package type if set on carrier + if self.carrier_id.product_packaging_id: + pkg = self.carrier_id.product_packaging_id + vals['package_type_id'] = pkg.id + vals['package_length'] = pkg.packaging_length + vals['package_width'] = pkg.width + vals['package_height'] = pkg.height + self.fusion_cp_package_ids = [(5, 0, 0), (0, 0, vals)] + + # ── Rate fetching ── + + def update_price(self): + """Override: for Canada Post, fetch all rates for all packages.""" + if self.carrier_id.delivery_type == 'fusion_canada_post': + return self._update_cp_rates() + return super().update_price() + + def _get_cp_package_info_for_pkg(self, pkg): + """Build package_info dict for a single package, converted to cm.""" + carrier = self.carrier_id + return { + 'length': round(carrier._fusion_cp_convert_dimension_to_cm( + pkg.package_length), 1), + 'width': round(carrier._fusion_cp_convert_dimension_to_cm( + pkg.package_width), 1), + 'height': round(carrier._fusion_cp_convert_dimension_to_cm( + pkg.package_height), 1), + } + + def _update_cp_rates(self): + """Fetch CP service rates for every package and aggregate.""" + carrier = self.carrier_id + packages = self.fusion_cp_package_ids + + if not packages: + raise UserError(_( + "Please add at least one package with dimensions.")) + + from_unit = (self.order_id.company_id + .weight_unit_of_measurement_id) + + # ── Validate every package ── + for pkg in packages: + if not (pkg.package_length and pkg.package_width + and pkg.package_height): + raise UserError(_( + "Please enter dimensions (L × W × H) " + "for all packages.")) + if not pkg.weight: + raise UserError(_( + "Please enter weight for all packages.")) + package_info = self._get_cp_package_info_for_pkg(pkg) + weight_kg = pkg.weight + if from_unit: + weight_kg = round(carrier.convert_weight( + from_unit, carrier.weight_uom_id, weight_kg), 2) + carrier._fusion_cp_validate_package(weight_kg, package_info) + + # ── Clear old rates ── + self.fusion_cp_rate_ids.unlink() + + # ── Fetch rates per package ── + # {service_code: {service_name, packages: [{pkg_id, price, exp}]}} + all_service_rates = {} + + for pkg in packages: + package_info = self._get_cp_package_info_for_pkg(pkg) + carrier_ctx = carrier.with_context( + order_weight=pkg.weight, # raw, in company UOM + cp_package_info=package_info, + ) + rates = carrier_ctx.fusion_canada_post_rate_shipment_all( + self.order_id) + + if isinstance(rates, dict) and rates.get('error_message'): + raise UserError(rates['error_message']) + + if not rates: + raise UserError(_( + "No shipping prices available for this order.")) + + for rate in rates: + code = rate['service_code'] + if code not in all_service_rates: + all_service_rates[code] = { + 'service_name': rate['service_name'], + 'packages': [], + } + all_service_rates[code]['packages'].append({ + 'pkg_id': pkg.id, + 'price': rate['price'], + 'expected_delivery': rate.get( + 'expected_delivery', ''), + }) + + # ── Keep only services available for ALL packages ── + num_packages = len(packages) + available = { + code: data + for code, data in all_service_rates.items() + if len(data['packages']) == num_packages + } + + if not available: + raise UserError(_( + "No single shipping service covers all packages. " + "Try adjusting package dimensions or weight.")) + + # ── Find cheapest service ── + cheapest_code = min( + available.keys(), + key=lambda c: sum( + p['price'] for p in available[c]['packages'])) + + # ── Create combined rate lines ── + vals_list = [] + for code, data in available.items(): + total_price = sum(p['price'] for p in data['packages']) + expected_dates = [ + p['expected_delivery'] for p in data['packages'] + if p['expected_delivery'] + ] + expected = max(expected_dates) if expected_dates else '' + is_sel = (code == cheapest_code) + vals_list.append({ + 'wizard_id': self.id, + 'service_code': code, + 'service_name': data['service_name'], + 'price': total_price, + 'expected_delivery': expected, + 'is_selected': is_sel, + 'per_package_prices': json.dumps(data['packages']), + }) + + self.env['choose.delivery.cp.rate'].create(vals_list) + + # ── Auto-select cheapest: update packages and wizard ── + cheapest_data = available[cheapest_code] + selected_price = sum( + p['price'] for p in cheapest_data['packages']) + + for pkg_data in cheapest_data['packages']: + pkg_rec = packages.filtered( + lambda p, pid=pkg_data['pkg_id']: p.id == pid) + if pkg_rec: + pkg_rec.selected_price = pkg_data['price'] + + # Apply carrier margins + price = selected_price + if self.carrier_id: + price = self.carrier_id._apply_margins( + price, self.order_id) + if self.carrier_id.free_over: + amount = self.order_id.currency_id._convert( + self.order_id.amount_untaxed, + self.order_id.company_id.currency_id, + self.order_id.company_id, + fields.Date.today(), + ) + if amount >= self.carrier_id.amount: + price = 0.0 + + expected_dates = [ + p['expected_delivery'] for p in cheapest_data['packages'] + if p['expected_delivery'] + ] + + self.write({ + 'delivery_price': price, + 'display_price': price, + 'fusion_cp_selected_service': cheapest_code, + 'fusion_cp_selected_service_name': ( + cheapest_data['service_name']), + 'fusion_cp_selected_expected_delivery': ( + max(expected_dates) if expected_dates else ''), + }) + + return { + 'name': _('Add a shipping method'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'choose.delivery.carrier', + 'res_id': self.id, + 'target': 'new', + } + + # ── Confirm ── + + def button_confirm(self): + """Override: store per-package info on the sale order and enhance + the delivery line description.""" + if self.carrier_id.delivery_type == 'fusion_canada_post': + order = self.order_id + + # Clear previous package records + order.fusion_cp_package_ids.unlink() + + selected_code = self.fusion_cp_selected_service or '' + selected_name = self.fusion_cp_selected_service_name or '' + selected_delivery = ( + self.fusion_cp_selected_expected_delivery or '') + + pkg_vals = [] + for idx, pkg in enumerate( + self.fusion_cp_package_ids.sorted('sequence')): + pkg_vals.append((0, 0, { + 'sequence': (idx + 1) * 10, + 'package_type_id': ( + pkg.package_type_id.id + if pkg.package_type_id else False), + 'package_length': pkg.package_length, + 'package_width': pkg.package_width, + 'package_height': pkg.package_height, + 'weight': pkg.weight, + 'service_code': selected_code, + 'service_name': selected_name, + 'price': pkg.selected_price, + 'expected_delivery': selected_delivery, + })) + + write_vals = { + 'fusion_cp_package_ids': pkg_vals, + 'fusion_cp_service_code': selected_code, + } + + # Backward compat: first-package dims in legacy fields + first_pkg = self.fusion_cp_package_ids.sorted('sequence')[:1] + if first_pkg: + write_vals['fusion_cp_package_length'] = ( + first_pkg.package_length) + write_vals['fusion_cp_package_width'] = ( + first_pkg.package_width) + write_vals['fusion_cp_package_height'] = ( + first_pkg.package_height) + + order.write(write_vals) + + res = super().button_confirm() + + # Enhance delivery line description with service details + if (self.carrier_id.delivery_type == 'fusion_canada_post' + and self.fusion_cp_selected_service_name): + delivery_line = self.order_id.order_line.filtered( + 'is_delivery') + if delivery_line: + line = delivery_line[-1] + parts = [line.name] + parts.append( + "Service: %s" + % self.fusion_cp_selected_service_name) + num_pkgs = len(self.fusion_cp_package_ids) + if num_pkgs > 1: + parts.append("Packages: %d" % num_pkgs) + if self.fusion_cp_selected_expected_delivery: + parts.append( + "Expected Delivery: %s" + % self.fusion_cp_selected_expected_delivery) + line.name = '\n'.join(parts) + return res diff --git a/fusion_claims/models/sale_order_line.py b/fusion_claims/models/sale_order_line.py index 99b6e8be..5bc61a80 100644 --- a/fusion_claims/models/sale_order_line.py +++ b/fusion_claims/models/sale_order_line.py @@ -29,10 +29,11 @@ class SaleOrderLine(models.Model): @api.depends('product_id', 'product_id.default_code') def _compute_adp_device_type(self): - """Compute ADP device type from the product's device code.""" + """Compute ADP device type and build type from the product's device code.""" ADPDevice = self.env['fusion.adp.device.code'].sudo() for line in self: device_type = '' + build_type = False if line.product_id: # Get the device code from product (default_code or custom field) device_code = line._get_adp_device_code() @@ -44,7 +45,9 @@ class SaleOrderLine(models.Model): ], limit=1) if adp_device: device_type = adp_device.device_type or '' + build_type = adp_device.build_type or False line.x_fc_adp_device_type = device_type + line.x_fc_adp_build_type = build_type # ========================================================================== # SERIAL NUMBER AND DEVICE PLACEMENT @@ -110,6 +113,16 @@ class SaleOrderLine(models.Model): store=True, help='Device type from ADP mobility manual (for approval matching)', ) + x_fc_adp_build_type = fields.Selection( + selection=[ + ('modular', 'Modular'), + ('custom_fabricated', 'Custom Fabricated'), + ], + string='Build Type', + compute='_compute_adp_device_type', + store=True, + help='Build type from ADP mobility manual (Modular or Custom Fabricated)', + ) # ========================================================================== # COMPUTED ADP PORTIONS diff --git a/fusion_claims/views/sale_order_views.xml b/fusion_claims/views/sale_order_views.xml index 8cbdae35..bcde3d2b 100644 --- a/fusion_claims/views/sale_order_views.xml +++ b/fusion_claims/views/sale_order_views.xml @@ -2178,6 +2178,7 @@ help="Device approved by ADP"/> + @@ -2410,9 +2411,11 @@ hide - + + diff --git a/fusion_quotations/static/src/css/quotation_form.css b/fusion_quotations/static/src/css/quotation_form.css index 56b81dac..19f04bcb 100644 --- a/fusion_quotations/static/src/css/quotation_form.css +++ b/fusion_quotations/static/src/css/quotation_form.css @@ -199,6 +199,204 @@ word-wrap: break-word; } +/* ── Compact option cards (seating tabs) ── */ +.wc-option-compact { + padding: 0.45rem 0.6rem !important; + border-radius: 0.375rem !important; + border: 1px solid #e0e0e0 !important; + background: #fff; + transition: border-color 0.15s, background-color 0.15s; +} + +.wc-option-compact:hover { + border-color: #adb5bd !important; + background: #f8f9fa; +} + +.wc-option-compact .form-check-input:checked ~ .form-check-label { + color: #0d6efd; +} + +.wc-option-card.wc-option-compact .form-check-input:checked { + /* parent card highlight when checked */ +} + +.wc-options-grid .col-md-4 { + padding-bottom: 0.15rem; +} + +.wc-option-compact .form-check { + min-height: auto; +} + +.wc-option-compact .form-check-input { + width: 0.9em; + height: 0.9em; + margin-top: 0.2em; +} + +.wc-option-compact .wc-opt-label { + font-size: 0.78rem; + font-weight: 500; + line-height: 1.3; + display: block; +} + +.wc-option-compact .wc-opt-meta { + font-size: 0.7rem; + line-height: 1.2; + margin-left: 1.35em; + margin-top: 0.15rem; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.wc-option-compact .wc-opt-adp { + font-size: 0.7rem; +} + +/* Checked card highlight */ +.wc-option-compact:has(.form-check-input:checked) { + border-color: #198754 !important; + background: #f0faf4; +} + +/* ── Collapsible section groups (accordion within tab) ── */ +.wc-section-groups { + display: flex; + flex-direction: column; + gap: 0; +} + +.wc-section-group { + border: 1px solid #dee2e6; + border-radius: 0.5rem; + margin-bottom: 0.5rem; + overflow: hidden; +} + +.wc-group-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.65rem 0.85rem; + border: none; + background: #f0f0f0; + color: #333; + font-size: 0.85rem; + font-weight: 600; + text-align: left; + cursor: pointer; + transition: background-color 0.15s; +} + +.wc-group-header:hover { + background: #e2e2e2; +} + +.wc-section-group.open > .wc-group-header { + background: #2c3e50; + color: #fff; +} + +.wc-section-group.open > .wc-group-header:hover { + background: #34495e; +} + +.wc-group-icon { + width: 18px; + text-align: center; + font-size: 0.9rem; + flex-shrink: 0; +} + +.wc-group-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wc-group-count { + display: none; + font-size: 0.65rem; + font-weight: 700; + background: #fff; + color: #2c3e50; + border-radius: 999px; + min-width: 20px; + height: 20px; + line-height: 20px; + text-align: center; + padding: 0 6px; + flex-shrink: 0; +} + +.wc-section-group.has-selection > .wc-group-header .wc-group-count { + display: inline-block; +} + +/* When closed + has selections: green accent */ +.wc-section-group.has-selection:not(.open) > .wc-group-header { + background: #d1e7dd; + color: #0f5132; +} + +.wc-section-group.has-selection:not(.open) > .wc-group-header .wc-group-count { + background: #198754; + color: #fff; +} + +.wc-group-chevron { + font-size: 0.7rem; + transition: transform 0.2s; + flex-shrink: 0; +} + +.wc-section-group.open > .wc-group-header .wc-group-chevron { + transform: rotate(180deg); +} + +.wc-group-body { + display: none; + padding: 0.75rem; +} + +.wc-section-group.open > .wc-group-body { + display: block; +} + +/* ── Real-time filter ── */ +.wc-option-filter-wrap { + max-width: 300px; +} + +.wc-option-filter-wrap .input-group-text { + background: transparent; + border-right: none; + color: var(--bs-secondary-color); + padding: 0.25rem 0.5rem; +} + +.wc-option-filter-wrap .wc-option-filter { + border-left: none; + font-size: 0.8rem; + padding: 0.25rem 0.5rem; +} + +.wc-option-filter-wrap .wc-option-filter:focus { + box-shadow: none; + border-color: var(--bs-primary); +} + +.wc-option-filter-wrap .wc-option-filter:focus + .input-group-text, +.wc-option-filter-wrap .input-group:focus-within .input-group-text { + border-color: var(--bs-primary); +} + /* ----------------------------------------------------------------- Search Containers — z-index must beat ALL sibling content below. position:relative creates a stacking context so the absolute @@ -321,37 +519,189 @@ } /* ----------------------------------------------------------------- - Accordion Sections (Step 4 seating) + Seating Side Tabs (responsive vertical tabs) + Desktop: side nav left + content right + Mobile: horizontal scrollable tabs on top + content below ----------------------------------------------------------------- */ -.wc-assessment-form .accordion-button:not(.collapsed) { - background-color: #e8f0fe; - color: #0d6efd; -} -.wc-assessment-form .accordion-item { - border-radius: 0.5rem !important; - margin-bottom: 0.75rem; - border: 1px solid #dee2e6 !important; +/* ── Container ── */ +.wc-seating-tabs { + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.75rem; overflow: hidden; + background: var(--bs-body-bg); } -.wc-assessment-form .accordion-item:last-child { - margin-bottom: 0; +.wc-seating-tabs > .row { + min-height: 400px; } -.wc-assessment-form .accordion-button { - border-radius: 0.5rem !important; +/* ── Tab Navigation Column ── */ +.wc-tab-nav-col { + background: var(--bs-tertiary-bg, #f8f9fa); + border-right: 1px solid var(--bs-border-color, #dee2e6); +} + +.wc-tab-nav { + padding: 0.5rem 0; + gap: 2px; +} + +/* ── Individual Tab Button ── */ +.wc-tab-btn { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.625rem 0.875rem; + border: none; + background: transparent; + color: var(--bs-body-color); + font-size: 0.82rem; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: background-color 0.15s, color 0.15s; + border-left: 3px solid transparent; + white-space: nowrap; +} + +.wc-tab-btn:hover { + background: rgba(var(--bs-primary-rgb), 0.06); +} + +.wc-tab-btn.active { + background: var(--bs-body-bg, #fff); + color: var(--bs-primary); font-weight: 600; + border-left-color: var(--bs-primary); + box-shadow: 1px 0 0 var(--bs-body-bg, #fff); } -.wc-assessment-form .accordion-button:not(.collapsed) { - border-radius: 0.5rem 0.5rem 0 0 !important; +.wc-tab-icon { + width: 18px; + text-align: center; + font-size: 0.9rem; + flex-shrink: 0; } -.wc-assessment-form .accordion-body { +.wc-tab-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Badge showing selected items count — hidden when 0 */ +.wc-tab-badge { + display: none; + font-size: 0.68rem; + font-weight: 700; + background: var(--bs-primary); + color: #fff; + border-radius: 999px; + min-width: 18px; + height: 18px; + line-height: 18px; + text-align: center; + padding: 0 5px; + flex-shrink: 0; +} + +.wc-tab-badge.has-items { + display: inline-block; +} + +/* ── Tab Content Column ── */ +.wc-tab-content-col { + min-height: 100%; +} + +.wc-tab-content { + padding: 1.25rem; position: relative; } +/* ── Tab Panels ── */ +.wc-tab-panel { + display: none; +} + +.wc-tab-panel.active { + display: block; +} + +.wc-tab-panel-title { + font-size: 1rem; + font-weight: 600; + margin-bottom: 1rem; + padding-bottom: 0.625rem; + border-bottom: 1px solid var(--bs-border-color, #dee2e6); + color: var(--bs-body-color); +} + +/* ── MOBILE: horizontal scrollable tabs ── */ +@media (max-width: 767.98px) { + .wc-seating-tabs > .row { + min-height: auto; + flex-direction: column; + } + + .wc-tab-nav-col { + border-right: none; + border-bottom: 1px solid var(--bs-border-color, #dee2e6); + width: 100%; + max-width: 100%; + flex: 0 0 auto; + } + + .wc-tab-nav { + flex-direction: row !important; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding: 0; + gap: 0; + } + + .wc-tab-nav::-webkit-scrollbar { + display: none; + } + + .wc-tab-btn { + flex-direction: column; + align-items: center; + gap: 0.25rem; + padding: 0.625rem 0.75rem; + min-width: 72px; + border-left: none; + border-bottom: 3px solid transparent; + font-size: 0.7rem; + text-align: center; + white-space: nowrap; + } + + .wc-tab-btn.active { + border-left-color: transparent; + border-bottom-color: var(--bs-primary); + box-shadow: none; + } + + .wc-tab-icon { + font-size: 1.1rem; + } + + .wc-tab-content-col { + width: 100%; + max-width: 100%; + flex: 1 1 auto; + } + + .wc-tab-content { + padding: 1rem 0.75rem; + } +} + /* ----------------------------------------------------------------- Review Table (Step 6) ----------------------------------------------------------------- */ diff --git a/fusion_quotations/static/src/js/quotation_form.js b/fusion_quotations/static/src/js/quotation_form.js index f21ed122..819bddbf 100644 --- a/fusion_quotations/static/src/js/quotation_form.js +++ b/fusion_quotations/static/src/js/quotation_form.js @@ -16,6 +16,9 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ 'input .wc-measurement-input': '_onMeasurementChange', 'change .wc-unit-select': '_onMeasurementChange', // equipment_type is now a hidden field — no change event needed + 'click .wc-tab-btn': '_onSeatingTabClick', + 'click .wc-group-header': '_onGroupHeaderClick', + 'input .wc-option-filter': '_onOptionFilter', 'change .wc-radio-btn input[type="radio"]': '_onRadioBtnChange', 'click #btnGenerate': '_onClickGenerate', 'submit #wcForm': '_onFormSubmit', @@ -52,6 +55,33 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ return '/my/quotation/api/' + endpoint; }, + /** + * Convert a string to Title Case. + * "ROHO HIGH-PROFILE CUSHION" → "Roho High-Profile Cushion" + */ + _toTitleCase: function (str) { + if (!str) return ''; + return str.toLowerCase().replace(/(?:^|\s|[-/])\S/g, function (c) { + return c.toUpperCase(); + }); + }, + + /** + * Strip leading bracketed codes like [SESND1045] or (SESND1045) from a + * product name AND strip trailing parenthesised ADP codes like (SE0001692). + * Returns the cleaned display name in Title Case. + */ + _cleanProductName: function (name) { + if (!name) return ''; + // Remove leading [CODE] or (CODE) + var cleaned = name.replace(/^\s*[\[(][A-Z0-9]+[\])]\s*/i, ''); + // Remove trailing (CODE) that matches an ADP-style pattern + cleaned = cleaned.replace(/\s*\([A-Z]{2,}[A-Z0-9]*\)\s*$/i, ''); + // Trim any leftover dashes/spaces + cleaned = cleaned.replace(/^\s*[-–—]\s*/, '').trim(); + return this._toTitleCase(cleaned); + }, + // ===================================================================== // INITIALIZATION // ===================================================================== @@ -315,8 +345,9 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ item.href = '#'; item.className = 'list-group-item list-group-item-action'; item.style.cssText = 'background:var(--bs-body-bg,#fff);cursor:pointer;'; + var displayName = self._cleanProductName(r.name); var adpInfo = ''; - if (r.adp_device_code) adpInfo += 'ADP: ' + r.adp_device_code; + if (r.adp_device_code) adpInfo += 'Code: ' + r.adp_device_code; if (r.adp_price) adpInfo += ' \u2014 $' + r.adp_price.toFixed(2); var variantBadge = ''; if (r.has_configurable_attributes) { @@ -324,7 +355,7 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ r.variant_count + ' options'; } item.innerHTML = '
' + - '
' + r.name + '' + variantBadge + + '
' + displayName + '' + variantBadge + (adpInfo ? '
' + adpInfo + '
' : '') + '
'; item.addEventListener('click', function (e) { @@ -343,8 +374,8 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ // Store template ID this.el.querySelector('#frameProductTmplId').value = template.id; - // Display selected template info - this.el.querySelector('#selectedFrameName').textContent = template.name; + // Display selected template info (Title Case, cleaned) + this.el.querySelector('#selectedFrameName').textContent = this._cleanProductName(template.name); this.el.querySelector('#selectedFrameCode').textContent = template.adp_device_code || 'N/A'; this.el.querySelector('#selectedFramePrice').textContent = (template.adp_price || 0).toFixed(2); this.el.querySelector('#selectedFrame').classList.remove('d-none'); @@ -610,8 +641,95 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ }, /** - * Show/hide seating accordion sections based on their equipment_type - * data attribute. Filtering rules: + * Handle seating tab click — switch active tab and panel. + */ + _onSeatingTabClick: function (ev) { + var clickedBtn = ev.currentTarget; + if (clickedBtn.classList.contains('active')) return; + + var container = clickedBtn.closest('.wc-seating-tabs'); + if (!container) return; + + // Deactivate all tabs and panels + container.querySelectorAll('.wc-tab-btn.active').forEach(function (btn) { + btn.classList.remove('active'); + }); + container.querySelectorAll('.wc-tab-panel.active').forEach(function (panel) { + panel.classList.remove('active'); + }); + + // Activate clicked tab + clickedBtn.classList.add('active'); + + // Activate corresponding panel + var targetId = clickedBtn.dataset.bsTarget; + if (targetId) { + var panel = container.querySelector(targetId); + if (panel) panel.classList.add('active'); + } + }, + + /** + * Real-time filter for product option cards within a seating tab. + * Filters by product name and ADP code as the user types. + */ + _onOptionFilter: function (ev) { + var input = ev.currentTarget; + var query = input.value.toLowerCase().trim(); + var sectionContainer = input.closest('.wc-section-options'); + if (!sectionContainer) return; + + var cards = sectionContainer.querySelectorAll('.wc-options-grid > div'); + cards.forEach(function (col) { + var card = col.querySelector('.wc-option-card'); + if (!card) { col.style.display = ''; return; } + var searchText = card.dataset.searchText || ''; + col.style.display = (!query || searchText.indexOf(query) !== -1) ? '' : 'none'; + }); + }, + + /** + * Toggle collapsible section groups within a seating tab. + * Accordion behaviour: opening one group closes the others. + */ + _onGroupHeaderClick: function (ev) { + var group = ev.currentTarget.closest('.wc-section-group'); + if (!group) return; + var container = group.closest('.wc-section-groups'); + if (!container) return; + + if (group.classList.contains('open')) { + // Close it + group.classList.remove('open'); + } else { + // Close all siblings, open this one + container.querySelectorAll('.wc-section-group.open').forEach(function (g) { + g.classList.remove('open'); + }); + group.classList.add('open'); + } + }, + + /** + * Update the selection count badge on each section group header. + * Called after any checkbox change via _syncSelectedLines. + */ + _updateGroupCounts: function () { + var groups = this.el.querySelectorAll('.wc-section-group'); + groups.forEach(function (group) { + var count = group.querySelectorAll('.wc-option-check:checked').length; + var badge = group.querySelector('.wc-group-count'); + if (badge) { + badge.textContent = count; + } + group.classList.toggle('has-selection', count > 0); + }); + }, + + /** + * Show/hide seating tab buttons and panels based on their equipment_type + * data attribute. Also ensures the first visible tab is active. + * Filtering rules: * - 'both' → always visible (all equipment types) * - 'wheelchair' → visible for manual_wheelchair and power_wheelchair * - exact match → visible only for that specific equipment type @@ -619,9 +737,16 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ */ _filterSeatingSections: function (equipType) { var wheelchairTypes = ['manual_wheelchair', 'power_wheelchair']; - var sectionItems = this.el.querySelectorAll('.wc-equipment-section'); - sectionItems.forEach(function (item) { - var sectionEquipType = item.dataset.equipmentType; + var container = this.el.querySelector('.wc-seating-tabs'); + if (!container) return; + + var firstVisibleBtn = null; + var hasActive = false; + + // Filter tab buttons + var tabBtns = container.querySelectorAll('.wc-tab-btn'); + tabBtns.forEach(function (btn) { + var sectionEquipType = btn.dataset.equipmentType; var show = false; if (!sectionEquipType || sectionEquipType === 'both') { show = true; @@ -630,8 +755,40 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ } else { show = sectionEquipType === equipType; } - item.style.display = show ? '' : 'none'; + btn.style.display = show ? '' : 'none'; + if (show && !firstVisibleBtn) firstVisibleBtn = btn; + if (show && btn.classList.contains('active')) hasActive = true; }); + + // Filter tab panels + var tabPanels = container.querySelectorAll('.wc-tab-panel'); + tabPanels.forEach(function (panel) { + var sectionEquipType = panel.dataset.equipmentType; + var show = false; + if (!sectionEquipType || sectionEquipType === 'both') { + show = true; + } else if (sectionEquipType === 'wheelchair') { + show = wheelchairTypes.indexOf(equipType) !== -1; + } else { + show = sectionEquipType === equipType; + } + if (!show) { + panel.style.display = 'none'; + panel.classList.remove('active'); + } else { + panel.style.display = ''; + } + }); + + // If no tab is active, activate the first visible one + if (!hasActive && firstVisibleBtn) { + firstVisibleBtn.classList.add('active'); + var targetId = firstVisibleBtn.dataset.bsTarget; + if (targetId) { + var panel = container.querySelector(targetId); + if (panel) panel.classList.add('active'); + } + } }, _reloadEquipmentOptions: function () { @@ -671,6 +828,49 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ var btn = radio.closest('.wc-radio-btn'); if (btn) self._applyRadioBtnStyle(btn, radio.checked); }); + + // If this is a build-type radio, filter products in the matching section + if (ev.target.classList.contains('wc-section-build-type')) { + var sectionCode = ev.target.dataset.section; + var selectedBuildType = ev.target.value; + this._filterByBuildType(sectionCode, selectedBuildType); + } + }, + + /** + * Filter product cards by build type. + * Finds the tab panel containing this section and filters ALL option cards + * in the entire panel (main section + children share the same build type). + */ + _filterByBuildType: function (sectionCode, buildType) { + // Find the build-type radio, then walk up to the tab panel + var radio = this.el.querySelector( + '.wc-section-build-type[data-section="' + sectionCode + '"]' + ); + var panel = radio ? radio.closest('.wc-tab-panel') : null; + + // Fallback: search the whole form + var scope = panel || this.el; + + var cards = scope.querySelectorAll('.wc-option-card[data-build-types]'); + cards.forEach(function (card) { + var cardBuild = card.dataset.buildTypes || 'both'; + var col = card.closest('[class*="col-"]'); + if (!col) col = card.parentElement; + if (cardBuild === 'both' || cardBuild === buildType) { + col.style.display = ''; + } else { + col.style.display = 'none'; + // Uncheck hidden cards so they don't get submitted + var cb = card.querySelector('.wc-option-check'); + if (cb && cb.checked) { + cb.checked = false; + } + } + }); + + this._syncSelectedLines(); + this._updateGroupCounts(); }, _syncRadioActiveState: function () { @@ -708,23 +908,30 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ options.forEach(function (opt) { var col = document.createElement('div'); - col.className = 'col-md-4'; + col.className = 'col-md-4 col-6'; + var displayName = self._cleanProductName(opt.product_name); + var priceHtml = opt.adp_price ? '$' + opt.adp_price.toFixed(2) + '' : ''; + var adpHtml = opt.adp_device_code ? 'Code: ' + opt.adp_device_code + '' : ''; + var metaParts = []; + if (adpHtml) metaParts.push(adpHtml); + if (priceHtml) metaParts.push(priceHtml); col.innerHTML = - '
' + - '
' + + '
' + ' ' + - '
' + + (metaParts.length ? '
' + metaParts.join(' ') + '
' : '') + (opt.requires_clinical_rationale ? '
' + '' + @@ -744,6 +951,25 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ grid.appendChild(col); }); + + // Apply build-type filter for newly rendered options + // The build-type radio might be for this section or a parent section + var sectionCode = container.dataset.section; + if (sectionCode) { + var buildRadio = this.el.querySelector( + '.wc-section-build-type[data-section="' + sectionCode + '"]:checked' + ); + if (!buildRadio) { + // Child section: find the build-type radio in the same tab panel + var panel = container.closest('.wc-tab-panel'); + if (panel) { + buildRadio = panel.querySelector('.wc-section-build-type:checked'); + } + } + if (buildRadio) { + this._filterByBuildType(buildRadio.dataset.section, buildRadio.value); + } + } }, _renderAdpOptions: function (options) { @@ -771,17 +997,34 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ cb.value = opt.product_tmpl_id; cb.style.cssText = 'width:18px;height:18px;min-width:18px;margin-top:2px;cursor:pointer;accent-color:var(--bs-primary);'; + var displayName = self._cleanProductName(opt.product_name); + + var content = document.createElement('div'); + content.style.cssText = 'flex:1;min-width:0;'; var lbl = document.createElement('label'); lbl.className = 'wc-option-label'; lbl.setAttribute('for', cb.id); - lbl.style.cssText = 'font-size:0.82rem;line-height:1.35;cursor:pointer;'; - lbl.textContent = opt.product_name; + lbl.style.cssText = 'font-size:0.82rem;line-height:1.35;cursor:pointer;display:block;'; + lbl.textContent = displayName; if (opt.requires_clinical_rationale) { lbl.innerHTML = lbl.textContent + ' *'; } + content.appendChild(lbl); + + // Meta line: Code + price + var metaParts = []; + if (opt.adp_device_code) metaParts.push('Code: ' + opt.adp_device_code + ''); + if (opt.adp_price) metaParts.push('$' + opt.adp_price.toFixed(2) + ''); + if (metaParts.length) { + var meta = document.createElement('div'); + meta.className = 'wc-opt-meta'; + meta.style.cssText = 'font-size:0.7rem;line-height:1.2;'; + meta.innerHTML = metaParts.join(' '); + content.appendChild(meta); + } card.appendChild(cb); - card.appendChild(lbl); + card.appendChild(content); if (opt.requires_clinical_rationale) { var rf = document.createElement('div'); @@ -833,18 +1076,31 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ cb.value = opt.product_tmpl_id; cb.style.cssText = 'width:18px;height:18px;min-width:18px;margin-top:2px;cursor:pointer;accent-color:var(--bs-primary);'; + var displayName = self._cleanProductName(opt.product_name); + + var content = document.createElement('div'); + content.style.cssText = 'flex:1;min-width:0;'; var lbl = document.createElement('label'); lbl.className = 'wc-option-label'; lbl.setAttribute('for', cb.id); - lbl.style.cssText = 'font-size:0.82rem;line-height:1.35;cursor:pointer;'; - var labelText = opt.product_name; - if (opt.adp_price) { - labelText += ' ($' + opt.adp_price.toFixed(2) + ')'; + lbl.style.cssText = 'font-size:0.82rem;line-height:1.35;cursor:pointer;display:block;'; + lbl.textContent = displayName; + content.appendChild(lbl); + + // Meta line: Code + price + var metaParts = []; + if (opt.adp_device_code) metaParts.push('Code: ' + opt.adp_device_code + ''); + if (opt.adp_price) metaParts.push('$' + opt.adp_price.toFixed(2) + ''); + if (metaParts.length) { + var meta = document.createElement('div'); + meta.className = 'wc-opt-meta'; + meta.style.cssText = 'font-size:0.7rem;line-height:1.2;'; + meta.innerHTML = metaParts.join(' '); + content.appendChild(meta); } - lbl.innerHTML = labelText; card.appendChild(cb); - card.appendChild(lbl); + card.appendChild(content); // Click anywhere on card toggles checkbox card.addEventListener('click', function (e) { @@ -909,6 +1165,9 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({ rationale: rationaleInput ? rationaleInput.value : '', }); }); + + // Update collapsible group count badges + this._updateGroupCounts(); }, _populateHiddenLineFields: function () { diff --git a/fusion_quotations/views/portal_quotation_templates.xml b/fusion_quotations/views/portal_quotation_templates.xml index b5776b6a..e7b5cd1d 100644 --- a/fusion_quotations/views/portal_quotation_templates.xml +++ b/fusion_quotations/views/portal_quotation_templates.xml @@ -601,110 +601,171 @@

- + -

Select seating and positioning devices. Choose Modular or Custom Fabricated for each.

-
- - -
-

- -

-
-
- -
- -
- - -
-
+

Select seating and positioning devices. Choose Modular or Custom Fabricated for each.

+
+
+ +
+ +
- - -
+ +
+
+ + + +
+ + +
+
+ +
+ + +
+
-
-
-
-
-
-
- - -
-
- -
- -
-
-
+ +
- - -
-
-
-
-
- -
+ +
+ +
+
+
+
+ + +
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+ + +
+
+
+
+ +
+
+
+
+
+
+
+ + -
-
+ +
- - +
+