# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. import logging from markupsafe import Markup from odoo import api, fields, models, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) 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'), ('staged', 'Staged for Racking'), ('closed', 'Closed'), # Legacy values — kept readable, never written by new code ('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_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.', ) @api.depends('x_fc_outbound_shipment_id.label_attachment_id') def _compute_x_fc_has_label(self): for rec in self: rec.x_fc_has_label = bool( rec.x_fc_outbound_shipment_id and rec.x_fc_outbound_shipment_id.label_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): """One-button label generation. Branches on carrier.delivery_type: - 'fixed' (no API integration): opens manual entry wizard. - 'fusion_*' (API integration): synthesizes a stock.picking, calls the existing carrier._send_shipping method, copies the result back to the linked fusion.shipment. - On API exception: falls back to the manual wizard with the error message in the note field. """ 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() shipping_data = 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. """ self.ensure_one() 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, }) # Stash packages on the picking via a transient attr so # _fp_apply_shipping_result can walk them in the same order # the API processes them (FedEx returns labels in the # order packages were submitted). picking._fp_outbound_packages = packages 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') # 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. 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(label_atts): row_vals['label_attachment_id'] = label_atts[idx].id if row_vals: row.sudo().write(row_vals) # Shipment-level fields. Primary label = first attachment; mirror # all labels onto x_fc_label_attachment_ids for the multi-print UX. vals = {'status': 'confirmed'} if primary_tracking: vals['tracking_number'] = primary_tracking if label_atts: vals['label_attachment_id'] = label_atts[0].id if 'x_fc_label_attachment_ids' in ship._fields: vals['x_fc_label_attachment_ids'] = [(6, 0, label_atts.ids)] # 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' )) % (tracking_number 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_print_label(self): """Open the label PDF for printing. Returns the standard Odoo download action so the operator can print from their browser. Phase F replaces this with auto-print to a network printer. """ 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 { 'type': 'ir.actions.act_url', 'url': '/web/content/%d?download=true' % ship.label_attachment_id.id, 'target': 'new', } 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): """Boxes are in the racking area, awaiting the racking crew.""" for rec in self: if rec.state not in ('counted',): raise UserError(_('Only Counted records can be marked Staged.')) rec.state = 'staged' rec._update_so_receiving_status() rec.message_post(body=_('Boxes staged for racking.')) def action_close(self): """Close the receiving — all boxes opened, inspection complete.""" for rec in self: if rec.state not in ('staged', 'accepted', 'resolved'): raise UserError(_('Only Staged (or legacy Accepted / Resolved) ' 'records can be closed.')) rec.state = 'closed' rec._update_so_receiving_status() rec.message_post(body=_('Receiving closed.')) # ------------------------------------------------------------------------- # 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 maps the new box-count-only states (`counted`, `staged`, `closed`) onto the SO's `x_fc_receiving_status`: - draft -> not_received (no rows or just-created) - counted / staged -> partial (boxes on dock, parts not yet racked / inspected) - closed -> received (all boxes opened, racking done) Legacy states (inspecting / accepted / discrepancy / resolved) keep their original mapping for back-compat with pre-Sub-8 records. """ 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'