This commit is contained in:
gsinghpal
2026-03-09 23:45:00 -04:00
parent acd3fc455e
commit f81e0cd918
85 changed files with 6085 additions and 126 deletions

BIN
.DS_Store vendored

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,7 @@
# Synodica Solutions
## Changelog
### 16.0.0.0.0
- Initial version

View File

@@ -0,0 +1,2 @@
from . import models
from . import canada_post_api

View File

@@ -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",
}

View File

@@ -0,0 +1,2 @@
from . import canada_post_response
from . import utils

View File

@@ -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())

View File

@@ -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}</{tag}>') \
.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}</{tag}>') \
.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}</{tag}>') \
.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)

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="0">
<!-- Canada post Package -->
<record id="canadapost_packaging_canadapost_BOX" model="stock.package.type">
<field name="name">canadapost BOX</field>
<field name="package_carrier_type">canada_post</field>
<field name="packaging_length">100</field>
<field name="width">100</field>
<field name="height">100</field>
<field name="max_weight">20.00</field>
</record>
<!-- Canada post Delivery Carriers -->
<record id="product_product_delivery_canadapost" model="product.product">
<field name="name">Canada Post</field>
<field name="default_code">Delivery</field>
<field name="type">service</field>
<field name="categ_id" ref="delivery.product_category_deliveries" />
<field name="sale_ok" eval="False" />
<field name="purchase_ok" eval="False" />
<field name="list_price">0.0</field>
</record>
<record id="delivery_carrier_canadapost" model="delivery.carrier">
<field name="name">Canada Post</field>
<field
name="product_id"
ref="delivery_canadapost.product_product_delivery_canadapost"
/>
<field name="delivery_type">canada_post</field>
<field name="canadapost_type">commercial</field>
<field name="option_code">SO</field>
<field name="service_type">DOM.RP</field>
<field
name="product_packaging_id"
ref="delivery_canadapost.canadapost_packaging_canadapost_BOX"
/>
</record>
</odoo>

View File

@@ -0,0 +1,3 @@
from . import delivery_carrier
from . import product_packaging
from . import res_company

View File

@@ -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 Senders 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 Intl'),
('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)

View File

@@ -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')])

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 983 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -0,0 +1,321 @@
<html>
<head>
<link href="custom.css" rel="stylesheet"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8"/>
<meta name="google-site-verification" content="NGpd7kJ1PumTrCwdhQdBLlYQDa7HzofXBtuQ4cJCtHw"/>
<title>Canada Post Shipping Integration with Odoo</title>
<meta name="description" content="Odoo Canada Post shipping Shipping gatway. Canada Post Shipping connector Odoo"/>
<meta name="keywords" content="shipping connector,Odoo Canada Post gatway, Delivery Canada Post, Odoo Canada Post shipping, Canada Post Shipping located across Global, Canada Post Shipping connect,
integrate Canada Post ,Canada Post odoo, Canada Post store, shipping solution to your site, accept shipment odoo, accept shipping odoo, sync shipment odoo, sync order odoo, Canada Post gateway settings,Canada Post Shipping gateway integration, Shipping Gateway Providers In Global,
Canada Post: The New Digital Shipping Solution for Retailer"/>
<meta name="robots" content="index, follow"/>
<link type="text/css" rel="stylesheet" href="/assets/assets.css"/>
<script src="https://code.jquery.com/jquery-2.1.1.min.js"></script>
<link type="text/css" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css"/>
<script src="https://code.jquery.com/jquery-3.6.0.js"
integrity="sha256-H+K7U5CnXl1h5ywQfKtSj8PCmoN9aaq30gDh27Xc0jk=" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css2?family=Laila:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
crossorigin="anonymous"></script>
</head>
<body>
<div id="top"></div>
<section class="oe_container bg-transparent fixed-top position-relative">
<div class="o_equal_col_sm">
<div class="shadow bg-white pb32 pt32 px-2"
style="border-radius: 15px">
<h3 class="oe_slogan"
style="color: #051f5a; font-family: Raleway; font-weight: 700; text-align: center; opacity: 1; font-size: 35px; margin-bottom: 25px; margin-top: 45px;">
Odoo Canada Post Shipping Integration</h3>
<img src="dash.png" style="width: 100%;">
</div>
</div>
</section>
<section class="oe_container overflow-hidden"
style="position-relative">
<div class="o_equal_col_sm">
<div class="px-2" style="border-radius:15px">
<h3 class="oe_slogan"
style="color:#0f1e40; font-family:Montserrat; font-weight:700; text-align:center; text-transform:uppercase; opacity:1; font-size:28px; margin-bottom:20px; margin-top:20px">
<span style="color:#2971FD">Why</span> Canada Post Odoo Shipping Integration App From Synodica?
</h3>
<p class="mb16 text-center text-black-dark px-3"
style="font-family:; font-weight:normal; color:#11335b; font-size:18px">
Integrate Canada Post streamline shipping processes by obtaining <b>real-time courier quotes for
various services, generating shipping labels based on order details</b>, and providing <b>tracking
numbers with
links for shipment updates</b>
ensuring a seamless and efficient logistics workflow
</p>
<div class="pb32 pt32" style="display:block; margin:0 auto; text-align:center">
<div class="d-inline-block mx-3 my-3" style="max-width:348px">
<div class="s_panel_video position-relative text-center" data-video-id="w17dnsMTt8M">
<h3 class="oe_slogan"
style="color:#091E42; font-family:Montserrat; font-weight:600; text-align:center; font-size:18px; opacity:1; margin:8px 0 16px">
WATCH QUICK DEMO HERE
</h3>
<!-- Play Button Centered -->
<a target="_new" href="https://youtu.be/MIt4t8h71aQ"
class="position-absolute top-50 start-50 translate-middle" style="padding-top: 50px;">
<img class="img img-fluid" src="play_button.png" alt="Play Button">
</a>
<!-- Thumbnail -->
<a target="_new" href="https://youtu.be/MIt4t8h71aQ">
<img class="img img-fluid shadow" src="canadapost_thumbnail.png"
style="border-radius:10px; width:100%;">
</a>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="oe_container pb-5">
<div class="mt64 mb64">
<h2 style="color:#091E42; font-family:'Raleway'; text-align:center; margin:25px auto; text-transform:uppercase"
class="oe_slogan">
<b>Features</b>
</h2>
<div class="row">
<div class="col-md-6 col-sm-12 mt32">
<div class="container shadow" style="border-radius:10px; padding:10px 0px">
<div class="col-md-3 shadow" style="float:left; margin: -10px 0px;">
<img class="img img-responsive"
src="images/icons_live_price.png"
style="width: 100%;height: auto;"></div>
<div class="col-md-9" style="padding-left:0; float:left; width:70%;margin-left: 20px;">
<h3 class="mt16 mb0" style="font-family:Roboto; font-weight:500; font-size:22px">Live Shipping
Price</h3>
<p class=" mt8" style="font-family:Roboto;">Get live price to ensure the best price for
your shipment</p>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12 mt32">
<div class="container shadow" style="border-radius:10px; padding:10px 0px">
<div class="col-md-3 shadow" style="float:left; margin: -10px 0px;">
<img class="img img-responsive"
src="images/icons_shipping_label.png"
style="width: 100%;height: auto;"></div>
<div class="col-md-9" style="padding-left:0; float:left; width:70%;margin-left: 20px;">
<h3 class="mt16 mb0" style="font-family:Roboto; font-weight:500; font-size:22px">Shipping
Label</h3>
<p class="mt8" style="font-family:Roboto;">Generate shipping label using order information</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-sm-12 mt32" style="margin: 0px auto;padding-top: 22px;">
<div class="container shadow" style="border-radius:10px; padding:10px 0px">
<div class="col-md-3 shadow" style="float:left; margin: -10px 0px;"><img class="img img-responsive"
src="images/icons_track_and_trace.png"
style="width: 100%;height: auto;">
</div>
<div class="col-md-9" style="padding-left:0; float:left; width:70%;margin-left: 20px;">
<h3 class="mt16 mb0" style="font-family:Roboto; font-weight:500; font-size:22px">Track &
Trace</h3>
<p class="mt8 mid_p_1200" style="font-family:Roboto;">Live track your shipment using the Canada
Post tracking number</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="container">
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
<h3 class="oe_slogan">Canada Post Shipping In Configuration</h3>
<div class="mt16 mb16" style="margin-left: 10px">
<img src="S1.png"
style="width: 100%; margin-top: 10px; border-radius: 5px; border: solid #E3E4E4; border-radius: 10px; border-width: 30px 7px 7px 7px;">
</div>
</div>
</section>
<section class="container">
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
<h3 class="oe_slogan">Create Sale Order With Canada Post Delivery Method</h3>
<div class="mt16 mb16" style="margin-left: 10px">
<img src="S2.png"
style="width: 100%; margin-top: 10px; border-radius: 5px; border: solid #E3E4E4; border-radius: 10px; border-width: 30px 7px 7px 7px;">
</div>
</div>
</section>
<section class="container">
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
<h3 class="oe_slogan">Generate Shipment With Tracking Number And Shipping Label</h3>
<div class="mt16 mb16" style="margin-left: 10px">
<img src="S3.png"
style="width: 100%; margin-top: 10px; border-radius: 5px; border: solid #E3E4E4; border-radius: 10px; border-width: 30px 7px 7px 7px;">
</div>
</div>
</section>
<section class="container">
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
<h3 class="oe_slogan">Generated Shipment Label</h3>
<div class="mt16 mb16" style="margin: 0px auto;text-align: center;">
<img src="label.png"
style="margin-top: 10px; border-radius: 5px; border: solid #E3E4E4; border-radius: 10px; border-width: 7px 7px 7px 7px;">
</div>
</div>
</section>
<section class="container">
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
<div class="mt16 mb16" style="margin-left: 10px">
<img src="synodica_services.png" style="width: 100%; height: 100%;">
</div>
</div>
</section>
<!-- Carousel -->
<div class="row" style="padding: 0px 60px;">
<section class="container">
<h2 style="color:#00008b; text-align:center; margin:25px auto; text-transform:uppercase;font-size: 50px;font-weight: 600;"
class="oe_slogan">
Suggested Products
</h2>
<div id="suggested_products" class="row carousel slide mt64 mb32" data-bs-ride="carousel">
<!-- The slideshow -->
<div class="carousel-inner">
<div class="carousel-item active" style="min-height: 292.4px;">
<div class="col-xs-12 col-sm-4 col-md-4 mb16 mt16" style="float:left">
<a href="https://apps.odoo.com/apps/modules/17.0/odoo_advance_search/">
<div style="border-radius:10px" class="shadow-sm">
<img class="img img-responsive center-block"
style="border-top-left-radius:10px; border-top-right-radius:10px;height: 200px;width: -webkit-fill-available;width: -moz-available;width: -webkit-fill-available;"
src="advanced_search.gif">
<h4 class="mt0 text-truncate"
style="padding:6% 4%; text-align:center; width:100%">
Odoo Advanced Search
</h4>
</div>
</a>
</div>
<div class="col-xs-12 col-sm-4 col-md-4 mb16 mt16" style="float:left;padding-left: 20px;">
<a href="https://apps.odoo.com/apps/modules/17.0/delivery_dpd_shipping/">
<div style="border-radius:10px" class="shadow-sm">
<img class="img img-responsive center-block"
style="border-top-left-radius:10px; border-top-right-radius:10px;height: 200px;width: -webkit-fill-available;width: -moz-available;width: -webkit-fill-available;"
src="dpd.gif">
<h4 class="mt0 text-truncate"
style="padding:6% 4%; text-align:center; width:100%;">
DPD Shipping
</h4>
</div>
</a>
</div>
<div class="col-xs-12 col-sm-4 col-md-4 mb16 mt16" style="float:left;padding-left: 20px;">
<a href="https://apps.odoo.com/apps/modules/17.0/delivery_shippo_ss/">
<div style="border-radius:10px" class="shadow-sm">
<img class="img img-responsive center-block"
style="border-top-left-radius:10px; border-top-right-radius:10px;height: 200px;width: -webkit-fill-available;width: -moz-available;width: -webkit-fill-available;"
src="GoShippo.gif">
<h4 class="mt0 text-truncate"
style="padding:6% 4%; text-align:center; width:100%">
GoShippo Shipping
</h4>
</div>
</a>
</div>
</div>
<div class="carousel-item" style="min-height: 292.4px;">
<div class="col-xs-12 col-sm-4 col-md-4 mb16 mt16" style="float:left">
<a href="https://apps.odoo.com/apps/modules/17.0/delivery_dsv_express/">
<div style="border-radius:10px" class="shadow-sm">
<img class="img img-responsive center-block"
style="border-top-left-radius:10px; border-top-right-radius:10px;height: 200px;width: -webkit-fill-available;width: -moz-available;width: -webkit-fill-available;"
src="dsv_banner.gif">
<h4 class="mt0 text-truncate"
style="padding:6% 4%; text-align:center; width:100%">
DSV Express Shipping
</h4>
</div>
</a>
</div>
<div class="col-xs-12 col-sm-4 col-md-4 mb16 mt16" style="float:left;padding-left: 20px;">
<a href="https://apps.odoo.com/apps/modules/17.0/delivery_chitchats/">
<div style="border-radius:10px" class="shadow-sm">
<img class="img img-responsive center-block"
style="border-top-left-radius:10px; border-top-right-radius:10px;height: 200px;width: -webkit-fill-available;width: -moz-available;width: -webkit-fill-available;"
src="chitchat_shipping.gif">
<h4 class="mt0 text-truncate"
style="padding:6% 4%; text-align:center; width:100%">
ChitChats Shipping
</h4>
</div>
</a>
</div>
<div class="col-xs-12 col-sm-4 col-md-4 mb16 mt16" style="float:left;padding-left: 20px;">
<a href="https://apps.odoo.com/apps/modules/17.0/delivery_shippit_ss/">
<div style="border-radius:10px" class="shadow-sm">
<img class="img img-responsive center-block"
style="border-top-left-radius:10px; border-top-right-radius:10px;height: 200px;width: -webkit-fill-available;width: -moz-available;width: -webkit-fill-available;"
src="Shippit.gif">
<h4 class="mt0 text-truncate"
style="padding:6% 4%; text-align:center; width:100%">
Shippit Shipping
</h4>
</div>
</a>
</div>
</div>
</div>
<!-- Left and right controls -->
<a class="carousel-control-prev" href="#suggested_products" data-bs-slide="prev"
style="width:35px; color:#000; margin-left:-30px">
<span class="carousel-control-prev-icon"><i class="fa fa-chevron-left"
style="font-size:24px"></i></span>
</a>
<a class="carousel-control-next" href="#suggested_products" data-bs-slide="next"
style="width:35px; color:#000; margin-right:-30px">
<span class="carousel-control-next-icon"><i class="fa fa-chevron-right"
style="font-size:24px"></i></span>
</a>
</div>
</section>
</div>
<!-- End Carousel -->
<div class="container">
<section class="container">
<h4 style="color:#00008b; text-align:center; margin:25px auto; text-transform:uppercase;font-weight: 600;"
class="oe_slogan">
Contact Us
</h4>
<div style="text-align:center">
<span>Suggestions &amp; Feedback to:</span> <a href="mailto:support@synodica.com">support@synodica.com </a>
</div>
</section>
</div>
<div class="container" align="center">
<section class="container">
<a href="#top">
<img src="up_arrow.png" alt="up-arrow" style="height: 125px;">
</a>
</section>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="delivery_carrier_form_view_canada_post" model="ir.ui.view">
<field name="name">delivery.carrier.form.view.canada.post</field>
<field name="model">delivery.carrier</field>
<field name="inherit_id" ref="delivery.view_delivery_carrier_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='destination']" position='before'>
<page string="Configuration" name="configuration" invisible="delivery_type != 'canada_post'">
<p class="alert alert-danger" role="alert" invisible="delivery_type != 'canada_post'">
Note : Weight UOM must be select KG in Canada Post provider.
</p>
<group>
<group>
<field name="canadapost_type" required="delivery_type == 'canada_post'"/>
<field name="canadapost_contract_id" required="canadapost_type == 'commercial'"
invisible="canadapost_type == 'counter'"/>
<field name="service_type" required="delivery_type == 'canada_post'"/>
<field name="option_code"/>
<field name="reason_for_export"/>
<field name="product_packaging_id"
required="delivery_type == 'canada_post'"
/>
</group>
<group string="Shipping Option">
<field
name="canadapost_payment_method"
string="Payment Method"
required="delivery_type == 'canada_post'"
/>
</group>
</group>
<group invisible="delivery_type != 'canada_post'">
<field name="username" required="delivery_type == 'canada_post'"/>
<field name="password" password="True"
required="delivery_type == 'canada_post'"/>
<field name="customer_number" required="delivery_type == 'canada_post'"/>
<field name="tracking_link"/>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,7 @@
# Nexa Systems Inc
## Changelog
### 19.0.1.0.0
- Initial version

View File

@@ -0,0 +1,3 @@
from . import models
from . import wizard
from . import fusion_cp_api

View File

@@ -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",
}

View File

@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="0">
<!-- ── Standard Canada Post Box Sizes (inches / lbs) ── -->
<record id="fusion_cp_pkg_26x20x20" model="stock.package.type">
<field name="name">CP Box 26×20×20 in, 35 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">26</field>
<field name="width">20</field>
<field name="height">20</field>
<field name="max_weight">35</field>
</record>
<record id="fusion_cp_pkg_26x26x9" model="stock.package.type">
<field name="name">CP Box 26×26×9 in, 15 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">26</field>
<field name="width">26</field>
<field name="height">9</field>
<field name="max_weight">15</field>
</record>
<record id="fusion_cp_pkg_32x28x10" model="stock.package.type">
<field name="name">CP Box 32×28×10 in, 12 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">32</field>
<field name="width">28</field>
<field name="height">10</field>
<field name="max_weight">12</field>
</record>
<record id="fusion_cp_pkg_34x28x10" model="stock.package.type">
<field name="name">CP Box 34×28×10 in, 10 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">34</field>
<field name="width">28</field>
<field name="height">10</field>
<field name="max_weight">10</field>
</record>
<record id="fusion_cp_pkg_9x25x27" model="stock.package.type">
<field name="name">CP Box 9×25×27 in, 16 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">9</field>
<field name="width">25</field>
<field name="height">27</field>
<field name="max_weight">16</field>
</record>
<record id="fusion_cp_pkg_9x25x29" model="stock.package.type">
<field name="name">CP Box 9×25×29 in, 17 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">9</field>
<field name="width">25</field>
<field name="height">29</field>
<field name="max_weight">17</field>
</record>
<record id="fusion_cp_pkg_10x10x6" model="stock.package.type">
<field name="name">CP Box 10×10×6 in, 35 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">10</field>
<field name="width">10</field>
<field name="height">6</field>
<field name="max_weight">35</field>
</record>
<record id="fusion_cp_pkg_25x15x30" model="stock.package.type">
<field name="name">CP Box 25×15×30 in, 25 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">25</field>
<field name="width">15</field>
<field name="height">30</field>
<field name="max_weight">25</field>
</record>
<record id="fusion_cp_pkg_10x10x10" model="stock.package.type">
<field name="name">CP Box 10×10×10 in, 2 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">10</field>
<field name="width">10</field>
<field name="height">10</field>
<field name="max_weight">2</field>
</record>
<record id="fusion_cp_pkg_28x15x30" model="stock.package.type">
<field name="name">CP Box 28×15×30 in, 40 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">28</field>
<field name="width">15</field>
<field name="height">30</field>
<field name="max_weight">40</field>
</record>
<record id="fusion_cp_pkg_28x10x30" model="stock.package.type">
<field name="name">CP Box 28×10×30 in, 16 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">28</field>
<field name="width">10</field>
<field name="height">30</field>
<field name="max_weight">16</field>
</record>
<record id="fusion_cp_pkg_22x10x6_cm" model="stock.package.type">
<field name="name">CP Box 22×10×6 cm, 10 kg</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">22</field>
<field name="width">10</field>
<field name="height">6</field>
<field name="max_weight">10</field>
</record>
<record id="fusion_cp_pkg_22x13x6" model="stock.package.type">
<field name="name">CP Box 22×13×6 in, 10 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">22</field>
<field name="width">13</field>
<field name="height">6</field>
<field name="max_weight">10</field>
</record>
<record id="fusion_cp_pkg_12x10x5" model="stock.package.type">
<field name="name">CP Box 12×10×5 in, 2 lb</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">12</field>
<field name="width">10</field>
<field name="height">5</field>
<field name="max_weight">2</field>
</record>
<!-- Old default box — replaced with a standard size -->
<record id="fusion_cp_packaging_BOX" model="stock.package.type">
<field name="name">CP Box 10×10×10 in (Legacy)</field>
<field name="package_carrier_type">fusion_canada_post</field>
<field name="packaging_length">10</field>
<field name="width">10</field>
<field name="height">10</field>
<field name="max_weight">2</field>
</record>
<!-- ── Canada Post Delivery Product ── -->
<record id="product_product_delivery_fusion_canada_post" model="product.product">
<field name="name">SHIPPING BY CANADA POST</field>
<field name="default_code">CPSHIPPING</field>
<field name="type">service</field>
<field name="categ_id" ref="delivery.product_category_deliveries" />
<field name="sale_ok" eval="False" />
<field name="purchase_ok" eval="False" />
<field name="list_price">0.0</field>
</record>
<!-- ── Default Canada Post Carrier ── -->
<record id="delivery_carrier_fusion_canada_post" model="delivery.carrier">
<field name="name">Canada Post</field>
<field
name="product_id"
ref="fusion_canada_post.product_product_delivery_fusion_canada_post"
/>
<field name="delivery_type">fusion_canada_post</field>
<field name="fusion_cp_type">commercial</field>
<field name="fusion_cp_dimension_unit">in</field>
<field name="option_code">SO</field>
<field name="service_type">DOM.RP</field>
</record>
</odoo>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_fusion_cp_shipment" model="ir.sequence">
<field name="name">Fusion CP Shipment</field>
<field name="code">fusion.cp.shipment</field>
<field name="prefix">FCP-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import fusion_cp_response
from . import utils

View File

@@ -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())

View File

@@ -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}</{tag}>') \
.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}</{tag}>') \
.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}</{tag}>') \
.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)

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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',
)

View File

@@ -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',
}

View File

@@ -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',
)

View File

@@ -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')])

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_cp_shipment_user fusion.cp.shipment.user model_fusion_cp_shipment base.group_user 1 0 0 0
3 access_fusion_cp_shipment_stock_user fusion.cp.shipment.stock.user model_fusion_cp_shipment stock.group_stock_user 1 1 1 0
4 access_fusion_cp_shipment_manager fusion.cp.shipment.manager model_fusion_cp_shipment stock.group_stock_manager 1 1 1 1
5 access_fusion_cp_tracking_event_user fusion.cp.tracking.event.user model_fusion_cp_tracking_event base.group_user 1 0 0 0
6 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
7 access_fusion_cp_tracking_event_manager fusion.cp.tracking.event.manager model_fusion_cp_tracking_event stock.group_stock_manager 1 1 1 1
8 access_choose_delivery_cp_rate_user choose.delivery.cp.rate.user model_choose_delivery_cp_rate base.group_user 1 1 1 1
9 access_choose_delivery_cp_package_user choose.delivery.cp.package.user model_choose_delivery_cp_package base.group_user 1 1 1 1
10 access_fusion_cp_order_package_user fusion.cp.order.package.user model_fusion_cp_order_package base.group_user 1 0 0 0
11 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
12 access_fusion_cp_order_package_manager fusion.cp.order.package.manager model_fusion_cp_order_package stock.group_stock_manager 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

View File

@@ -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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -0,0 +1,173 @@
<html>
<head>
<link href="custom.css" rel="stylesheet"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8"/>
<title>Canada Post Shipping Integration with Odoo</title>
<meta name="description" content="Odoo Canada Post Shipping Integration. Canada Post Shipping connector for Odoo"/>
<meta name="keywords" content="shipping connector, Odoo Canada Post, Delivery Canada Post, Odoo Canada Post shipping, Canada Post Shipping,
integrate Canada Post, Canada Post odoo, shipping solution, accept shipment odoo, sync shipment odoo, Canada Post gateway integration"/>
<meta name="robots" content="index, follow"/>
<link type="text/css" rel="stylesheet" href="/assets/assets.css"/>
<link type="text/css" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css"/>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css2?family=Laila:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
crossorigin="anonymous"></script>
</head>
<body>
<div id="top"></div>
<section class="oe_container bg-transparent fixed-top position-relative">
<div class="o_equal_col_sm">
<div class="shadow bg-white pb32 pt32 px-2"
style="border-radius: 15px">
<h3 class="oe_slogan"
style="color: #051f5a; font-family: Raleway; font-weight: 700; text-align: center; opacity: 1; font-size: 35px; margin-bottom: 25px; margin-top: 45px;">
Odoo Canada Post Shipping Integration</h3>
<img src="dash.png" style="width: 100%;">
</div>
</div>
</section>
<section class="oe_container overflow-hidden"
style="position-relative">
<div class="o_equal_col_sm">
<div class="px-2" style="border-radius:15px">
<h3 class="oe_slogan"
style="color:#0f1e40; font-family:Montserrat; font-weight:700; text-align:center; text-transform:uppercase; opacity:1; font-size:28px; margin-bottom:20px; margin-top:20px">
<span style="color:#2971FD">Why</span> Canada Post Odoo Shipping Integration?
</h3>
<p class="mb16 text-center text-black-dark px-3"
style="font-family:; font-weight:normal; color:#11335b; font-size:18px">
Integrate Canada Post to streamline shipping processes by obtaining <b>real-time courier quotes for
various services, generating shipping labels based on order details</b>, and providing <b>tracking
numbers with
links for shipment updates</b>
ensuring a seamless and efficient logistics workflow
</p>
</div>
</div>
</section>
<section class="oe_container pb-5">
<div class="mt64 mb64">
<h2 style="color:#091E42; font-family:'Raleway'; text-align:center; margin:25px auto; text-transform:uppercase"
class="oe_slogan">
<b>Features</b>
</h2>
<div class="row">
<div class="col-md-6 col-sm-12 mt32">
<div class="container shadow" style="border-radius:10px; padding:10px 0px">
<div class="col-md-3 shadow" style="float:left; margin: -10px 0px;">
<img class="img img-responsive"
src="images/icons_live_price.png"
style="width: 100%;height: auto;"></div>
<div class="col-md-9" style="padding-left:0; float:left; width:70%;margin-left: 20px;">
<h3 class="mt16 mb0" style="font-family:Roboto; font-weight:500; font-size:22px">Live Shipping
Price</h3>
<p class=" mt8" style="font-family:Roboto;">Get live price to ensure the best price for
your shipment</p>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12 mt32">
<div class="container shadow" style="border-radius:10px; padding:10px 0px">
<div class="col-md-3 shadow" style="float:left; margin: -10px 0px;">
<img class="img img-responsive"
src="images/icons_shipping_label.png"
style="width: 100%;height: auto;"></div>
<div class="col-md-9" style="padding-left:0; float:left; width:70%;margin-left: 20px;">
<h3 class="mt16 mb0" style="font-family:Roboto; font-weight:500; font-size:22px">Shipping
Label</h3>
<p class="mt8" style="font-family:Roboto;">Generate shipping label using order information</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-sm-12 mt32" style="margin: 0px auto;padding-top: 22px;">
<div class="container shadow" style="border-radius:10px; padding:10px 0px">
<div class="col-md-3 shadow" style="float:left; margin: -10px 0px;"><img class="img img-responsive"
src="images/icons_track_and_trace.png"
style="width: 100%;height: auto;">
</div>
<div class="col-md-9" style="padding-left:0; float:left; width:70%;margin-left: 20px;">
<h3 class="mt16 mb0" style="font-family:Roboto; font-weight:500; font-size:22px">Track &
Trace</h3>
<p class="mt8 mid_p_1200" style="font-family:Roboto;">Live track your shipment using the Canada
Post tracking number</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="container">
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
<h3 class="oe_slogan">Canada Post Shipping Configuration</h3>
<div class="mt16 mb16" style="margin-left: 10px">
<img src="S1.png"
style="width: 100%; margin-top: 10px; border-radius: 5px; border: solid #E3E4E4; border-radius: 10px; border-width: 30px 7px 7px 7px;">
</div>
</div>
</section>
<section class="container">
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
<h3 class="oe_slogan">Create Sale Order With Canada Post Delivery Method</h3>
<div class="mt16 mb16" style="margin-left: 10px">
<img src="S2.png"
style="width: 100%; margin-top: 10px; border-radius: 5px; border: solid #E3E4E4; border-radius: 10px; border-width: 30px 7px 7px 7px;">
</div>
</div>
</section>
<section class="container">
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
<h3 class="oe_slogan">Generate Shipment With Tracking Number And Shipping Label</h3>
<div class="mt16 mb16" style="margin-left: 10px">
<img src="S3.png"
style="width: 100%; margin-top: 10px; border-radius: 5px; border: solid #E3E4E4; border-radius: 10px; border-width: 30px 7px 7px 7px;">
</div>
</div>
</section>
<section class="container">
<div class="oe_row oe_spaced mt8 mb8" style="width: auto;">
<h3 class="oe_slogan">Generated Shipment Label</h3>
<div class="mt16 mb16" style="margin: 0px auto;text-align: center;">
<img src="label.png"
style="margin-top: 10px; border-radius: 5px; border: solid #E3E4E4; border-radius: 10px; border-width: 7px 7px 7px 7px;">
</div>
</div>
</section>
<div class="container">
<section class="container">
<h4 style="color:#00008b; text-align:center; margin:25px auto; text-transform:uppercase;font-weight: 600;"
class="oe_slogan">
Contact Us
</h4>
<div style="text-align:center">
<span>Suggestions &amp; Feedback to:</span> <a href="mailto:support@nexasystems.ca">support@nexasystems.ca</a>
</div>
</section>
</div>
<div class="container" align="center">
<section class="container">
<a href="#top">
<img src="up_arrow.png" alt="up-arrow" style="height: 125px;">
</a>
</section>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="choose_delivery_carrier_view_form_cp" model="ir.ui.view">
<field name="name">choose.delivery.carrier.form.cp</field>
<field name="model">choose.delivery.carrier</field>
<field name="inherit_id" ref="delivery.choose_delivery_carrier_view_form"/>
<field name="arch" type="xml">
<!-- Hidden state fields (inside the existing group — no layout impact) -->
<xpath expr="//field[@name='delivery_price']" position="after">
<field name="fusion_cp_selected_service" invisible="1"/>
<field name="fusion_cp_dimension_unit_label" invisible="1"/>
<field name="fusion_cp_weight_unit_label" invisible="1"/>
</xpath>
<!-- Hide base "Get rate" button for Canada Post -->
<xpath expr="//button[@name='update_price']" position="attributes">
<attribute name="invisible">delivery_type in ('fixed', 'base_on_rule', 'fusion_canada_post')</attribute>
</xpath>
<!-- "Get Prices" button (inside the Cost / Get-rate row) -->
<xpath expr="//button[@name='update_price']" position="after">
<button name="update_price" type="object"
invisible="delivery_type != 'fusion_canada_post'">
<i class="oi oi-arrow-right me-1"/>Get Prices
</button>
</xpath>
<!--
Full-width sections AFTER the outer <group>.
This avoids them being crammed into the group's
label-field grid and gives them the full dialog width.
-->
<xpath expr="//form/group" position="after">
<!-- ── Package list ── -->
<div invisible="delivery_type != 'fusion_canada_post'"
class="px-3 mt-2">
<span class="o_form_label fw-bold mb-1">Packages</span>
<field name="fusion_cp_package_ids" nolabel="1">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="package_type_id" string="Box Type"/>
<field name="package_length" string="L"/>
<field name="package_width" string="W"/>
<field name="package_height" string="H"/>
<field name="weight" string="Weight"/>
<field name="selected_price" string="Cost"
widget="monetary"
options="{'currency_field': 'currency_id'}"
optional="hide"/>
<field name="currency_id" column_invisible="1"/>
</list>
</field>
</div>
<!-- ── Service selection ── -->
<div invisible="delivery_type != 'fusion_canada_post' or not fusion_cp_rate_ids"
class="px-3 mt-3">
<span class="o_form_label fw-bold mb-1">Select a Service</span>
<field name="fusion_cp_rate_ids" nolabel="1">
<list create="0" delete="0" no_open="1"
decoration-bf="is_selected"
decoration-success="is_selected">
<field name="is_selected" column_invisible="1"/>
<field name="service_name" string="Service"/>
<field name="price" string="Shipping Cost"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="currency_id" column_invisible="1"/>
<field name="expected_delivery"
string="Expected Delivery"/>
<button name="action_select" type="object"
string="Select"
class="btn-sm btn-link"
icon="fa-circle-o"
invisible="is_selected"/>
<button name="action_select" type="object"
string="Selected"
class="btn-sm btn-primary"
icon="fa-check-circle"
invisible="not is_selected"/>
</list>
</field>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="delivery_carrier_form_view_fusion_canada_post" model="ir.ui.view">
<field name="name">delivery.carrier.form.view.fusion.canada.post</field>
<field name="model">delivery.carrier</field>
<field name="inherit_id" ref="delivery.view_delivery_carrier_form"/>
<field name="arch" type="xml">
<!-- Rename "Get Rate" → "Get Prices" on the Integration Level radio -->
<xpath expr="//field[@name='integration_level']" position="attributes">
<attribute name="invisible">delivery_type == 'fusion_canada_post'</attribute>
</xpath>
<xpath expr="//field[@name='integration_level']" position="after">
<field name="integration_level"
invisible="delivery_type != 'fusion_canada_post'"
widget="radio"
options="{'horizontal': true}"
help="Get Prices: only fetch shipping prices on sale orders. Get Prices and Create Shipment: also generate labels and tracking when the delivery order is validated."/>
</xpath>
<xpath expr="//page[@name='destination']" position='before'>
<page string="Configuration" name="configuration" invisible="delivery_type != 'fusion_canada_post'">
<group string="API Credentials"
invisible="delivery_type != 'fusion_canada_post'">
<group>
<field name="username" string="API Key"
required="delivery_type == 'fusion_canada_post'"
help="API key from the Canada Post Developer Program."/>
<field name="password" string="API Password" password="True"
required="delivery_type == 'fusion_canada_post'"
help="API password from the Canada Post Developer Program."/>
<field name="customer_number" required="delivery_type == 'fusion_canada_post'"
help="Your Canada Post customer number (mailed-by number)."/>
</group>
<group>
<field name="fusion_cp_type" string="Account Type"
required="delivery_type == 'fusion_canada_post'"/>
<field name="fusion_cp_contract_id" required="fusion_cp_type == 'commercial'"
invisible="fusion_cp_type == 'counter'"/>
</group>
</group>
<group>
<group string="Shipping Options">
<field name="service_type" string="Default Service"
required="delivery_type == 'fusion_canada_post'"
help="Default shipping service. Users can choose a different service when adding shipping to a sale order."/>
<field name="option_code" string="Shipping Option"/>
<field name="reason_for_export"/>
<field name="product_packaging_id"
help="Optional default package type. Dimensions will pre-fill in the shipping wizard but can be overridden per shipment."
/>
<field name="fusion_cp_dimension_unit"
required="delivery_type == 'fusion_canada_post'"
help="Unit for package dimensions. Values are automatically converted to centimetres for the Canada Post API."/>
</group>
<group string="Payment &amp; Tracking">
<field
name="fusion_cp_payment_method"
string="Payment Method"
required="delivery_type == 'fusion_canada_post'"
/>
<field name="tracking_link"
help="Custom tracking URL. Leave blank to use the default Canada Post tracking page."/>
</group>
</group>
<group string="Label Settings"
invisible="delivery_type != 'fusion_canada_post'">
<group>
<field name="fusion_cp_output_format"
required="delivery_type == 'fusion_canada_post'"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,266 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Search View -->
<record id="view_fusion_cp_shipment_search" model="ir.ui.view">
<field name="name">fusion.cp.shipment.search</field>
<field name="model">fusion.cp.shipment</field>
<field name="arch" type="xml">
<search string="Search Shipments">
<field name="name"/>
<field name="tracking_number"/>
<field name="shipment_id"/>
<field name="sale_order_id"/>
<field name="picking_id"/>
<field name="recipient_name"/>
<filter name="filter_confirmed" string="Confirmed"
domain="[('status','=','confirmed')]"/>
<filter name="filter_shipped" string="Shipped"
domain="[('status','=','shipped')]"/>
<filter name="filter_delivered" string="Delivered"
domain="[('status','=','delivered')]"/>
<filter name="filter_cancelled" string="Cancelled"
domain="[('status','=','cancelled')]"/>
<group>
<filter name="group_status" string="Status"
context="{'group_by': 'status'}"/>
<filter name="group_service" string="Service Type"
context="{'group_by': 'service_type'}"/>
<filter name="group_date" string="Shipment Date"
context="{'group_by': 'shipment_date:month'}"/>
</group>
</search>
</field>
</record>
<!-- List View -->
<record id="view_fusion_cp_shipment_list" model="ir.ui.view">
<field name="name">fusion.cp.shipment.list</field>
<field name="model">fusion.cp.shipment</field>
<field name="arch" type="xml">
<list string="Canada Post Shipments" default_order="shipment_date desc">
<field name="name" decoration-bf="1"/>
<field name="tracking_number"/>
<field name="recipient_name"/>
<field name="sale_order_id" optional="show"/>
<field name="picking_id" optional="show"/>
<field name="service_type"/>
<field name="shipping_cost" sum="Total Cost"/>
<field name="weight" optional="hide"/>
<field name="package_name" optional="hide"/>
<field name="shipment_date"/>
<field name="status"
decoration-info="status == 'draft'"
decoration-success="status in ('confirmed','shipped')"
decoration-bf="status == 'delivered'"
decoration-danger="status == 'cancelled'"
widget="badge"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_fusion_cp_shipment_form" model="ir.ui.view">
<field name="name">fusion.cp.shipment.form</field>
<field name="model">fusion.cp.shipment</field>
<field name="arch" type="xml">
<form string="Canada Post Shipment">
<header>
<button name="action_refresh_tracking" type="object"
string="Refresh Tracking"
class="btn-primary"
icon="fa-refresh"
invisible="not tracking_number or status == 'cancelled'"/>
<button name="action_track_on_canada_post" type="object"
string="Track on Canada Post"
class="btn-secondary"
icon="fa-external-link"
invisible="not tracking_number"/>
<button name="action_void_shipment" type="object"
string="Void Shipment"
class="btn-danger"
icon="fa-ban"
invisible="status in ('cancelled', 'delivered')"
confirm="Are you sure you want to void this shipment? This cannot be undone."/>
<button name="action_reissue_shipment" type="object"
string="Reissue Shipment"
class="btn-warning"
icon="fa-repeat"
invisible="status != 'cancelled'"/>
<field name="status" widget="statusbar"
statusbar_visible="confirmed,shipped,delivered"/>
</header>
<sheet>
<div name="button_box" class="oe_button_box">
<button name="action_open_sale_order" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not sale_order_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Sale Order</span>
</div>
</button>
<button name="action_open_picking" type="object"
class="oe_stat_button" icon="fa-truck"
invisible="not picking_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Transfer</span>
</div>
</button>
<button name="action_refresh_tracking" type="object"
class="oe_stat_button" icon="fa-map-marker"
invisible="not tracking_number">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="tracking_event_count"/>
</span>
<span class="o_stat_text">Events</span>
</div>
</button>
</div>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group string="Shipment Details">
<field name="tracking_number"/>
<field name="shipment_id"/>
<field name="carrier_id"/>
<field name="service_type"/>
<field name="shipment_date"/>
<field name="shipping_cost"/>
<field name="weight"/>
<field name="package_name"
invisible="not package_name"/>
</group>
<group string="Links">
<field name="sale_order_id"/>
<field name="picking_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group>
<group string="Sender">
<field name="sender_name"/>
<field name="sender_address"/>
</group>
<group string="Recipient">
<field name="recipient_name"/>
<field name="recipient_address"/>
</group>
</group>
<notebook>
<page string="Labels &amp; Documents" name="labels">
<field name="label_attachment_id" invisible="1"/>
<field name="full_label_attachment_id" invisible="1"/>
<field name="receipt_attachment_id" invisible="1"/>
<field name="commercial_invoice_attachment_id" invisible="1"/>
<group string="Labels">
<span class="o_form_label" invisible="not label_attachment_id">Printable Label (4x6)</span>
<button name="action_view_label" type="object"
class="btn btn-link p-0"
icon="fa-file-pdf-o"
string="Open"
invisible="not label_attachment_id"/>
<span class="o_form_label" invisible="not full_label_attachment_id">Full Label (8.5x11)</span>
<button name="action_view_full_label" type="object"
class="btn btn-link p-0"
icon="fa-file-pdf-o"
string="Open"
invisible="not full_label_attachment_id"/>
</group>
<group string="Documents">
<span class="o_form_label" invisible="not receipt_attachment_id">Receipt</span>
<button name="action_view_receipt" type="object"
class="btn btn-link p-0"
icon="fa-file-pdf-o"
string="Open"
invisible="not receipt_attachment_id"/>
<span class="o_form_label" invisible="not commercial_invoice_attachment_id">Commercial Invoice</span>
<button name="action_view_commercial_invoice" type="object"
class="btn btn-link p-0"
icon="fa-file-pdf-o"
string="Open"
invisible="not commercial_invoice_attachment_id"/>
</group>
</page>
<page string="Tracking History" name="tracking_history">
<group>
<group>
<field name="last_tracking_update"/>
</group>
<group>
<field name="delivery_date"
invisible="status != 'delivered'"/>
</group>
</group>
<field name="tracking_event_ids" readonly="1" nolabel="1">
<list string="Tracking Events"
default_order="event_datetime desc">
<field name="event_datetime" string="Date/Time"/>
<field name="event_description"/>
<field name="event_site" string="Location"/>
<field name="event_province"/>
<field name="signatory_name" optional="hide"/>
<field name="event_type" optional="hide"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Kanban View -->
<record id="view_fusion_cp_shipment_kanban" model="ir.ui.view">
<field name="name">fusion.cp.shipment.kanban</field>
<field name="model">fusion.cp.shipment</field>
<field name="arch" type="xml">
<kanban default_group_by="status" class="o_kanban_small_column">
<field name="name"/>
<field name="tracking_number"/>
<field name="status"/>
<field name="shipping_cost"/>
<field name="shipment_date"/>
<field name="recipient_name"/>
<field name="service_type"/>
<templates>
<t t-name="card">
<div class="oe_kanban_details">
<strong><field name="name"/></strong>
<div><field name="recipient_name"/></div>
<div class="text-muted">
<i class="fa fa-barcode"/> <field name="tracking_number"/>
</div>
<div class="text-muted">
<field name="service_type"/> |
<field name="shipment_date" widget="date"/>
</div>
<div>
<strong>$<field name="shipping_cost"/></strong>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- Window Action -->
<record id="action_fusion_cp_shipment" model="ir.actions.act_window">
<field name="name">Shipments</field>
<field name="res_model">fusion.cp.shipment</field>
<field name="view_mode">list,form,kanban</field>
<field name="search_view_id" ref="view_fusion_cp_shipment_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No shipments yet
</p>
<p>Shipments are created automatically when you validate a delivery
order with a Canada Post carrier.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top-level app menu -->
<menuitem id="menu_fusion_cp_root"
name="Canada Post"
web_icon="fusion_canada_post,static/description/icon.png"
sequence="85"/>
<!-- Shipments -->
<menuitem id="menu_fusion_cp_shipments"
name="Shipments"
parent="menu_fusion_cp_root"
action="action_fusion_cp_shipment"
sequence="10"/>
<!-- Configuration parent -->
<menuitem id="menu_fusion_cp_config"
name="Configuration"
parent="menu_fusion_cp_root"
sequence="90"/>
<!-- Carrier Settings action -->
<record id="action_fusion_cp_carrier_settings" model="ir.actions.act_window">
<field name="name">Canada Post Carrier</field>
<field name="res_model">delivery.carrier</field>
<field name="view_mode">list,form</field>
<field name="domain">[('delivery_type', '=', 'fusion_canada_post')]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Configure your Canada Post carrier
</p>
</field>
</record>
<menuitem id="menu_fusion_cp_carrier"
name="Carrier Settings"
parent="menu_fusion_cp_config"
action="action_fusion_cp_carrier_settings"
sequence="10"/>
</odoo>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_fusion_cp" model="ir.ui.view">
<field name="name">res.config.settings.view.form.fusion.canada.post</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Canada Post" string="Canada Post"
name="fusion_canada_post">
<block title="Carrier Configuration" name="carrier_config_block">
<setting string="Canada Post Carrier Settings"
help="Configure your Canada Post API credentials, service types, label format, and shipping preferences.">
<div class="content-group">
<div class="mt8">
<button name="%(fusion_canada_post.action_fusion_cp_carrier_settings)d"
type="action"
class="btn-link"
icon="oi-arrow-right"
string="Open Carrier Configuration"/>
</div>
</div>
</setting>
<setting string="Shipment History"
help="View and manage all Canada Post shipments, labels, and tracking information.">
<div class="content-group">
<div class="mt8">
<button name="%(fusion_canada_post.action_fusion_cp_shipment)d"
type="action"
class="btn-link"
icon="oi-arrow-right"
string="View Shipments"/>
</div>
</div>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_order_form_fusion_cp" model="ir.ui.view">
<field name="name">sale.order.form.fusion.cp</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_fusion_cp_shipments" type="object"
class="oe_stat_button" icon="fa-plane"
invisible="fusion_cp_shipment_count == 0">
<field name="fusion_cp_shipment_count" widget="statinfo"
string="CP Shipments"/>
</button>
</xpath>
<!-- Show CP package details on the sale order -->
<xpath expr="//group[@name='note_group']" position="before">
<group string="Canada Post Packages"
invisible="not fusion_cp_package_ids"
colspan="6">
<field name="fusion_cp_package_ids" nolabel="1"
colspan="6" readonly="1">
<list create="0" delete="0" no_open="1">
<field name="package_type_id" string="Box Type"
optional="show"/>
<field name="package_length" string="Length"/>
<field name="package_width" string="Width"/>
<field name="package_height" string="Height"/>
<field name="weight" string="Weight"/>
<field name="service_name" string="Service"/>
<field name="price" string="Cost"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="currency_id" column_invisible="1"/>
</list>
</field>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_picking_form_fusion_cp" model="ir.ui.view">
<field name="name">stock.picking.form.fusion.cp</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_fusion_cp_shipments" type="object"
class="oe_stat_button" icon="fa-plane"
invisible="fusion_cp_shipment_count == 0">
<field name="fusion_cp_shipment_count" widget="statinfo"
string="CP Shipments"/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import choose_delivery_cp_rate

View File

@@ -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": <int>, "price": <float>}, ...]
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

View File

@@ -29,10 +29,11 @@ class SaleOrderLine(models.Model):
@api.depends('product_id', 'product_id.default_code') @api.depends('product_id', 'product_id.default_code')
def _compute_adp_device_type(self): 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() ADPDevice = self.env['fusion.adp.device.code'].sudo()
for line in self: for line in self:
device_type = '' device_type = ''
build_type = False
if line.product_id: if line.product_id:
# Get the device code from product (default_code or custom field) # Get the device code from product (default_code or custom field)
device_code = line._get_adp_device_code() device_code = line._get_adp_device_code()
@@ -44,7 +45,9 @@ class SaleOrderLine(models.Model):
], limit=1) ], limit=1)
if adp_device: if adp_device:
device_type = adp_device.device_type or '' 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_device_type = device_type
line.x_fc_adp_build_type = build_type
# ========================================================================== # ==========================================================================
# SERIAL NUMBER AND DEVICE PLACEMENT # SERIAL NUMBER AND DEVICE PLACEMENT
@@ -110,6 +113,16 @@ class SaleOrderLine(models.Model):
store=True, store=True,
help='Device type from ADP mobility manual (for approval matching)', 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 # COMPUTED ADP PORTIONS

View File

@@ -2178,6 +2178,7 @@
help="Device approved by ADP"/> help="Device approved by ADP"/>
<field name="name" string="Description" class="text-wrap"/> <field name="name" string="Description" class="text-wrap"/>
<field name="x_fc_adp_device_type" string="Device Type" optional="show" width="150px"/> <field name="x_fc_adp_device_type" string="Device Type" optional="show" width="150px"/>
<field name="x_fc_adp_build_type" string="Build" optional="show" width="100px"/>
<field name="x_fc_serial_number" string="S/N" optional="show" width="90px"/> <field name="x_fc_serial_number" string="S/N" optional="show" width="90px"/>
<field name="product_uom_qty" string="Qty" width="60px"/> <field name="product_uom_qty" string="Qty" width="60px"/>
<field name="price_unit" string="Unit $" width="100px"/> <field name="price_unit" string="Unit $" width="100px"/>
@@ -2410,9 +2411,11 @@
<attribute name="optional">hide</attribute> <attribute name="optional">hide</attribute>
</xpath> </xpath>
<!-- Serial Number: after description, always visible --> <!-- Serial Number and Build Type: after description -->
<xpath expr="//field[@name='order_line']/list/field[@name='name']" position="after"> <xpath expr="//field[@name='order_line']/list/field[@name='name']" position="after">
<field name="x_fc_serial_number" string="S/N" optional="show"/> <field name="x_fc_serial_number" string="S/N" optional="show"/>
<field name="x_fc_adp_build_type" string="Build" optional="hide"
column_invisible="not parent.x_fc_is_adp_sale"/>
</xpath> </xpath>
<!-- Shorten Qty label --> <!-- Shorten Qty label -->

View File

@@ -199,6 +199,204 @@
word-wrap: break-word; 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. Search Containers — z-index must beat ALL sibling content below.
position:relative creates a stacking context so the absolute 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 { /* ── Container ── */
border-radius: 0.5rem !important; .wc-seating-tabs {
margin-bottom: 0.75rem; border: 1px solid var(--bs-border-color, #dee2e6);
border: 1px solid #dee2e6 !important; border-radius: 0.75rem;
overflow: hidden; overflow: hidden;
background: var(--bs-body-bg);
} }
.wc-assessment-form .accordion-item:last-child { .wc-seating-tabs > .row {
margin-bottom: 0; min-height: 400px;
} }
.wc-assessment-form .accordion-button { /* ── Tab Navigation Column ── */
border-radius: 0.5rem !important; .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; 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) { .wc-tab-icon {
border-radius: 0.5rem 0.5rem 0 0 !important; 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; 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) Review Table (Step 6)
----------------------------------------------------------------- */ ----------------------------------------------------------------- */

View File

@@ -16,6 +16,9 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
'input .wc-measurement-input': '_onMeasurementChange', 'input .wc-measurement-input': '_onMeasurementChange',
'change .wc-unit-select': '_onMeasurementChange', 'change .wc-unit-select': '_onMeasurementChange',
// equipment_type is now a hidden field — no change event needed // 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', 'change .wc-radio-btn input[type="radio"]': '_onRadioBtnChange',
'click #btnGenerate': '_onClickGenerate', 'click #btnGenerate': '_onClickGenerate',
'submit #wcForm': '_onFormSubmit', 'submit #wcForm': '_onFormSubmit',
@@ -52,6 +55,33 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
return '/my/quotation/api/' + endpoint; 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 // INITIALIZATION
// ===================================================================== // =====================================================================
@@ -315,8 +345,9 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
item.href = '#'; item.href = '#';
item.className = 'list-group-item list-group-item-action'; item.className = 'list-group-item list-group-item-action';
item.style.cssText = 'background:var(--bs-body-bg,#fff);cursor:pointer;'; item.style.cssText = 'background:var(--bs-body-bg,#fff);cursor:pointer;';
var displayName = self._cleanProductName(r.name);
var adpInfo = ''; 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); if (r.adp_price) adpInfo += ' \u2014 $' + r.adp_price.toFixed(2);
var variantBadge = ''; var variantBadge = '';
if (r.has_configurable_attributes) { if (r.has_configurable_attributes) {
@@ -324,7 +355,7 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
r.variant_count + ' options</span>'; r.variant_count + ' options</span>';
} }
item.innerHTML = '<div class="d-flex justify-content-between align-items-center">' + item.innerHTML = '<div class="d-flex justify-content-between align-items-center">' +
'<div><strong>' + r.name + '</strong>' + variantBadge + '<div><strong>' + displayName + '</strong>' + variantBadge +
(adpInfo ? '<div class="small text-muted">' + adpInfo + '</div>' : '') + (adpInfo ? '<div class="small text-muted">' + adpInfo + '</div>' : '') +
'</div></div>'; '</div></div>';
item.addEventListener('click', function (e) { item.addEventListener('click', function (e) {
@@ -343,8 +374,8 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
// Store template ID // Store template ID
this.el.querySelector('#frameProductTmplId').value = template.id; this.el.querySelector('#frameProductTmplId').value = template.id;
// Display selected template info // Display selected template info (Title Case, cleaned)
this.el.querySelector('#selectedFrameName').textContent = template.name; this.el.querySelector('#selectedFrameName').textContent = this._cleanProductName(template.name);
this.el.querySelector('#selectedFrameCode').textContent = template.adp_device_code || 'N/A'; 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('#selectedFramePrice').textContent = (template.adp_price || 0).toFixed(2);
this.el.querySelector('#selectedFrame').classList.remove('d-none'); 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 * Handle seating tab click — switch active tab and panel.
* data attribute. Filtering rules: */
_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) * - 'both' → always visible (all equipment types)
* - 'wheelchair' → visible for manual_wheelchair and power_wheelchair * - 'wheelchair' → visible for manual_wheelchair and power_wheelchair
* - exact match → visible only for that specific equipment type * - exact match → visible only for that specific equipment type
@@ -619,9 +737,16 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
*/ */
_filterSeatingSections: function (equipType) { _filterSeatingSections: function (equipType) {
var wheelchairTypes = ['manual_wheelchair', 'power_wheelchair']; var wheelchairTypes = ['manual_wheelchair', 'power_wheelchair'];
var sectionItems = this.el.querySelectorAll('.wc-equipment-section'); var container = this.el.querySelector('.wc-seating-tabs');
sectionItems.forEach(function (item) { if (!container) return;
var sectionEquipType = item.dataset.equipmentType;
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; var show = false;
if (!sectionEquipType || sectionEquipType === 'both') { if (!sectionEquipType || sectionEquipType === 'both') {
show = true; show = true;
@@ -630,8 +755,40 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
} else { } else {
show = sectionEquipType === equipType; 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 () { _reloadEquipmentOptions: function () {
@@ -671,6 +828,49 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
var btn = radio.closest('.wc-radio-btn'); var btn = radio.closest('.wc-radio-btn');
if (btn) self._applyRadioBtnStyle(btn, radio.checked); 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 () { _syncRadioActiveState: function () {
@@ -708,23 +908,30 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
options.forEach(function (opt) { options.forEach(function (opt) {
var col = document.createElement('div'); 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 ? '<span class="text-success wc-opt-price">$' + opt.adp_price.toFixed(2) + '</span>' : '';
var adpHtml = opt.adp_device_code ? '<span class="text-muted wc-opt-adp">Code: ' + opt.adp_device_code + '</span>' : '';
var metaParts = [];
if (adpHtml) metaParts.push(adpHtml);
if (priceHtml) metaParts.push(priceHtml);
col.innerHTML = col.innerHTML =
'<div class="wc-option-card h-100 p-2" data-product-tmpl-id="' + opt.product_tmpl_id + '"' + '<div class="wc-option-card wc-option-compact h-100" data-product-tmpl-id="' + opt.product_tmpl_id + '"' +
' data-section-id="' + (container.dataset.sectionId || '') + '"' + ' data-section-id="' + (container.dataset.sectionId || '') + '"' +
' data-section-code="' + (container.dataset.section || '') + '"' + ' data-section-code="' + (container.dataset.section || '') + '"' +
' data-adp-code="' + (opt.adp_device_code || '') + '"' + ' data-adp-code="' + (opt.adp_device_code || '') + '"' +
' data-price="' + (opt.list_price || 0) + '"' + ' data-price="' + (opt.list_price || 0) + '"' +
' data-build-types="' + (opt.available_build_types || 'both') + '"' +
' data-search-text="' + (opt.product_name + ' ' + (opt.adp_device_code || '')).toLowerCase().replace(/"/g, '') + '"' +
' data-rationale="' + (opt.requires_clinical_rationale ? '1' : '0') + '">' + ' data-rationale="' + (opt.requires_clinical_rationale ? '1' : '0') + '">' +
' <div class="form-check">' + ' <div class="form-check mb-0">' +
' <input class="form-check-input wc-option-check" type="checkbox"' + ' <input class="form-check-input wc-option-check" type="checkbox"' +
' id="opt_' + opt.id + '" value="' + opt.product_tmpl_id + '"/>' + ' id="opt_' + opt.id + '" value="' + opt.product_tmpl_id + '"/>' +
' <label class="form-check-label" for="opt_' + opt.id + '">' + ' <label class="form-check-label wc-opt-label" for="opt_' + opt.id + '">' +
' <strong>' + opt.product_name + '</strong>' + displayName +
(opt.adp_device_code ? '<br/><small class="text-muted">ADP: ' + opt.adp_device_code + '</small>' : '') +
(opt.adp_price ? '<br/><small class="text-success">$' + opt.adp_price.toFixed(2) + '</small>' : '') +
' </label>' + ' </label>' +
' </div>' + ' </div>' +
(metaParts.length ? ' <div class="wc-opt-meta">' + metaParts.join(' ') + '</div>' : '') +
(opt.requires_clinical_rationale ? (opt.requires_clinical_rationale ?
'<div class="wc-rationale-field d-none mt-1">' + '<div class="wc-rationale-field d-none mt-1">' +
'<input type="text" class="form-control form-control-sm" placeholder="Clinical rationale *"/>' + '<input type="text" class="form-control form-control-sm" placeholder="Clinical rationale *"/>' +
@@ -744,6 +951,25 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
grid.appendChild(col); 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) { _renderAdpOptions: function (options) {
@@ -771,17 +997,34 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
cb.value = opt.product_tmpl_id; 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);'; 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'); var lbl = document.createElement('label');
lbl.className = 'wc-option-label'; lbl.className = 'wc-option-label';
lbl.setAttribute('for', cb.id); lbl.setAttribute('for', cb.id);
lbl.style.cssText = 'font-size:0.82rem;line-height:1.35;cursor:pointer;'; lbl.style.cssText = 'font-size:0.82rem;line-height:1.35;cursor:pointer;display:block;';
lbl.textContent = opt.product_name; lbl.textContent = displayName;
if (opt.requires_clinical_rationale) { if (opt.requires_clinical_rationale) {
lbl.innerHTML = lbl.textContent + ' <span class="text-danger fw-bold">*</span>'; lbl.innerHTML = lbl.textContent + ' <span class="text-danger fw-bold">*</span>';
} }
content.appendChild(lbl);
// Meta line: Code + price
var metaParts = [];
if (opt.adp_device_code) metaParts.push('<span class="text-muted">Code: ' + opt.adp_device_code + '</span>');
if (opt.adp_price) metaParts.push('<span class="text-success">$' + opt.adp_price.toFixed(2) + '</span>');
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(cb);
card.appendChild(lbl); card.appendChild(content);
if (opt.requires_clinical_rationale) { if (opt.requires_clinical_rationale) {
var rf = document.createElement('div'); var rf = document.createElement('div');
@@ -833,18 +1076,31 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
cb.value = opt.product_tmpl_id; 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);'; 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'); var lbl = document.createElement('label');
lbl.className = 'wc-option-label'; lbl.className = 'wc-option-label';
lbl.setAttribute('for', cb.id); lbl.setAttribute('for', cb.id);
lbl.style.cssText = 'font-size:0.82rem;line-height:1.35;cursor:pointer;'; lbl.style.cssText = 'font-size:0.82rem;line-height:1.35;cursor:pointer;display:block;';
var labelText = opt.product_name; lbl.textContent = displayName;
if (opt.adp_price) { content.appendChild(lbl);
labelText += ' <small class="text-success">($' + opt.adp_price.toFixed(2) + ')</small>';
// Meta line: Code + price
var metaParts = [];
if (opt.adp_device_code) metaParts.push('<span class="text-muted">Code: ' + opt.adp_device_code + '</span>');
if (opt.adp_price) metaParts.push('<span class="text-success">$' + opt.adp_price.toFixed(2) + '</span>');
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(cb);
card.appendChild(lbl); card.appendChild(content);
// Click anywhere on card toggles checkbox // Click anywhere on card toggles checkbox
card.addEventListener('click', function (e) { card.addEventListener('click', function (e) {
@@ -909,6 +1165,9 @@ publicWidget.registry.WCAssessmentForm = publicWidget.Widget.extend({
rationale: rationaleInput ? rationaleInput.value : '', rationale: rationaleInput ? rationaleInput.value : '',
}); });
}); });
// Update collapsible group count badges
this._updateGroupCounts();
}, },
_populateHiddenLineFields: function () { _populateHiddenLineFields: function () {

View File

@@ -601,110 +601,171 @@
<p class="text-muted"><t t-out="fstep.help_text"/></p> <p class="text-muted"><t t-out="fstep.help_text"/></p>
</t> </t>
<!-- Seating/Positioning sections with accordion --> <!-- Seating/Positioning sections with side tabs -->
<t t-if="fstep.section_code == 'seating'"> <t t-if="fstep.section_code == 'seating'">
<p class="text-muted">Select seating and positioning devices. Choose Modular or Custom Fabricated for each.</p> <p class="text-muted mb-3">Select seating and positioning devices. Choose Modular or Custom Fabricated for each.</p>
<div class="accordion" id="seatingAccordion"> <div class="wc-seating-tabs" id="seatingTabsContainer">
<t t-foreach="sections" t-as="section"> <div class="row g-0">
<t t-if="section.has_build_type and section.code != 'accessories'"> <!-- ── TAB NAV (left on desktop, top on mobile) ── -->
<div class="accordion-item wc-equipment-section" <div class="col-md-3 wc-tab-nav-col">
t-att-data-equipment-type="section.equipment_type" <div class="nav flex-md-column wc-tab-nav" id="seatingTabNav" role="tablist"
t-att-style="'' if (section.equipment_type in ('both', equipment_type) or (section.equipment_type == 'wheelchair' and equipment_type in ('manual_wheelchair', 'power_wheelchair'))) else 'display:none;'"> aria-orientation="vertical">
<h2 class="accordion-header"> <t t-set="first_visible" t-value="True"/>
<button class="accordion-button collapsed" type="button" <t t-foreach="sections" t-as="section">
data-bs-toggle="collapse" <t t-if="section.has_build_type and section.code != 'accessories'">
t-attf-data-bs-target="#collapse_#{section.code}"> <button type="button" role="tab"
<i t-attf-class="fa #{section.icon or 'fa-cube'} me-2"/> t-attf-class="wc-tab-btn wc-equipment-section #{'active' if first_visible and (section.equipment_type in ('both', equipment_type) or (section.equipment_type == 'wheelchair' and equipment_type in ('manual_wheelchair', 'power_wheelchair'))) else ''}"
<t t-out="section.name"/> t-att-data-equipment-type="section.equipment_type"
</button> t-attf-id="tab_#{section.code}"
</h2> t-attf-data-bs-target="#panel_#{section.code}"
<div t-attf-id="collapse_#{section.code}" class="accordion-collapse collapse" t-att-aria-controls="'panel_' + section.code"
data-bs-parent="#seatingAccordion"> t-att-data-section-code="section.code"
<div class="accordion-body"> t-att-style="'' if (section.equipment_type in ('both', equipment_type) or (section.equipment_type == 'wheelchair' and equipment_type in ('manual_wheelchair', 'power_wheelchair'))) else 'display:none;'">
<!-- Build Type Toggle --> <i t-attf-class="fa #{section.icon or 'fa-cube'} wc-tab-icon"/>
<div class="mb-3"> <span class="wc-tab-label"><t t-out="section.name"/></span>
<label class="form-label fw-bold">Build Type</label> <span class="wc-tab-badge" title="Selected items">0</span>
<div class="d-flex gap-2"> </button>
<label class="btn btn-sm btn-outline-secondary wc-radio-btn"> <t t-if="first_visible and (section.equipment_type in ('both', equipment_type) or (section.equipment_type == 'wheelchair' and equipment_type in ('manual_wheelchair', 'power_wheelchair')))">
<input type="radio" t-attf-name="section_build_#{section.code}" <t t-set="first_visible" t-value="False"/>
value="modular" checked="checked" </t>
class="wc-section-build-type" </t>
t-att-data-section="section.code"/> </t>
Modular </div>
</label> </div>
<label class="btn btn-sm btn-outline-secondary wc-radio-btn">
<input type="radio" t-attf-name="section_build_#{section.code}"
value="custom_fabricated"
class="wc-section-build-type"
t-att-data-section="section.code"/>
Custom Fabricated
</label>
</div>
</div>
<!-- Measurement fields if section has them --> <!-- ── TAB CONTENT (right on desktop, below on mobile) ── -->
<t t-if="section.has_width or section.has_depth or section.has_height"> <div class="col-md-9 wc-tab-content-col">
<div class="row g-2 mb-3"> <div class="wc-tab-content" id="seatingTabContent">
<t t-set="first_visible" t-value="True"/>
<t t-foreach="sections" t-as="section">
<t t-if="section.has_build_type and section.code != 'accessories'">
<div role="tabpanel"
t-attf-class="wc-tab-panel wc-equipment-section #{'active' if first_visible and (section.equipment_type in ('both', equipment_type) or (section.equipment_type == 'wheelchair' and equipment_type in ('manual_wheelchair', 'power_wheelchair'))) else ''}"
t-attf-id="panel_#{section.code}"
t-att-data-equipment-type="section.equipment_type"
t-att-data-section-code="section.code"
t-attf-aria-labelledby="tab_#{section.code}">
<!-- Build Type + Measurements row (always visible) -->
<div class="d-flex flex-wrap align-items-end gap-3 mb-3">
<div>
<label class="form-label fw-bold mb-1" style="font-size:0.78rem;">Build Type</label>
<div class="d-flex gap-2">
<label class="btn btn-sm btn-outline-secondary wc-radio-btn">
<input type="radio" t-attf-name="section_build_#{section.code}"
value="modular" checked="checked"
class="wc-section-build-type"
t-att-data-section="section.code"/>
Modular
</label>
<label class="btn btn-sm btn-outline-secondary wc-radio-btn">
<input type="radio" t-attf-name="section_build_#{section.code}"
value="custom_fabricated"
class="wc-section-build-type"
t-att-data-section="section.code"/>
Custom Fabricated
</label>
</div>
</div>
<t t-if="section.has_width"> <t t-if="section.has_width">
<div class="col-md-4"> <div style="min-width:100px;">
<label class="form-label" t-out="section.width_label"/> <label class="form-label mb-1" style="font-size:0.78rem;" t-out="section.width_label"/>
<input type="number" step="0.25" class="form-control" <input type="number" step="0.25" class="form-control form-control-sm"
t-attf-name="section_width_#{section.code}"/> t-attf-name="section_width_#{section.code}"/>
</div> </div>
</t> </t>
<t t-if="section.has_depth"> <t t-if="section.has_depth">
<div class="col-md-4"> <div style="min-width:100px;">
<label class="form-label" t-out="section.depth_label"/> <label class="form-label mb-1" style="font-size:0.78rem;" t-out="section.depth_label"/>
<input type="number" step="0.25" class="form-control" <input type="number" step="0.25" class="form-control form-control-sm"
t-attf-name="section_depth_#{section.code}"/> t-attf-name="section_depth_#{section.code}"/>
</div> </div>
</t> </t>
<t t-if="section.has_height"> <t t-if="section.has_height">
<div class="col-md-4"> <div style="min-width:100px;">
<label class="form-label" t-out="section.height_label"/> <label class="form-label mb-1" style="font-size:0.78rem;" t-out="section.height_label"/>
<input type="number" step="0.25" class="form-control" <input type="number" step="0.25" class="form-control form-control-sm"
t-attf-name="section_height_#{section.code}"/> t-attf-name="section_height_#{section.code}"/>
</div> </div>
</t> </t>
</div> </div>
</t>
<!-- Product options for this section --> <!-- ── Collapsible section groups (accordion within tab) ── -->
<div class="wc-section-options" t-att-data-section="section.code" <div class="wc-section-groups">
t-att-data-section-id="section.id">
<div class="wc-options-grid row g-2"/>
<!-- Custom search -->
<div class="mt-2 wc-search-container">
<input type="text" class="form-control form-control-sm wc-product-search"
t-att-data-section="section.code"
placeholder="Search for product..."
autocomplete="off"/>
<div class="wc-search-results list-group d-none"/>
</div>
</div>
<!-- Sub-sections --> <!-- Group: Main section products -->
<t t-foreach="section.child_ids" t-as="child"> <div class="wc-section-group open">
<div class="mt-3 ps-3 border-start"> <button type="button" class="wc-group-header">
<h6><t t-out="child.name"/></h6> <i t-attf-class="fa #{section.icon or 'fa-cube'} wc-group-icon"/>
<div class="wc-section-options" t-att-data-section="child.code" <span class="wc-group-title"><t t-out="section.name"/></span>
t-att-data-section-id="child.id"> <span class="wc-group-count">0</span>
<div class="wc-options-grid row g-2"/> <i class="fa fa-chevron-down wc-group-chevron"/>
<div class="mt-1 wc-search-container"> </button>
<input type="text" class="form-control form-control-sm wc-product-search" <div class="wc-group-body">
t-att-data-section="child.code" <div class="wc-section-options" t-att-data-section="section.code"
placeholder="Search..." t-att-data-section-id="section.id">
autocomplete="off"/> <div class="wc-option-filter-wrap mb-2">
<div class="wc-search-results list-group d-none"/> <div class="input-group input-group-sm">
<span class="input-group-text"><i class="fa fa-filter"/></span>
<input type="text" class="form-control wc-option-filter"
placeholder="Filter products..."
autocomplete="off"/>
</div>
</div>
<div class="wc-options-grid row g-2"/>
<div class="mt-2 wc-search-container">
<input type="text" class="form-control form-control-sm wc-product-search"
t-att-data-section="section.code"
placeholder="Search catalogue for more..."
autocomplete="off"/>
<div class="wc-search-results list-group d-none"/>
</div>
</div>
</div> </div>
</div> </div>
<!-- Group: Sub-sections (collapsed by default) -->
<t t-foreach="section.child_ids" t-as="child">
<div class="wc-section-group">
<button type="button" class="wc-group-header">
<i class="fa fa-cube wc-group-icon"/>
<span class="wc-group-title"><t t-out="child.name"/></span>
<span class="wc-group-count">0</span>
<i class="fa fa-chevron-down wc-group-chevron"/>
</button>
<div class="wc-group-body">
<div class="wc-section-options" t-att-data-section="child.code"
t-att-data-section-id="child.id">
<div class="wc-option-filter-wrap mb-2">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="fa fa-filter"/></span>
<input type="text" class="form-control wc-option-filter"
placeholder="Filter products..."
autocomplete="off"/>
</div>
</div>
<div class="wc-options-grid row g-2"/>
<div class="mt-1 wc-search-container">
<input type="text" class="form-control form-control-sm wc-product-search"
t-att-data-section="child.code"
placeholder="Search catalogue for more..."
autocomplete="off"/>
<div class="wc-search-results list-group d-none"/>
</div>
</div>
</div>
</div>
</t>
</div> </div>
</div>
<t t-if="first_visible and (section.equipment_type in ('both', equipment_type) or (section.equipment_type == 'wheelchair' and equipment_type in ('manual_wheelchair', 'power_wheelchair')))">
<t t-set="first_visible" t-value="False"/>
</t> </t>
</div> </t>
</div> </t>
</div> </div>
</t> </div>
</t> </div>
</div> </div>
</t> </t>