This commit is contained in:
gsinghpal
2026-03-11 12:15:53 -04:00
parent f81e0cd918
commit db4b9aa278
1210 changed files with 173089 additions and 4044 deletions

View File

@@ -13,11 +13,13 @@
"security/ir.model.access.csv",
"data/ir_sequence_data.xml",
"data/fusion_canada_post_data.xml",
"data/ir_cron_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/report_templates.xml",
"views/menus.xml",
"views/res_config_settings_views.xml",
],

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="ir_cron_fusion_cp_refresh_tracking" model="ir.cron">
<field name="name">Canada Post: Refresh Tracking</field>
<field name="model_id" ref="model_fusion_cp_shipment"/>
<field name="state">code</field>
<field name="code">model._cron_refresh_tracking()</field>
<field name="interval_number">8</field>
<field name="interval_type">hours</field>
<field name="active">True</field>
</record>
</odoo>

View File

@@ -1,3 +1,4 @@
import base64
import logging
from datetime import datetime as dt_mod
from lxml import etree
@@ -66,6 +67,7 @@ class FusionCpShipment(models.Model):
('confirmed', 'Confirmed'),
('shipped', 'Shipped'),
('delivered', 'Delivered'),
('returned', 'Returned'),
('cancelled', 'Cancelled'),
],
string='Status',
@@ -95,10 +97,21 @@ class FusionCpShipment(models.Model):
string='Commercial Invoice',
ondelete='set null',
)
# Return label
return_tracking_number = fields.Char(
string='Return Tracking Number',
readonly=True,
copy=False,
)
return_label_attachment_id = fields.Many2one(
'ir.attachment',
string='Return Label',
ondelete='set null',
)
# Shipment details
shipping_cost = fields.Float(
shipping_cost = fields.Monetary(
string='Shipping Cost',
digits='Product Price',
currency_field='currency_id',
readonly=True,
)
service_type = fields.Char(
@@ -141,6 +154,11 @@ class FusionCpShipment(models.Model):
string='Company',
default=lambda self: self.env.company,
)
currency_id = fields.Many2one(
'res.currency',
related='company_id.currency_id',
store=True,
)
# Tracking history
tracking_event_ids = fields.One2many(
'fusion.cp.tracking.event',
@@ -369,15 +387,31 @@ class FusionCpShipment(models.Model):
delivered_date = detail.get('actual-delivery-date', '')
if delivered_date:
self.status = 'delivered'
if self._has_return_events():
self.status = 'returned'
else:
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._has_return_events():
self.status = 'returned'
elif self.status == 'confirmed' and self.tracking_event_ids:
self.status = 'shipped'
def _has_return_events(self):
"""Check if any tracking events indicate a return/RTS."""
RETURN_TYPES = {'RTS', 'RETURN', 'RTS_LABEL_PROC'}
for event in self.tracking_event_ids:
if event.event_type in RETURN_TYPES:
return True
desc = (event.event_description or '').lower()
if 'return to sender' in desc or 'item returned' in desc:
return True
return False
# ── Void & Reissue ─────────────────────────────────────
def action_void_shipment(self):
@@ -504,3 +538,182 @@ class FusionCpShipment(models.Model):
'url': '%s%s' % (base_link, self.tracking_number),
'target': 'new',
}
# ── Cron ─────────────────────────────────────────────────
@api.model
def _cron_refresh_tracking(self):
"""Auto-refresh tracking for all active (non-terminal) shipments."""
shipments = self.search([
('status', 'in', ('confirmed', 'shipped')),
('tracking_number', '!=', False),
])
_logger.info(
"Cron: refreshing tracking for %d shipments", len(shipments))
for shipment in shipments:
try:
shipment.action_refresh_tracking()
self.env.cr.commit()
except Exception as e:
self.env.cr.rollback()
_logger.warning(
"Cron: tracking refresh failed for %s: %s",
shipment.name, str(e))
# ── Return Labels ────────────────────────────────────────
def action_view_return_label(self):
return self._action_open_attachment(self.return_label_attachment_id)
def action_create_return_label(self):
"""Create a prepaid return label via Canada Post Authorized
Returns API. Bill-on-scan: charged only when the customer
uses the label at a post office.
"""
self.ensure_one()
if self.return_tracking_number:
raise ValidationError(
_("A return label has already been created "
"for this shipment."))
if not self.carrier_id:
raise ValidationError(
_("No carrier linked to this shipment."))
if not self.picking_id:
raise ValidationError(
_("No transfer linked to this shipment."))
carrier = self.carrier_id
customer = carrier.customer_number
warehouse_partner = (
self.picking_id.picking_type_id.warehouse_id.partner_id)
if not warehouse_partner:
raise ValidationError(
_("No warehouse address found. Please configure "
"a contact on the warehouse."))
if carrier.prod_environment:
base = "https://soa-gw.canadapost.ca"
else:
base = "https://ct.soa-gw.canadapost.ca"
url = "%s/rs/%s/%s/authorizedreturn" % (base, customer, customer)
xml = self._build_return_label_xml(carrier, warehouse_partner)
headers = {
'Content-Type': 'application/vnd.cpc.authreturn-v2+xml',
'Accept': 'application/vnd.cpc.authreturn-v2+xml',
'Accept-language': 'en-CA',
}
try:
resp = request(
method='POST', url=url, data=xml,
headers=headers,
auth=(carrier.username, carrier.password))
_logger.info(
"Return Label API %s%s", url, resp.status_code)
if resp.status_code not in (200, 201):
self._parse_cp_error_response(resp)
self._process_return_label_response(resp, carrier)
except ValidationError:
raise
except Exception as e:
raise ValidationError(
_("Failed to create return label: %s") % str(e))
def _build_return_label_xml(self, carrier, return_to_partner):
"""Build Authorized Return XML.
return_to_partner = warehouse address (where the return goes).
"""
ns = "http://www.canadapost.ca/ws/authreturn-v2"
root = etree.Element("authorized-return", xmlns=ns)
etree.SubElement(root, "service-code").text = "DOM.EP"
# Receiver — warehouse where the item is returned to
receiver = etree.SubElement(root, "receiver")
rec_name = etree.SubElement(receiver, "name")
rec_name.text = (return_to_partner.name or '')[:44]
rec_company = etree.SubElement(receiver, "company")
rec_company.text = (
return_to_partner.commercial_company_name
or return_to_partner.name or '')[:44]
rec_addr = etree.SubElement(receiver, "domestic-address")
etree.SubElement(rec_addr, "address-line-1").text = (
return_to_partner.street or '')[:44]
if return_to_partner.street2:
etree.SubElement(rec_addr, "address-line-2").text = (
return_to_partner.street2)[:44]
etree.SubElement(rec_addr, "city").text = (
return_to_partner.city or '')[:40]
etree.SubElement(rec_addr, "province").text = (
return_to_partner.state_id.code or '')
etree.SubElement(rec_addr, "postal-code").text = (
(return_to_partner.zip or '').replace(' ', ''))
# Parcel characteristics
parcel = etree.SubElement(root, "parcel-characteristics")
etree.SubElement(parcel, "weight").text = str(
max(self.weight or 0.5, 0.1))
# Print preferences
prefs = etree.SubElement(root, "print-preferences")
etree.SubElement(prefs, "output-format").text = (
carrier.fusion_cp_output_format or '8.5x11')
return etree.tostring(
root, xml_declaration=True, encoding='UTF-8')
def _process_return_label_response(self, resp, carrier):
"""Parse return label response, download label PDF,
store on shipment.
"""
api_resp = Response(resp)
result = api_resp.dict()
auth_return = result.get('authorized-return', result)
tracking_pin = auth_return.get('tracking-pin', '')
self.return_tracking_number = tracking_pin
# Get label artifact URL from links
links = auth_return.get('links', {})
link_list = links.get('link', [])
if isinstance(link_list, dict):
link_list = [link_list]
label_url = ''
for link in link_list:
if link.get('@rel') == 'labelDetails':
label_url = link.get('@href', '')
break
if label_url:
label_resp = request(
method='GET', url=label_url,
headers={
'Accept': 'application/pdf',
'Accept-language': 'en-CA',
},
auth=(carrier.username, carrier.password))
if label_resp.status_code == 200:
attachment = self.env['ir.attachment'].create({
'name': 'Return-Label-%s.pdf' % tracking_pin,
'type': 'binary',
'datas': base64.b64encode(
label_resp.content).decode(),
'res_model': self._name,
'res_id': self.id,
'mimetype': 'application/pdf',
})
self.return_label_attachment_id = attachment
self.message_post(
body=_("Return label created. Tracking: %s") % tracking_pin,
attachment_ids=(
self.return_label_attachment_id.ids
if self.return_label_attachment_id else []))

View File

@@ -27,17 +27,20 @@ class SaleOrder(models.Model):
fusion_cp_package_height = fields.Float(
string='Package Height', copy=False)
fusion_cp_shipment_ids = fields.One2many(
'fusion.cp.shipment',
'sale_order_id',
string='CP Shipments',
)
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)]
)
order.fusion_cp_shipment_count = len(
order.fusion_cp_shipment_ids)
def action_view_fusion_cp_shipments(self):
self.ensure_one()

View File

@@ -19,6 +19,8 @@
domain="[('status','=','shipped')]"/>
<filter name="filter_delivered" string="Delivered"
domain="[('status','=','delivered')]"/>
<filter name="filter_returned" string="Returned"
domain="[('status','=','returned')]"/>
<filter name="filter_cancelled" string="Cancelled"
domain="[('status','=','cancelled')]"/>
<group>
@@ -45,6 +47,7 @@
<field name="sale_order_id" optional="show"/>
<field name="picking_id" optional="show"/>
<field name="service_type"/>
<field name="currency_id" column_invisible="1"/>
<field name="shipping_cost" sum="Total Cost"/>
<field name="weight" optional="hide"/>
<field name="package_name" optional="hide"/>
@@ -53,6 +56,7 @@
decoration-info="status == 'draft'"
decoration-success="status in ('confirmed','shipped')"
decoration-bf="status == 'delivered'"
decoration-warning="status == 'returned'"
decoration-danger="status == 'cancelled'"
widget="badge"/>
</list>
@@ -76,11 +80,17 @@
class="btn-secondary"
icon="fa-external-link"
invisible="not tracking_number"/>
<button name="action_create_return_label" type="object"
string="Create Return Label"
class="btn-warning"
icon="fa-mail-reply"
invisible="status not in ('delivered', 'shipped') or return_tracking_number"
confirm="Return labels are a paid service (bill-on-scan). The cost will be charged to your Canada Post account when the customer uses the label. Continue?"/>
<button name="action_void_shipment" type="object"
string="Void Shipment"
class="btn-danger"
icon="fa-ban"
invisible="status in ('cancelled', 'delivered')"
invisible="status in ('cancelled', 'delivered', 'returned')"
confirm="Are you sure you want to void this shipment? This cannot be undone."/>
<button name="action_reissue_shipment" type="object"
string="Reissue Shipment"
@@ -132,6 +142,8 @@
<field name="weight"/>
<field name="package_name"
invisible="not package_name"/>
<field name="return_tracking_number"
invisible="not return_tracking_number"/>
</group>
<group string="Links">
<field name="sale_order_id"/>
@@ -155,6 +167,7 @@
<field name="full_label_attachment_id" invisible="1"/>
<field name="receipt_attachment_id" invisible="1"/>
<field name="commercial_invoice_attachment_id" invisible="1"/>
<field name="return_label_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"
@@ -168,6 +181,12 @@
icon="fa-file-pdf-o"
string="Open"
invisible="not full_label_attachment_id"/>
<span class="o_form_label" invisible="not return_label_attachment_id">Return Label</span>
<button name="action_view_return_label" type="object"
class="btn btn-link p-0"
icon="fa-file-pdf-o"
string="Open"
invisible="not return_label_attachment_id"/>
</group>
<group string="Documents">
<span class="o_form_label" invisible="not receipt_attachment_id">Receipt</span>
@@ -191,7 +210,7 @@
</group>
<group>
<field name="delivery_date"
invisible="status != 'delivered'"/>
invisible="status not in ('delivered', 'returned')"/>
</group>
</group>
<field name="tracking_event_ids" readonly="1" nolabel="1">
@@ -248,9 +267,9 @@
</field>
</record>
<!-- Window Action -->
<!-- Window Actions -->
<record id="action_fusion_cp_shipment" model="ir.actions.act_window">
<field name="name">Shipments</field>
<field name="name">All 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"/>
@@ -263,4 +282,36 @@
</field>
</record>
<record id="action_fusion_cp_shipment_confirmed" model="ir.actions.act_window">
<field name="name">Confirmed</field>
<field name="res_model">fusion.cp.shipment</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('status', '=', 'confirmed')]</field>
<field name="search_view_id" ref="view_fusion_cp_shipment_search"/>
</record>
<record id="action_fusion_cp_shipment_shipped" model="ir.actions.act_window">
<field name="name">Shipped</field>
<field name="res_model">fusion.cp.shipment</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('status', '=', 'shipped')]</field>
<field name="search_view_id" ref="view_fusion_cp_shipment_search"/>
</record>
<record id="action_fusion_cp_shipment_delivered" model="ir.actions.act_window">
<field name="name">Delivered</field>
<field name="res_model">fusion.cp.shipment</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('status', '=', 'delivered')]</field>
<field name="search_view_id" ref="view_fusion_cp_shipment_search"/>
</record>
<record id="action_fusion_cp_shipment_returned" model="ir.actions.act_window">
<field name="name">Returned</field>
<field name="res_model">fusion.cp.shipment</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('status', '=', 'returned')]</field>
<field name="search_view_id" ref="view_fusion_cp_shipment_search"/>
</record>
</odoo>

View File

@@ -6,13 +6,43 @@
web_icon="fusion_canada_post,static/description/icon.png"
sequence="85"/>
<!-- Shipments -->
<!-- Shipments (parent — no action) -->
<menuitem id="menu_fusion_cp_shipments"
name="Shipments"
parent="menu_fusion_cp_root"
action="action_fusion_cp_shipment"
sequence="10"/>
<!-- Shipments sub-menus -->
<menuitem id="menu_fusion_cp_shipments_all"
name="All Shipments"
parent="menu_fusion_cp_shipments"
action="action_fusion_cp_shipment"
sequence="1"/>
<menuitem id="menu_fusion_cp_shipments_confirmed"
name="Confirmed"
parent="menu_fusion_cp_shipments"
action="action_fusion_cp_shipment_confirmed"
sequence="10"/>
<menuitem id="menu_fusion_cp_shipments_shipped"
name="Shipped"
parent="menu_fusion_cp_shipments"
action="action_fusion_cp_shipment_shipped"
sequence="20"/>
<menuitem id="menu_fusion_cp_shipments_delivered"
name="Delivered"
parent="menu_fusion_cp_shipments"
action="action_fusion_cp_shipment_delivered"
sequence="30"/>
<menuitem id="menu_fusion_cp_shipments_returned"
name="Returned"
parent="menu_fusion_cp_shipments"
action="action_fusion_cp_shipment_returned"
sequence="40"/>
<!-- Configuration parent -->
<menuitem id="menu_fusion_cp_config"
name="Configuration"

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ════════════════════════════════════════════════════════
Sale Order PDF — Shipping Information
════════════════════════════════════════════════════════ -->
<template id="report_saleorder_shipping_info"
inherit_id="sale.report_saleorder_document">
<xpath expr="//div[@name='so_total_summary']" position="after">
<t t-set="cp_shipments" t-value="doc.fusion_cp_shipment_ids.filtered(lambda s: s.status != 'cancelled')"/>
<div t-if="cp_shipments" class="mt-4">
<h5><strong>Shipping Information</strong></h5>
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Tracking Number</th>
<th>Service</th>
<th class="text-end">Weight</th>
<th class="text-end">Cost</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr t-foreach="cp_shipments" t-as="shipment">
<td><span t-field="shipment.tracking_number"/></td>
<td><span t-field="shipment.service_type"/></td>
<td class="text-end">
<span t-field="shipment.weight"/> kg
</td>
<td class="text-end">
<span t-field="shipment.shipping_cost"
t-options="{'widget': 'monetary', 'display_currency': shipment.currency_id}"/>
</td>
<td><span t-field="shipment.status"/></td>
</tr>
</tbody>
</table>
</div>
</xpath>
</template>
<!-- ════════════════════════════════════════════════════════
Invoice PDF — Shipping Information
════════════════════════════════════════════════════════ -->
<template id="report_invoice_shipping_info"
inherit_id="account.report_invoice_document">
<xpath expr="//div[@id='payment_term']" position="before">
<t t-set="sale_orders" t-value="o.line_ids.sale_line_ids.order_id"/>
<t t-set="cp_shipments" t-value="sale_orders.mapped('fusion_cp_shipment_ids').filtered(lambda s: s.status != 'cancelled')" t-if="sale_orders"/>
<div t-if="cp_shipments" class="mt-4">
<h5><strong>Shipping Information</strong></h5>
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Tracking Number</th>
<th>Service</th>
<th class="text-end">Weight</th>
<th class="text-end">Cost</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr t-foreach="cp_shipments" t-as="shipment">
<td><span t-field="shipment.tracking_number"/></td>
<td><span t-field="shipment.service_type"/></td>
<td class="text-end">
<span t-field="shipment.weight"/> kg
</td>
<td class="text-end">
<span t-field="shipment.shipping_cost"
t-options="{'widget': 'monetary', 'display_currency': shipment.currency_id}"/>
</td>
<td><span t-field="shipment.status"/></td>
</tr>
</tbody>
</table>
</div>
</xpath>
</template>
</odoo>