# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) """Phase C — extend fusion.shipment with dimension fields. fusion_shipping's native model has `weight` but no length/width/height. The plating workflow needs all four captured at receiving time so the shipment record carries everything the carrier API would want. Added here (not in fusion_shipping) to keep the upstream module untouched. """ import logging from odoo import _, api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class FusionShipment(models.Model): _inherit = 'fusion.shipment' x_fc_length = fields.Float(string='Length', digits=(10, 2)) x_fc_width = fields.Float(string='Width', digits=(10, 2)) x_fc_height = fields.Float(string='Height', digits=(10, 2)) x_fc_dim_uom = fields.Selection( [('in', 'in'), ('cm', 'cm')], string='Dim UoM', default='in', ) x_fc_weight_uom = fields.Selection( [('lb', 'lb'), ('kg', 'kg')], string='Weight UoM', default='lb', ) # Multi-piece label storage. label_attachment_id remains the # primary (first box) for backward-compat; this M2M holds the full # set so the operator can download any box's label individually. x_fc_label_attachment_ids = fields.Many2many( 'ir.attachment', 'fusion_shipment_label_attachment_rel', 'shipment_id', 'attachment_id', string='All Labels', copy=False, ) # Separate slot for the ZPL version of the label. FedEx (and most # carriers) return one format per ship-call; the primary # label_attachment_id holds whatever the carrier was configured to # return (we default to PDF). This field is populated only when a # ZPL variant has been fetched explicitly. Two slots = two smart # buttons on the receiving form, one per format. x_fc_label_zpl_attachment_id = fields.Many2one( 'ir.attachment', string='ZPL Label', copy=False, help='ZPL/ZPLII version of the shipping label. Empty unless ' 'the carrier returned ZPL (or a ZPL fetch was triggered ' 'separately).', ) def action_view_label_zpl(self): """Download the ZPL label for direct-to-thermal-printer use. ZPL is text/plain — the PDF preview dialog can't render it, so this stays on the legacy download path (no preview, just a file the operator sends to their Zebra). Mirrors fp.receiving's action_print_label_zpl so the button exists on both forms. """ self.ensure_one() if not self.x_fc_label_zpl_attachment_id: raise UserError(_( 'No ZPL label on this shipment. Use the PDF version, ' 'or switch the carrier label format to ZPLII and ' 'regenerate.' )) return self.x_fc_label_zpl_attachment_id.action_fusion_preview( title=self.x_fc_label_zpl_attachment_id.name or 'ZPL Label', model_name=self._name, record_ids=self.id, ) # Phase C — resolved carrier tracking URL with the tracking number # substituted into the carrier.tracking_url template. Used by the # shipment_labeled email template and any other place that needs a # working clickable tracking link. Single source of truth so both # email + portal stay consistent. x_fc_tracking_url = fields.Char( string='Tracking URL (resolved)', compute='_compute_x_fc_tracking_url', help='carrier.tracking_url with replaced ' 'by tracking_number. Empty when the carrier has no URL ' 'template or there is no tracking number yet.', ) @api.depends('carrier_id.tracking_url', 'tracking_number') def _compute_x_fc_tracking_url(self): for rec in self: tpl = (rec.carrier_id.tracking_url or '') if rec.carrier_id else '' tn = rec.tracking_number or '' if not tpl or not tn: rec.x_fc_tracking_url = '' continue placeholder = '' if placeholder in tpl: rec.x_fc_tracking_url = tpl.replace(placeholder, tn) else: rec.x_fc_tracking_url = tpl + tn def write(self, vals): """Sync the carrier tracking number + label to the customer portal job whenever they land on the shipment. The portal_job currently shows `delivery.name` as 'tracking' — wrong; the customer wants the carrier's actual tracking number so the clickable link goes to FedEx/UPS/etc.""" res = super().write(vals) sync_keys = {'tracking_number', 'label_attachment_id', 'status'} if not sync_keys & set(vals.keys()): return res for ship in self: try: ship._fp_sync_to_portal_job() except Exception as e: _logger.warning( 'Shipment %s: portal-job sync failed: %s', ship.name, e, ) return res def _fp_sync_to_portal_job(self): """Walk shipment → SO → fp.job → fusion.plating.portal.job and push the carrier tracking number + label + delivery's packing slip onto the customer-facing record. """ self.ensure_one() if not self.sale_order_id: return Job = self.env.get('fp.job') if Job is None: return jobs = Job.sudo().search( [('sale_order_id', '=', self.sale_order_id.id)], ) if not jobs: return for job in jobs: portal = job.portal_job_id if not portal: continue vals = {} if self.tracking_number and portal.tracking_ref != self.tracking_number: vals['tracking_ref'] = self.tracking_number # Packing slip lives on the linked fp.delivery, not the # shipment. Walk it lazily here so a packing-slip generated # earlier on the delivery also lands on the portal job. delivery = job.delivery_id if (delivery and 'packing_list_attachment_id' in delivery._fields and delivery.packing_list_attachment_id and portal.packing_list_attachment_id != delivery.packing_list_attachment_id): vals['packing_list_attachment_id'] = ( delivery.packing_list_attachment_id.id ) if vals: portal.sudo().write(vals) # State is now derived centrally — see # fusion.plating.portal.job._fp_recompute_portal_state. It # only promotes to 'shipped' when every linked WO is done # AND the shipment.status is 'shipped' or 'delivered'. A # FedEx label booked early (tracking number without the # carrier actually picking up) no longer leapfrogs the # shop floor. if hasattr(portal, '_fp_recompute_portal_state'): portal.sudo()._fp_recompute_portal_state()