Files
Odoo-Modules/fusion_plating/fusion_plating_receiving/models/fp_receiving.py
gsinghpal 091f98e1f9 changes
2026-05-18 22:33:23 -04:00

885 lines
40 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'