import base64 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_shipping.api.canada_post.response import Response _logger = logging.getLogger(__name__) class FusionShipment(models.Model): _name = 'fusion.shipment' _description = 'Fusion 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, ) carrier_type = fields.Selection( [ ('canada_post', 'Canada Post'), ('ups', 'UPS'), ('ups_rest', 'UPS (REST)'), ('fedex', 'FedEx'), ('fedex_rest', 'FedEx (REST)'), ('dhl', 'DHL Express'), ('dhl_rest', 'DHL Express (REST)'), ], string='Carrier Type', readonly=True, tracking=True, help='The shipping carrier used for this shipment.', ) 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'), ('returned', 'Returned'), ('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', ) # 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.Monetary( string='Shipping Cost', currency_field='currency_id', 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, ) currency_id = fields.Many2one( 'res.currency', related='company_id.currency_id', store=True, ) # Tracking history tracking_event_ids = fields.One2many( 'fusion.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.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.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: 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): """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_carrier(self): """Open the carrier's tracking website in a new tab.""" self.ensure_one() if not self.tracking_number: raise ValidationError( _("No tracking number available for this shipment.")) # Default tracking URLs per carrier type DEFAULT_TRACKING = { 'canada_post': 'https://www.canadapost.ca/trackweb/en#/resultList?searchFor=', 'ups': 'https://www.ups.com/track?tracknum=', 'ups_rest': 'https://www.ups.com/track?tracknum=', 'fedex': 'https://www.fedex.com/wtrk/track/?trknbr=', 'fedex_rest': 'https://www.fedex.com/wtrk/track/?trknbr=', 'dhl': 'https://www.dhl.com/en/express/tracking.html?AWB=', 'dhl_rest': 'https://www.dhl.com/en/express/tracking.html?AWB=', } base_link = '' if self.carrier_id and self.carrier_id.tracking_link: base_link = self.carrier_id.tracking_link elif self.carrier_type: base_link = DEFAULT_TRACKING.get(self.carrier_type, '') if not base_link: base_link = 'https://www.google.com/search?q=' return { 'type': 'ir.actions.act_url', '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 []))