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>
943 lines
43 KiB
Python
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})
|