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