# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. import base64 import logging from types import SimpleNamespace import requests from markupsafe import Markup from odoo import api, fields, models, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) # labelary.com — free ZPL→PDF rasterization service. 8dpmm = 203dpi # (ZD450 default), label size 4x6 in. No API key required for the # typical low-volume use case (limit: ~5 req/s anonymous). PDF output # is requested via the Accept header. _LABELARY_URL = 'https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/' _LABELARY_TIMEOUT = 10 class FpReceiving(models.Model): """Parts receiving record. Created automatically when a sale order is confirmed. Tracks quantity verification, condition inspection, and damage logging for customer parts arriving at the shop. """ _name = 'fp.receiving' _description = 'Fusion Plating — Receiving' _inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin'] _order = 'received_date desc, id desc' name = fields.Char(string='Reference', readonly=True, copy=False, default='New') sale_order_id = fields.Many2one( 'sale.order', string='Sale Order', required=True, ondelete='restrict', tracking=True, ) partner_id = fields.Many2one( 'res.partner', string='Customer', related='sale_order_id.partner_id', store=True, readonly=True, ) po_number = fields.Char( string='Customer PO #', related='sale_order_id.x_fc_po_number', store=True, readonly=True, ) received_by_id = fields.Many2one( 'res.users', string='Received By', default=lambda self: self.env.user, tracking=True, ) received_date = fields.Datetime( string='Received Date', default=fields.Datetime.now, tracking=True, ) # Sub 8 — simplified state machine. Receiving = box count only. The # part-level inspection that used to happen here now lives on # fp.racking.inspection (racking crew does it when they open the # boxes). Legacy state values are kept in the Selection so existing # records from before Sub 8 don't raise on upgrade. state = fields.Selection( [ ('draft', 'Awaiting Parts'), ('counted', 'Counted'), ('closed', 'Closed'), # Legacy values — kept readable, never written by new code. # 2026-05-20: `staged` collapsed away. The state had zero # downstream effect (same SO mapping as counted, no field # captured, action_mark_staged just flipped a flag) and # median dwell was 11 sec — pure ceremony. Pre-migrate # advances any existing 'staged' record to 'closed'. ('staged', 'Staged (legacy)'), ('inspecting', 'Inspecting (legacy)'), ('accepted', 'Accepted (legacy)'), ('discrepancy', 'Discrepancy (legacy)'), ('resolved', 'Resolved (legacy)'), ], string='Status', default='draft', tracking=True, required=True, ) box_count_in = fields.Integer( string='Boxes Received', tracking=True, help='Number of boxes the receiver counted when the truck ' 'dropped off. Receiving is box count only — parts are ' 'inspected by the racking crew when boxes are opened.', ) expected_qty = fields.Integer(string='Expected Qty', help='Total quantity expected from the sale order.') received_qty = fields.Integer(string='Received Qty', help='Total quantity actually received.') qty_match = fields.Boolean( string='Qty Match', compute='_compute_qty_match', store=True, ) carrier_name = fields.Char( string='Carrier (Legacy)', help='Legacy free-text carrier field. Kept for back-compat with ' 'records that predate the carrier_id M2O. New records use ' 'x_fc_carrier_id instead.', ) carrier_tracking = fields.Char(string='Inbound Tracking #') # ---- Phase A — outbound carrier + shipment link ---------------------- # The receiver picks the OUTBOUND (return) carrier here; clicking # "Create Outbound Shipment" creates a draft fusion.shipment which # owns weight, dimensions, label PDF, tracking. The shop's workflow # generates the return label at receiving time so the printed label # can travel with the parts. x_fc_carrier_id = fields.Many2one( 'delivery.carrier', string='Outbound Carrier', tracking=True, ondelete='set null', help='Who picks up the parts when work is done. Used to generate ' 'the return shipping label on the linked Outbound Shipment.', ) x_fc_outbound_service_type = fields.Selection( selection='_fp_get_service_type_selection', string='Service Type', tracking=True, help='Override the carrier default for this shipment. Leave ' 'blank to use the carrier-level default (typically Ground ' 'or Priority depending on configuration). Pick a faster ' 'tier (e.g. Priority Overnight) when the customer is ' 'paying for expedited delivery.', ) # Curated FedEx services for a Canadian B2B plating shop. The # carrier-level selection (~38 options) is overwhelming and mostly # noise — frieght tiers want 150+ lb, regional services don't apply # to CA-origin shipments, distribution-program services need extra # account config. Sweep against the live sandbox (see # scripts/fp_fedex_service_matrix.py) confirmed these 12 are the # only ones realistically usable for parts shipments. If a future # contract enables more, append here. _FP_USABLE_FEDEX_SERVICES = ( # CA domestic 'FEDEX_GROUND', # Cheapest, 1-7 days 'FEDEX_EXPRESS_SAVER', # 3-day economy 'FEDEX_2_DAY', # 2 business days 'FEDEX_2_DAY_AM', # 2 business days, 10:30am 'STANDARD_OVERNIGHT', # Next day, end of day 'PRIORITY_OVERNIGHT', # Next day, 10:30am 'FIRST_OVERNIGHT', # Next day, 8:00am # International (CA -> US / EU / APAC) 'FEDEX_INTERNATIONAL_CONNECT_PLUS', # Mid-tier intl 'INTERNATIONAL_ECONOMY', # 4-5 day intl economy 'FEDEX_INTERNATIONAL_PRIORITY', # 1-3 day intl 'FEDEX_INTERNATIONAL_PRIORITY_EXPRESS', # 1-3 day intl premium 'INTERNATIONAL_FIRST', # Earliest intl available ) @api.model def _fp_get_service_type_selection(self): """Curated FedEx service selection for the receiving form. Pulls labels from the carrier's full selection (so they match whatever fusion_shipping ships) but filters to the codes in _FP_USABLE_FEDEX_SERVICES. Order in the dropdown follows the tuple — cheapest CA-domestic first, premium international last. Empty list when fusion_shipping isn't installed. """ Carrier = self.env.get('delivery.carrier') if Carrier is None: return [] field = Carrier._fields.get('fedex_rest_service_type') if not field: return [] labels = dict(field.selection) return [ (code, labels.get(code, code)) for code in self._FP_USABLE_FEDEX_SERVICES if code in labels ] x_fc_outbound_shipment_id = fields.Many2one( 'fusion.shipment', string='Outbound Shipment', tracking=True, ondelete='set null', copy=False, help='The shipment record carrying weight, dimensions, label PDF, ' 'and tracking. Created via the "Create Outbound Shipment" ' 'button on this form.', ) x_fc_outbound_shipment_count = fields.Integer( compute='_compute_x_fc_outbound_shipment_count', ) x_fc_has_label = fields.Boolean( compute='_compute_x_fc_has_label', help='True when the linked outbound shipment has a label PDF ' 'attached. Drives the Print Label smart-button visibility.', ) x_fc_has_label_zpl = fields.Boolean( compute='_compute_x_fc_has_label', help='True when the linked outbound shipment has a ZPL label ' 'attached. Drives the Print ZPL smart-button visibility.', ) x_fc_shipping_quote_html = fields.Html( string='Shipping Quote', readonly=True, copy=False, sanitize=False, help='Estimated cost + delivery date from the carrier API. ' 'Click "Refresh Quote" to fetch the latest. Reflects the ' 'currently-selected Service Type, weight, and dimensions.', ) @api.depends( 'x_fc_outbound_shipment_id.label_attachment_id', 'x_fc_outbound_shipment_id.x_fc_label_zpl_attachment_id', ) def _compute_x_fc_has_label(self): for rec in self: ship = rec.x_fc_outbound_shipment_id rec.x_fc_has_label = bool(ship and ship.label_attachment_id) rec.x_fc_has_label_zpl = bool( ship and ship.x_fc_label_zpl_attachment_id ) # ---- Phase C — Outbound packaging fields ----------------------------- # Operator enters these at receiving time so the shipping label can be # generated immediately. Pushed to the linked fusion.shipment when # action_generate_outbound_label fires. x_fc_weight = fields.Float( string='Weight', digits=(10, 3), tracking=True, help='Total package weight for outbound shipping. Used at label ' 'generation time.', ) x_fc_weight_uom = fields.Selection( [('lb', 'lb'), ('kg', 'kg')], string='Weight UoM', default='lb', tracking=True, ) x_fc_length = fields.Float( string='Length', digits=(10, 2), tracking=True, ) x_fc_width = fields.Float( string='Width', digits=(10, 2), tracking=True, ) x_fc_height = fields.Float( string='Height', digits=(10, 2), tracking=True, ) x_fc_dim_uom = fields.Selection( [('in', 'in'), ('cm', 'cm')], string='Dim UoM', default='in', tracking=True, ) # Back-link to the synthetic stock.picking used at API-call time. # Set by _fp_build_shipping_picking; kept for debugging / traceability. x_fc_shipping_picking_id = fields.Many2one( 'stock.picking', string='Shipping Picking', readonly=True, copy=False, help='The internal picking record used to drive the carrier API ' 'call. Hidden from operator UIs; kept for traceability.', ) # Per-package detail for multi-piece shipments (MPS). Each row # produces one stock.package + one carrier label. Single-box flow # still works: when no rows are entered, _fp_build_shipping_picking # falls back to the receiving's top-level weight/dim fields. x_fc_outbound_package_ids = fields.One2many( 'fp.outbound.package', 'receiving_id', string='Outbound Packages', ) @api.depends('x_fc_outbound_shipment_id') def _compute_x_fc_outbound_shipment_count(self): for rec in self: rec.x_fc_outbound_shipment_count = ( 1 if rec.x_fc_outbound_shipment_id else 0 ) @api.onchange('x_fc_carrier_id') def _onchange_x_fc_carrier_id(self): """Propagate carrier change to a linked DRAFT shipment. Once a shipment is confirmed / shipped / delivered, we leave it alone — changing the carrier on a non-draft shipment is a destructive operation that needs explicit user intent (cancel + re-create), not a side-effect of editing the receiving form. """ for rec in self: ship = rec.x_fc_outbound_shipment_id if ship and ship.status == 'draft' and rec.x_fc_carrier_id: ship.carrier_id = rec.x_fc_carrier_id.id # ---- Actions ---------------------------------------------------------- def action_create_outbound_shipment(self): """Create a draft fusion.shipment linked to this receiving. Idempotent: if a shipment is already linked, just open it. Pre-fills carrier_type, sender + recipient name/address, and service_type from the carrier's defaults so the operator never sees an empty form. """ self.ensure_one() if self.x_fc_outbound_shipment_id: return self.action_view_outbound_shipment() if 'fusion.shipment' not in self.env: raise UserError(_( 'fusion_shipping module is not installed. ' 'Cannot create an outbound shipment.' )) vals = { 'sale_order_id': self.sale_order_id.id if self.sale_order_id else False, 'carrier_id': self.x_fc_carrier_id.id if self.x_fc_carrier_id else False, 'status': 'draft', } vals.update(self._fp_resolve_shipment_defaults()) shipment = self.env['fusion.shipment'].sudo().create(vals) self.x_fc_outbound_shipment_id = shipment.id self.message_post(body=Markup(_( 'Outbound shipment %s created (draft).' )) % shipment.name) return self.action_view_outbound_shipment() def _fp_resolve_shipment_defaults(self): """Build the dict of fusion.shipment field values that can be derived from the receiving's context (carrier, SO, company). Used at creation time and re-used by the generate-label flow to refresh fields if the operator changes carrier mid-flow. """ self.ensure_one() vals = {} carrier = self.x_fc_carrier_id # carrier_type — Selection on fusion.shipment ('canada_post', # 'ups_rest', 'fedex_rest', etc.). Map from delivery_type by # stripping the 'fusion_' prefix (e.g. 'fusion_fedex_rest' → # 'fedex_rest'). Selection on the model may not include every # value our delivery_type uses; defensive against missing keys. if carrier and carrier.delivery_type: dt = carrier.delivery_type ct = dt[len('fusion_'):] if dt.startswith('fusion_') else dt Ship = self.env.get('fusion.shipment') if Ship is not None: valid_types = dict( Ship._fields['carrier_type'].selection ) if ct in valid_types: vals['carrier_type'] = ct # service_type — carrier-specific. FedEx REST stores it on # carrier.fedex_rest_service_type; UPS REST has its own field. # Read whichever attribute exists. if carrier: for attr in ('fedex_rest_service_type', 'ups_rest_service_type', 'dhl_rest_service_type'): if attr in carrier._fields and carrier[attr]: vals['service_type'] = carrier[attr] break # Sender from company partner; recipient from SO shipping address. company_partner = self.env.company.partner_id vals['sender_name'] = company_partner.name or '' vals['sender_address'] = self._fp_format_address(company_partner) so = self.sale_order_id if so: recipient = so.partner_shipping_id or so.partner_id vals['recipient_name'] = recipient.name or '' vals['recipient_address'] = self._fp_format_address(recipient) return vals def _fp_format_address(self, partner): """Single-line address string for the shipment record. fusion.shipment.sender_address / recipient_address are plain Char; we just need a readable rendering.""" if not partner: return '' parts = [partner.street, partner.street2, partner.city, partner.state_id.code if partner.state_id else False, partner.zip, partner.country_id.name if partner.country_id else False] return ', '.join(p for p in parts if p) def action_view_outbound_shipment(self): self.ensure_one() if not self.x_fc_outbound_shipment_id: return False return { 'type': 'ir.actions.act_window', 'name': self.x_fc_outbound_shipment_id.name, 'res_model': 'fusion.shipment', 'res_id': self.x_fc_outbound_shipment_id.id, 'view_mode': 'form', 'target': 'current', } # ---- Phase C — Generate Outbound Label ------------------------------- def action_generate_outbound_label(self): """Open the confirmation wizard before the actual API call. Two guards live here so the user can't accidentally bill themselves for duplicate shipments: 1. If a label is already attached to the linked shipment, refuse to regenerate — operator must void the shipment first. 2. Otherwise pop fp.label.generate.wizard so the operator confirms carrier + service tier + weight before any API call. The wizard's action_confirm calls _fp_actually_generate_outbound_label. """ self.ensure_one() self._fp_validate_label_inputs() if self.x_fc_outbound_shipment_id \ and self.x_fc_outbound_shipment_id.label_attachment_id: raise UserError(_( 'A shipping label already exists for this receiving ' '(shipment %s). Void that shipment first if you need ' 'to regenerate — otherwise every click would create a ' 'new billable FedEx shipment with its own tracking ' 'number.' ) % self.x_fc_outbound_shipment_id.name) carrier = self.x_fc_carrier_id if carrier.delivery_type == 'fixed': return self._fp_open_manual_label_wizard(note=_( 'Carrier "%s" has no API integration configured. Enter ' 'the label PDF and tracking number below to record the ' 'shipment manually.' ) % carrier.name) Wizard = self.env['fp.label.generate.wizard'] wiz = Wizard.create(Wizard._fp_default_from_receiving(self.env, self)) return { 'type': 'ir.actions.act_window', 'name': _('Generate Label — %s') % self.name, 'res_model': 'fp.label.generate.wizard', 'res_id': wiz.id, 'view_mode': 'form', 'target': 'new', } def _fp_actually_generate_outbound_label(self): """Make the actual carrier API call to create the shipping label. Called by fp.label.generate.wizard.action_confirm after the operator has confirmed service + weight in the wizard. Same fall-through behaviour as before: API failure drops to the manual-label wizard with the error pre-filled. """ self.ensure_one() self._fp_validate_label_inputs() carrier = self.x_fc_carrier_id if carrier.delivery_type == 'fixed': return self._fp_open_manual_label_wizard(note=_( 'Carrier "%s" has no API integration configured. Enter ' 'the label PDF and tracking number below to record the ' 'shipment manually.' ) % carrier.name) # Ensure the shipment exists before we attempt the API call. if not self.x_fc_outbound_shipment_id: self.action_create_outbound_shipment() # Push the packaging info onto the shipment so it's the source # of truth post-generation. self._fp_sync_packaging_to_shipment() try: picking = self._fp_build_shipping_picking() # Per-shipment service override (e.g. Priority Overnight) # rides through to the carrier API via context. Empty # falls back to the carrier default. See # fusion_shipping.fusion_fedex_rest_send_shipping for the # consumer. ship_carrier = carrier if self.x_fc_outbound_service_type: ship_carrier = carrier.with_context( fp_service_type_override=self.x_fc_outbound_service_type, ) shipping_data = ship_carrier.send_shipping(picking) self._fp_apply_shipping_result(picking, shipping_data) except UserError: raise except Exception as e: _logger.warning( 'Receiving %s: outbound label API call failed: %s', self.name, e, ) return self._fp_open_manual_label_wizard(note=_( 'Carrier API call failed:\n %s\n\nEnter the label ' 'PDF and tracking number below to record the shipment ' 'manually.' ) % str(e)) return self.action_view_outbound_shipment() def _fp_validate_label_inputs(self): """Gate: required inputs before label generation.""" self.ensure_one() if not self.x_fc_carrier_id: raise UserError(_( 'Pick an Outbound Carrier before generating a label.' )) if not self.x_fc_weight or self.x_fc_weight <= 0: raise UserError(_( 'Enter the Weight before generating a label.' )) if not self.sale_order_id: raise UserError(_( 'Receiving "%s" is not linked to a sale order — ' 'cannot generate a shipping label.' ) % self.name) if not self.sale_order_id.partner_shipping_id \ and not self.sale_order_id.partner_id: raise UserError(_( 'Sale order has no shipping address. Set one on ' '%s before generating a label.' ) % self.sale_order_id.name) def _fp_open_manual_label_wizard(self, note=''): """Open the small manual-entry wizard for label PDF + tracking.""" self.ensure_one() # Ensure the shipment exists so the wizard has a target to write to. if not self.x_fc_outbound_shipment_id: self.action_create_outbound_shipment() Wizard = self.env.get('fp.label.manual.wizard') if Wizard is None: raise UserError(_( 'Manual label wizard is not installed. Upgrade ' 'fusion_plating_receiving.' )) wiz = Wizard.create({ 'receiving_id': self.id, 'note': note or '', }) return { 'type': 'ir.actions.act_window', 'name': _('Enter Label Manually — %s') % self.name, 'res_model': Wizard._name, 'res_id': wiz.id, 'view_mode': 'form', 'target': 'new', } def _fp_sync_packaging_to_shipment(self): """Copy weight + dimensions from the receiving to the linked fusion.shipment so the shipment record carries the values used for label generation.""" self.ensure_one() ship = self.x_fc_outbound_shipment_id if not ship: return vals = {} if self.x_fc_weight: vals['weight'] = self.x_fc_weight if 'x_fc_length' in ship._fields: if self.x_fc_length: vals['x_fc_length'] = self.x_fc_length if self.x_fc_width: vals['x_fc_width'] = self.x_fc_width if self.x_fc_height: vals['x_fc_height'] = self.x_fc_height if self.x_fc_dim_uom: vals['x_fc_dim_uom'] = self.x_fc_dim_uom if self.x_fc_weight_uom: vals['x_fc_weight_uom'] = self.x_fc_weight_uom if vals: ship.sudo().write(vals) def _fp_build_shipping_picking(self): """Synthesize a stock.picking just to carry the data needed by carrier.send_shipping. The picking is auto-validated to 'done' state so it doesn't sit as draft in operator views. Idempotent: if a prior call left a non-validated picking on x_fc_shipping_picking_id (e.g. the API call crashed before reaching button_validate), cancel it before building a fresh one. Without this guard, every retry of "Generate Outbound Label" leaks another WH/OUT picking into the Ready queue. """ self.ensure_one() prior = self.x_fc_shipping_picking_id if prior and prior.state not in ('done', 'cancel'): try: prior.sudo().action_cancel() except Exception: _logger.warning( 'Receiving %s: could not cancel stale shipping ' 'picking %s (state=%s); leaving it in place.', self.name, prior.name, prior.state, ) Picking = self.env['stock.picking'].sudo() warehouse = self.env['stock.warehouse'].sudo().search( [('company_id', '=', self.env.company.id)], limit=1, ) if not warehouse: raise UserError(_( 'No warehouse configured for the company. Configure ' 'one in Settings > Warehouses before generating labels.' )) picking_type = warehouse.out_type_id if not picking_type: raise UserError(_( 'Warehouse "%s" has no outgoing picking type.' ) % warehouse.name) so = self.sale_order_id partner = so.partner_shipping_id or so.partner_id # Use the first SO line's product as the synthetic move's product # (carrier APIs read product info for dimensions / customs forms). product = (so.order_line and so.order_line[0].product_id) or self.env.ref( 'product.product_product_4', raise_if_not_found=False, ) if not product: raise UserError(_( 'No product available to synthesize the shipping picking.' )) picking = Picking.create({ 'partner_id': partner.id, 'picking_type_id': picking_type.id, 'origin': so.name, 'sale_id': so.id, 'carrier_id': self.x_fc_carrier_id.id, 'move_ids': [(0, 0, { # Odoo 19 dropped stock.move.name; description_picking # replaces it (see CLAUDE.md "stock.move.name removed"). 'description_picking': 'Outbound %s' % (self.name or ''), 'product_id': product.id, 'product_uom_qty': 1, 'product_uom': product.uom_id.id, 'location_id': picking_type.default_location_src_id.id, 'location_dest_id': picking_type.default_location_dest_id.id, })], }) # Force the picking's weight so the API helper reads our value # instead of the computed (zero) weight from the synthetic move. if 'weight' in picking._fields: picking.write({'weight': self.x_fc_weight}) # Confirm + assign so move_lines exist; we then pre-pack them # into one stock.package carrying the operator-entered weight + # the carrier's default package type. Without an explicit # package, _get_packages_from_picking falls back to weight_bulk # which reads from product.weight (always 0 for our synthetic # move) → FedEx rejects with "weight 0.0 lb". Setting # package_type_id makes DeliveryPackage.packaging_type resolve # to the carrier-specific shipper_package_code (e.g. # 'YOUR_PACKAGING' for FedEx). picking.action_confirm() try: picking.action_assign() except Exception: pass Package = self.env.get('stock.package') if Package is not None and picking.move_line_ids: default_pkg_type = self._fp_resolve_carrier_default_package_type() # Build the list of (weight, dimensions) tuples — one per # outbound package. Multi-piece shipments use the per-row # data from x_fc_outbound_package_ids; single-piece falls # back to the receiving's top-level weight/dim fields. rows = self.x_fc_outbound_package_ids.filtered( lambda r: (r.weight or 0) > 0 ) if not rows: # Synthesize one virtual row from the top-level fields. rows = [type('Row', (), { 'weight': self.x_fc_weight, 'length': self.x_fc_length, 'width': self.x_fc_width, 'height': self.x_fc_height, 'id': False, })()] ml = picking.move_line_ids[0] packages = Package for row in rows: pkg_vals = {'shipping_weight': row.weight or 0} if default_pkg_type: pkg_vals['package_type_id'] = default_pkg_type.id pkg = Package.sudo().create(pkg_vals) packages |= pkg # Spread move_line qty across packages via result_package_id. # Stock's pack flow allows multiple move lines, but our move # has a single line with qty=1. For multi-box, we split the # move_line by creating extra lines (one per package). if len(packages) == 1: ml.result_package_id = packages[0].id else: # First package keeps the existing move_line. ml.result_package_id = packages[0].id Move = picking.move_ids[0] if picking.move_ids else False if Move: MoveLine = self.env['stock.move.line'].sudo() for pkg in packages[1:]: MoveLine.create({ 'move_id': Move.id, 'picking_id': picking.id, 'product_id': Move.product_id.id, 'product_uom_id': Move.product_uom.id, 'quantity': 1, 'location_id': Move.location_id.id, 'location_dest_id': Move.location_dest_id.id, 'result_package_id': pkg.id, }) self.x_fc_shipping_picking_id = picking.id return picking def _fp_resolve_carrier_default_package_type(self): """Return the stock.package.type to use for the synthetic outbound package. Reads the carrier's per-provider default (e.g. fedex_rest_default_package_type_id). Returns False when no default is configured — the API call will then fail with a clear PACKAGINGTYPE error pointing the admin at the setup. """ self.ensure_one() carrier = self.x_fc_carrier_id if not carrier: return False # Field name pattern is _default_package_type_id # for the FedEx REST / UPS REST / etc. integrations. field_name = '%s_default_package_type_id' % ( carrier.delivery_type or '' ) # Strip the 'fusion_' prefix used by fusion_shipping. if field_name.startswith('fusion_'): field_name = field_name[len('fusion_'):] if field_name in carrier._fields: return carrier[field_name] return False def _fp_apply_shipping_result(self, picking, shipping_data): """Copy tracking + label(s) from the picking back to the linked fusion.shipment AND to the per-package rows for multi-piece shipments. shipping_data is the list returned by carrier.send_shipping — `[{exact_price, tracking_number}, ...]`, one dict per package, in submission order. Multi-piece (MPS): walks shipping_data alongside the picking's packages and writes per-package tracking + label_attachment back onto the matching fp.outbound.package row. The shipment-level tracking_number stores the first package's tracking (so the chatter / portal / notification still has a single primary ref). """ self.ensure_one() ship = self.x_fc_outbound_shipment_id if not ship: return # All label attachments uploaded to the picking by the upstream # send_shipping. PDF for PDF mode, application/zpl-ish for ZPLII. # We accept any attachment created on this picking by the API # call (the upstream code uses message_post which creates them). label_atts = self.env['ir.attachment'].sudo().search([ ('res_model', '=', 'stock.picking'), ('res_id', '=', picking.id), ], order='id asc') # Split labels by format so the two smart buttons (Print PDF / # Print ZPL) on the receiving form each open the right file. # FedEx names ZPL labels '...ZPLII'; PDFs are 'application/pdf'. pdf_atts = label_atts.filtered( lambda a: (a.mimetype or '').lower() == 'application/pdf' or (a.name or '').lower().endswith('.pdf') ) zpl_atts = label_atts.filtered( lambda a: 'zpl' in (a.name or '').lower() ) # FedEx ZPL ships with `^POI` (print-orientation invert), which # flips the label 180° on the printer. On a desktop thermal # like the Zebra ZD450 that comes out upside-down for the # operator AND labelary renders the PDF preview inverted to # match. Strip ^POI from a copy of the ZPL so both surfaces # show right-side-up. Original FedEx ZPL on the picking is # left untouched for audit. The cleaned copy is what operators # see (PDF preview + ZPL download). Attachment = self.env['ir.attachment'].sudo() cleaned_zpl_atts = self.env['ir.attachment'].sudo() for zpl in zpl_atts: raw = base64.b64decode(zpl.datas) if zpl.datas else b'' if not raw: continue cleaned = raw.replace(b'^POI', b'') if cleaned == raw: # No ^POI present — keep using the original attachment. cleaned_zpl_atts |= zpl continue cleaned_name = (zpl.name or 'label.zpl').rsplit('.', 1) cleaned_name = '%s-fixed.%s' % ( cleaned_name[0], cleaned_name[1] if len(cleaned_name) > 1 else 'zpl', ) cleaned_zpl_atts |= Attachment.create({ 'name': cleaned_name, 'res_model': 'stock.picking', 'res_id': picking.id, 'datas': base64.b64encode(cleaned), 'mimetype': 'text/plain', }) zpl_atts = cleaned_zpl_atts or zpl_atts # When the carrier returned ZPL but not PDF, render a PDF # rasterization via labelary so the Print PDF smart button has # something to open. One FedEx ship call → two smart buttons. # Best-effort: if labelary is unreachable, the ZPL button still # works and the operator can print from the Zebra directly. if zpl_atts and not pdf_atts: for zpl in zpl_atts: pdf_bytes = self._fp_zpl_to_pdf_via_labelary( base64.b64decode(zpl.datas) if zpl.datas else None ) if not pdf_bytes: continue pdf_name = (zpl.name or 'label.zpl').rsplit('.', 1)[0] + '.pdf' new_pdf = Attachment.create({ 'name': pdf_name, 'res_model': 'stock.picking', 'res_id': picking.id, 'datas': base64.b64encode(pdf_bytes), 'mimetype': 'application/pdf', }) pdf_atts |= new_pdf # Primary slot keeps backward-compat: prefer PDF for the main # button, fall back to whatever the carrier returned otherwise. primary_atts = pdf_atts or label_atts # Per-package shipping_data list — one entry per package. sd_list = shipping_data if isinstance(shipping_data, list) else [ shipping_data ] # Pair rows with their results. If user didn't enter per-row # data, fall back to a single virtual row scenario (no rows to # write back to). rows = self.x_fc_outbound_package_ids.filtered( lambda r: (r.weight or 0) > 0 ) # Walk both lists in parallel; carrier returns one tracking + # label per package in submission order. Some carriers return # one combined tracking_ref split by '+' — handle both. primary_tracking = '' per_pkg_trackings = [] for sd in sd_list: tn = sd.get('tracking_number') or '' for part in tn.split('+'): if part: per_pkg_trackings.append(part) if not per_pkg_trackings and 'carrier_tracking_ref' in picking._fields: for part in (picking.carrier_tracking_ref or '').split('+'): if part: per_pkg_trackings.append(part) primary_tracking = per_pkg_trackings[0] if per_pkg_trackings else '' # Write per-row labels + tracking. Attachments are paired by # index — N labels and N rows. Excess on either side is ignored. # Use primary_atts (PDF-preferred) so the per-row "Label" link # opens a printable PDF, not raw ZPL. for idx, row in enumerate(rows): row_vals = {} if idx < len(per_pkg_trackings): row_vals['tracking_number'] = per_pkg_trackings[idx] if idx < len(primary_atts): row_vals['label_attachment_id'] = primary_atts[idx].id if row_vals: row.sudo().write(row_vals) # Shipment-level fields. Primary label = PDF (or first attachment # if carrier didn't return PDF); ZPL goes into its own slot so # the Print ZPL button can find it. vals = {'status': 'confirmed'} if primary_tracking: vals['tracking_number'] = primary_tracking if primary_atts: vals['label_attachment_id'] = primary_atts[0].id if 'x_fc_label_attachment_ids' in ship._fields: vals['x_fc_label_attachment_ids'] = [(6, 0, primary_atts.ids)] if zpl_atts and 'x_fc_label_zpl_attachment_id' in ship._fields: vals['x_fc_label_zpl_attachment_id'] = zpl_atts[0].id # Link the synthetic stock.picking so the Transfer field shows # it on the shipment form. Also refresh sender/recipient/carrier # defaults in case the operator changed carrier between create # and generate. if 'picking_id' in ship._fields: vals['picking_id'] = picking.id for k, v in self._fp_resolve_shipment_defaults().items(): # Only fill if blank; never overwrite an operator edit. if not ship[k]: vals[k] = v ship.sudo().write(vals) self.message_post(body=Markup(_( 'Outbound label generated. Tracking: %s' )) % (primary_tracking or '(see attached PDF)')) # Validate the synthetic picking so it lands in 'done' state # instead of sitting at 'ready'. The shipping label is the proof # of dispatch — keeping the picking open misleads anyone looking # at the warehouse view. Wrapped in try/except so any quirk in # the validation flow (e.g. zero on-hand stock) doesn't block # the label generation success path. if picking and picking.state not in ('done', 'cancel'): try: # skip_sms = bypass the SMS-on-delivery confirm wizard # (stock_sms intercepts button_validate otherwise). # skip_backorder = no backorder dialog when qty doesn't # reconcile (won't on a synthetic picking with no stock). # skip_immediate = bypass the immediate-transfer prompt. result = picking.with_context( skip_immediate=True, skip_backorder=True, skip_sms=True, ).button_validate() # If button_validate still returned an action (a wizard # popped up despite the context flags), log and move on # — the label is already saved; manual validation later # is fine. if isinstance(result, dict) and result.get('res_model'): _logger.info( 'Receiving %s: button_validate returned a wizard ' '(%s); leaving picking %s in state %s.', self.name, result.get('res_model'), picking.name, picking.state, ) except Exception as e: _logger.warning( 'Receiving %s: failed to auto-validate picking %s: %s', self.name, picking.name, e, ) def action_refresh_shipping_quote(self): """Fetch a rate quote from FedEx for the current carrier + service + weight/dims and store it as HTML for the preview panel. Best-effort: any failure renders a friendly error message in the same panel instead of raising. Only wired up for FedEx REST today; other carriers fall back to a "not supported" message. Add a branch here when wiring Canada Post / UPS rate quotes. """ self.ensure_one() try: carrier = self.x_fc_carrier_id if not carrier: raise UserError(_('Pick an Outbound Carrier first.')) if not self.x_fc_weight or self.x_fc_weight <= 0: raise UserError(_('Enter a non-zero Weight first.')) so = self.sale_order_id if not so: raise UserError(_( 'No sale order linked — cannot resolve sender / ' 'recipient addresses for the quote.' )) if carrier.delivery_type != 'fusion_fedex_rest': self.x_fc_shipping_quote_html = self._fp_quote_html_msg( _('Rate quote is only wired up for FedEx REST ' 'right now. Carrier "%s" is not supported.') % ( carrier.name, ), is_error=True, ) return result = self._fp_quote_fedex_rate(carrier, so) self.x_fc_shipping_quote_html = self._fp_format_shipping_quote( result ) except UserError as exc: self.x_fc_shipping_quote_html = self._fp_quote_html_msg( str(exc), is_error=True, ) except Exception as exc: _logger.warning( 'Receiving %s: shipping quote failed: %s', self.name, exc, ) self.x_fc_shipping_quote_html = self._fp_quote_html_msg( _('Quote failed: %s') % exc, is_error=True, ) def _fp_quote_fedex_rate(self, carrier, so): """Call FedEx /rate/v1/rates/quotes with the receiving's current weight + service-type override. Returns the dict from FedexRestRequest._get_shipping_price (price, service_name, delivery_timestamp, etc.). """ # Lazy import — fusion_plating_receiving depends on # fusion_shipping but importing at module load order can race # with the registry. Inside-method keeps everything sane. from odoo.addons.fusion_shipping.api.fedex_rest.request import ( FedexRequest as FedexRestRequest, ) srm = FedexRestRequest(carrier) if self.x_fc_outbound_service_type: srm.service_type = self.x_fc_outbound_service_type package_type = ( carrier.fedex_rest_default_package_type_id.shipper_package_code or 'YOUR_PACKAGING' ) if carrier.fedex_rest_default_package_type_id else 'YOUR_PACKAGING' pkg = SimpleNamespace( weight=self.x_fc_weight, dimension={ 'length': self.x_fc_length or 0, 'width': self.x_fc_width or 0, 'height': self.x_fc_height or 0, }, packaging_type=package_type, total_cost=0, commodities=[], currency_id=so.currency_id, ) ship_from = ( so.warehouse_id.partner_id if so.warehouse_id else self.env.company.partner_id ) return srm._get_shipping_price( ship_from=ship_from, ship_to=so.partner_shipping_id or so.partner_id, packages=[pkg], currency=so.currency_id.name, ) def _fp_format_shipping_quote(self, result): """Render a rate-quote result dict as the HTML the form panel displays. Kept here so the styling decisions live next to the view that consumes them. """ price = result.get('price') or 0.0 currency = result.get('currency') or '' service_name = result.get('service_name') or '' service_code = result.get('service_type') or '' delivery = result.get('delivery_timestamp') or '' day_of_week = result.get('day_of_week') or '' transit = result.get('transit_time') or '' # Trim the FedEx ISO timestamp to "YYYY-MM-DD HH:MM" if present. if delivery and 'T' in delivery: delivery = delivery.replace('T', ' ')[:16] eta_line = '' if delivery or day_of_week: eta_line = '
Estimated delivery: %s%s
' % ( delivery or '(date not provided)', ' (%s)' % day_of_week.title() if day_of_week else '', ) transit_line = ( '
Transit: %s
' % transit.replace('_', ' ').title() ) if transit else '' # Colours come from fp_shipping_quote.scss (theme-aware). Only # structural styling lives inline (sizes, weights, spacing). return ( '
' '
' '%(currency)s %(price).2f' '
' '
%(service)s' '%(code)s
' '%(eta)s' '%(transit)s' '
' 'Quote is an estimate from FedEx — final charges may differ.' '
' '
' ) % { 'currency': currency, 'price': price, 'service': service_name or service_code or 'Carrier service', 'code': ' (%s)' % service_code if ( service_name and service_code and service_name != service_code ) else '', 'eta': eta_line, 'transit': transit_line, } def _fp_quote_html_msg(self, msg, is_error=False): """Wrap a one-line message in the same styling as the quote panel so the right-side column doesn't flicker between layouts. Colour comes from the wrapper class (theme-aware); errors use the Bootstrap danger semantic so dark/light both look right. """ klass = 'text-danger' if is_error else '' icon = 'fa-exclamation-triangle' if is_error else 'fa-info-circle' return ( '
' '%s' '
' ) % (klass, icon, msg) def action_print_label(self): """Open the primary (PDF) label in the fusion_pdf_preview dialog. Delegates to fusion.shipment._action_open_attachment, which routes PDFs through the preview client action and falls back to a new-tab URL when fusion_pdf_preview isn't installed. See CLAUDE.md "PDF Preview" for the contract. """ self.ensure_one() ship = self.x_fc_outbound_shipment_id if not ship or not ship.label_attachment_id: raise UserError(_( 'No outbound shipping label on this receiving. ' 'Generate the label first.' )) return ship._action_open_attachment(ship.label_attachment_id) def _fp_zpl_to_pdf_via_labelary(self, zpl_bytes): """POST raw ZPL to labelary and return the rendered PDF bytes. Returns None on any failure — caller treats labelary as a best-effort enhancement, never a blocker for label generation. See CLAUDE.md "labelary.com dependency" for privacy + ratelimit notes. """ if not zpl_bytes: return None try: res = requests.post( _LABELARY_URL, data=zpl_bytes, headers={ 'Accept': 'application/pdf', 'Content-Type': 'application/x-www-form-urlencoded', }, timeout=_LABELARY_TIMEOUT, ) except requests.RequestException as exc: _logger.warning( 'Receiving %s: labelary ZPL→PDF request failed: %s', self.name, exc, ) return None if not res.ok: _logger.warning( 'Receiving %s: labelary returned %s — %s', self.name, res.status_code, res.text[:200], ) return None return res.content def action_print_label_zpl(self): """Open the ZPL/ZPLII label for direct-to-thermal-printer use. Visibility on the form is gated by x_fc_has_label_zpl so this only appears when a ZPL attachment is actually present — i.e. the carrier returned ZPL on Generate, or a ZPL fetch was added later. When no ZPL exists, the operator should use the PDF button instead (PDF prints on any printer). """ self.ensure_one() ship = self.x_fc_outbound_shipment_id if not ship or not ship.x_fc_label_zpl_attachment_id: raise UserError(_( 'No ZPL label on this shipment. Use the PDF version, ' 'or switch the FedEx carrier label format to ZPLII and ' 'regenerate.' )) return ship.x_fc_label_zpl_attachment_id.action_fusion_preview( title=ship.x_fc_label_zpl_attachment_id.name or 'ZPL Label', model_name=self._name, record_ids=self.id, ) notes = fields.Html(string='Notes') line_ids = fields.One2many('fp.receiving.line', 'receiving_id', string='Receiving Lines') damage_ids = fields.One2many('fp.receiving.damage', 'receiving_id', string='Damage Log') damage_count = fields.Integer(string='Damage Count', compute='_compute_damage_count') unresolved_damage_count = fields.Integer( string='Unresolved Damage', compute='_compute_damage_count', ) attachment_ids = fields.Many2many( 'ir.attachment', 'fp_receiving_attachment_rel', 'receiving_id', 'attachment_id', string='Photos / Documents', ) @api.depends('expected_qty', 'received_qty') def _compute_qty_match(self): for rec in self: rec.qty_match = rec.expected_qty > 0 and rec.received_qty == rec.expected_qty @api.depends('damage_ids', 'damage_ids.resolved') def _compute_damage_count(self): for rec in self: rec.damage_count = len(rec.damage_ids) rec.unresolved_damage_count = len(rec.damage_ids.filtered(lambda d: not d.resolved)) # ------------------------------------------------------------------------- # Sequence + parent-derived naming # ------------------------------------------------------------------------- def _fp_parent_sale_order(self): return self.sale_order_id def _fp_name_prefix(self): return 'RCV' def _fp_parent_counter_field(self): return 'x_fc_pn_receiving_count' @api.model_create_multi def create(self, vals_list): for vals in vals_list: # Prefill received_qty from expected_qty so the operator only # types when the count is wrong. if vals.get('expected_qty') and not vals.get('received_qty'): vals['received_qty'] = vals['expected_qty'] if not vals.get('name'): vals['name'] = 'New' records = super().create(vals_list) for rec in records: if rec.name and rec.name != 'New': continue if not rec._fp_assign_parent_name(): seq = self.env['ir.sequence'].next_by_code('fp.receiving') or 'New' self.env.cr.execute( "UPDATE fp_receiving SET name = %s WHERE id = %s", (seq, rec.id), ) rec.invalidate_recordset(['name']) return records # ------------------------------------------------------------------------- # Sub 8 — box-count-only actions (new primary flow) # ------------------------------------------------------------------------- def action_mark_counted(self): """Receiver has counted the boxes on the dock. Move to Counted.""" for rec in self: if rec.state not in ('draft', 'inspecting'): # inspecting allows legacy records raise UserError(_('Only Awaiting-Parts or legacy-Inspecting ' 'records can be marked Counted.')) if not rec.box_count_in: raise UserError(_('Set the Boxes Received count before marking Counted.')) rec.state = 'counted' rec.received_by_id = self.env.user rec.received_date = fields.Datetime.now() rec._update_so_receiving_status() rec.message_post(body=_( '%(user)s counted %(n)d box(es) at receiving.' ) % {'user': self.env.user.name, 'n': rec.box_count_in}) def action_mark_staged(self): """Deprecated 2026-05-20 — `staged` state was dead ceremony (median dwell 11 sec, no captured data, no downstream effect). Kept as a thin shim so any legacy button binding still works: it advances counted records straight to closed. """ for rec in self: if rec.state != 'counted': raise UserError(_( 'Only Counted records can be closed. Stage-for-racking ' 'is no longer a separate step.' )) rec.action_close() def action_close(self): """Close the receiving — all boxes opened, inspection complete. 2026-05-20: now reachable directly from `counted` (the `staged` intermediate was dropped). Legacy values 'staged' / 'accepted' / 'resolved' still accepted so pre-Sub-8 records can be closed without manual SQL surgery. """ for rec in self: if rec.state not in ('counted', 'staged', 'accepted', 'resolved'): raise UserError(_( 'Only Counted (or legacy Staged / Accepted / Resolved) ' 'records can be closed.' )) rec.state = 'closed' rec._update_so_receiving_status() rec.message_post(body=_('Receiving closed.')) def action_reset_to_counted(self): """Reset a Closed receiving back to Counted. Recovery escape hatch for when receiving was closed prematurely. Blocked once downstream work has begun — operator must cancel every fp.job spawned from this SO and avoid touching any step before the rewind is allowed. Without the gate it's trivial to rewind a receiving while jobs are mid-flight, which silently breaks the qty_received feed and the cert mark-done gate. """ Job = self.env.get('fp.job') for rec in self: if rec.state != 'closed': raise UserError(_('Only Closed receivings can be reset.')) if Job is not None and rec.sale_order_id: jobs = Job.sudo().search([ ('sale_order_id', '=', rec.sale_order_id.id), ]) if jobs: started = jobs.step_ids.filtered( lambda s: s.state in ( 'in_progress', 'paused', 'done', 'skipped', ) ) if started: raise UserError(_( 'Cannot reset — %d step(s) on this order have ' 'been started. Reset is only allowed before ' 'work begins.' ) % len(started)) active = jobs.filtered(lambda j: j.state != 'cancelled') if active: raise UserError(_( 'Cannot reset — %d work order(s) on this sale ' 'order are not cancelled. Cancel them first, ' 'then retry.' ) % len(active)) rec.state = 'counted' rec._update_so_receiving_status() rec.message_post(body=_('Receiving reset to Counted.')) # ------------------------------------------------------------------------- # Legacy state actions — kept for backward compatibility. # Deprecated: Sub 8 moves part-level inspection to fp.racking.inspection. # Retained so existing UI bindings don't blow up. # ------------------------------------------------------------------------- def action_start_inspection(self): """Move from draft to inspecting.""" for rec in self: if rec.state != 'draft': raise UserError(_('Only draft records can start inspection.')) rec.state = 'inspecting' rec.received_by_id = self.env.user rec.received_date = fields.Datetime.now() def action_accept(self): """Accept the receiving — parts match and condition is OK. Quantity-mismatch policy: if expected_qty != received_qty, operators must use action_flag_discrepancy() instead. Managers can override (the override is logged on chatter for audit). """ is_manager = self.env.user.has_group( 'fusion_plating.group_fusion_plating_manager' ) for rec in self: if rec.state not in ('inspecting', 'resolved'): raise UserError(_('Can only accept from Inspecting or Resolved state.')) if rec.unresolved_damage_count > 0: raise UserError(_('Cannot accept — there are %d unresolved damage entries.') % rec.unresolved_damage_count) qty_match = rec.expected_qty > 0 and rec.received_qty == rec.expected_qty if not qty_match: if not is_manager: raise UserError(_( 'Cannot accept — quantity mismatch (expected %(exp)d, ' 'received %(rcv)d).\n\nUse "Flag Discrepancy" instead, ' 'or have a manager override.' ) % {'exp': rec.expected_qty, 'rcv': rec.received_qty}) rec.message_post(body=_( 'Manager override: accepted with quantity mismatch ' '(expected %(exp)d, received %(rcv)d).' ) % {'exp': rec.expected_qty, 'rcv': rec.received_qty}) rec.state = 'accepted' rec._update_so_receiving_status() rec.message_post(body=_('Parts accepted — quantity: %d, all checks passed.') % rec.received_qty) def action_flag_discrepancy(self): """Flag a discrepancy — qty mismatch or damage found.""" for rec in self: if rec.state != 'inspecting': raise UserError(_('Can only flag discrepancy from Inspecting state.')) rec.state = 'discrepancy' rec._update_so_receiving_status() # Create follow-up activity for the sales team rec.activity_schedule( 'mail.mail_activity_data_todo', summary=_('Receiving discrepancy — %s') % rec.name, note=_('Qty expected: %d, received: %d. Check damage log for details.') % ( rec.expected_qty, rec.received_qty), ) rec.message_post(body=_('Discrepancy flagged — follow-up required.')) def action_resolve(self): """Resolve a discrepancy after customer follow-up.""" for rec in self: if rec.state != 'discrepancy': raise UserError(_('Can only resolve from Discrepancy state.')) rec.state = 'resolved' rec._update_so_receiving_status() rec.message_post(body=_('Discrepancy resolved.')) def _update_so_receiving_status(self): """Update the linked sale order's receiving status. Sub 8 + 2026-05-20 cleanup map the receiving states onto the SO's `x_fc_receiving_status`: - draft -> not_received (no rows or just-created) - counted -> partial (boxes on dock, parts not yet racked) - closed -> received (all boxes opened, racking confirmed) Legacy values (staged / inspecting / accepted / discrepancy / resolved) keep their pre-Sub-8 / pre-cleanup mapping so records that haven't been touched still resolve sanely. """ for rec in self: if not rec.sale_order_id: continue if rec.state == 'closed': rec.sale_order_id.x_fc_receiving_status = 'received' elif rec.state in ('counted', 'staged'): rec.sale_order_id.x_fc_receiving_status = 'partial' # Legacy states preserved. elif rec.state in ('accepted', 'resolved'): rec.sale_order_id.x_fc_receiving_status = 'received' elif rec.state in ('discrepancy', 'inspecting'): rec.sale_order_id.x_fc_receiving_status = 'partial' elif rec.state == 'draft': rec.sale_order_id.x_fc_receiving_status = 'not_received' # Propagate the per-part qty onto the matching fp.job records # so the 2026-05-18 mark_done gate can see what was received. rec._update_job_qty_received() def _update_job_qty_received(self): """Push received qty from this receiving's lines onto fp.job. The 2026-05-18 cert-creation gate (fp.job.button_mark_done) blocks completion until ``job.qty_received`` is non-zero, but nothing was writing the field — receiving and job were two disconnected records on the same SO. Operators completed receiving, then hit "Quantity Received is blank" with no obvious next step. Match rule: one fp.job ←→ one fp.receiving.line within the same sale order, joined by ``part_catalog_id``. Multi-part SOs spawn one job per line + one receiving line per part, so this gives a 1-to-1 mapping. Single-line SOs work too because the only line matches the only job. Best-effort: if no job matches (e.g. receiving without a spawned job, or part-catalog mismatch), skip silently — the receiving record itself still has the qty for audit. """ Job = self.env.get('fp.job') if Job is None: return # fusion_plating_jobs not installed # Match criteria depend on fields owned by fusion_plating_jobs. # Bail out cleanly if the registry doesn't have them — the same # hook then becomes a no-op in any install topology that # doesn't ship the jobs module (and in test scope where the # field may not be materialised on fp.job yet). if 'sale_order_id' not in Job._fields \ or 'part_catalog_id' not in Job._fields \ or 'qty_received' not in Job._fields: return for rec in self: so = rec.sale_order_id if not so: continue for line in rec.line_ids: if not line.part_catalog_id: continue domain = [ ('sale_order_id', '=', so.id), ('part_catalog_id', '=', line.part_catalog_id.id), ] jobs = Job.sudo().search(domain) if not jobs: continue # Only sync the integer qty, don't touch state. Skip writes # when the value already matches so we don't churn chatter. qty = int(line.received_qty or 0) jobs_to_update = jobs.filtered( lambda j: (j.qty_received or 0) != qty ) if jobs_to_update: jobs_to_update.sudo().write({'qty_received': qty})