Files
Odoo-Modules/fusion_plating/fusion_plating_receiving/models/fp_receiving.py
gsinghpal 2645db40a2 fix(receiving): propagate qty_received to fp.job + drop duplicate carrier field
Bug surfaced on WO-30043 (2026-05-20): operator walked every step
including a fully closed receiving record, then hit
"Quantity Received is blank — close the receiving record for
SO SO-30043 before completing this job." Receiving WAS closed.

Root cause: the 2026-05-18 cert-creation gate
(fp.job.button_mark_done) blocks on job.qty_received but nothing
populated it. fp.receiving carried the qty on its line records,
fp.job stayed at 0 indefinitely. Two disconnected records on the
same SO.

Fix: when fp.receiving._update_so_receiving_status runs (i.e. on
every state transition — counted / staged / closed / accepted /
resolved), also mirror each line's received_qty onto the matching
fp.job by (sale_order_id + part_catalog_id). Single-part SOs map
1-to-1; multi-part SOs spawn one job per line so the same join
still works.

Two defensive guards in the hook:
- Skip silently when fusion_plating_jobs not installed
  (Job = env.get('fp.job') returns None).
- Skip silently when fp.job doesn't yet carry part_catalog_id /
  qty_received (test scope, unusual install topology).

Drive-by during cleanup:
- fp_parent_numbered_mixin._fp_assign_parent_name: guard
  so.x_fc_parent_number access with field-existence check. The
  column lives in fusion_plating_jobs; downstream modules that
  inherit the mixin (receiving) but don't depend on jobs were
  hitting AttributeError on every fp.receiving.create at test
  time. Falls through to the legacy sequence when the column
  isn't there.

- fp_receiving_views.xml: legacy carrier_name Char field rendered
  as a second carrier row labeled "Legacy Carrier" alongside the
  proper x_fc_carrier_id M2O — operators saw two carrier fields
  and got confused. Hide the legacy display (data stays in DB for
  audit; migration 19.0.3.10.0 already matched it to a real
  delivery.carrier).

Migration 19.0.3.19.0/post-migrate.py backfills qty_received from
closed receiving lines for any job stuck at 0 — fixes WO-30043
and two sibling jobs on entech.

Modules: fusion_plating 19.0.20.2.0, fusion_plating_receiving
19.0.3.19.0, fusion_plating_jobs 19.0.10.15.0.

All 19 tests green (TestCarrierFields 6, TestQtyReceivedPropagation 5
new, TestReceivingGate 8). Direct verification on entech: WO-30043
qty_received = 1, mark_done succeeds, delivery + cert auto-created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:15:46 -04:00

943 lines
43 KiB
Python

# -*- 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 <b>%s</b> 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.<provider>_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 <delivery_type>_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: <b>%s</b>'
)) % (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'
# 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})