changes
This commit is contained in:
@@ -5,7 +5,8 @@
|
||||
|
||||
from . import fp_receiving_damage
|
||||
from . import fp_receiving_line
|
||||
from . import fp_outbound_package
|
||||
from . import fp_receiving
|
||||
from . import fp_racking_inspection
|
||||
from . import fp_receiving_racking_link
|
||||
from . import sale_order
|
||||
from . import fusion_shipment
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Per-package row for outbound multi-piece shipments.
|
||||
|
||||
Each fp.receiving has zero-or-more fp.outbound.package rows. When the
|
||||
operator clicks Generate Outbound Label, one stock.package + one
|
||||
carrier label is generated per row.
|
||||
|
||||
Single-box scenario: the form auto-fills one row when the receiving's
|
||||
top-level weight/dim are set, so existing UX still works.
|
||||
Multi-box scenario: operator adds more rows. Each row gets its own
|
||||
tracking number + label PDF/ZPL stored back on the row after the API
|
||||
call returns.
|
||||
"""
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpOutboundPackage(models.Model):
|
||||
_name = 'fp.outbound.package'
|
||||
_description = 'Fusion Plating — Outbound Package (per-box detail)'
|
||||
_order = 'sequence, id'
|
||||
|
||||
receiving_id = fields.Many2one(
|
||||
'fp.receiving', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
weight = fields.Float(string='Weight', digits=(10, 3))
|
||||
length = fields.Float(string='Length', digits=(10, 2))
|
||||
width = fields.Float(string='Width', digits=(10, 2))
|
||||
height = fields.Float(string='Height', digits=(10, 2))
|
||||
# Populated by the carrier API once Generate Label fires.
|
||||
tracking_number = fields.Char(readonly=True, copy=False)
|
||||
label_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Label',
|
||||
ondelete='set null',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
# Computed convenience: filename of the label (for download UX).
|
||||
label_filename = fields.Char(
|
||||
related='label_attachment_id.name', readonly=True,
|
||||
)
|
||||
@@ -3,9 +3,15 @@
|
||||
# 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.
|
||||
@@ -70,8 +76,620 @@ class FpReceiving(models.Model):
|
||||
qty_match = fields.Boolean(
|
||||
string='Qty Match', compute='_compute_qty_match', store=True,
|
||||
)
|
||||
carrier_name = fields.Char(string='Carrier', help='Who delivered the parts (Purolator, customer drop-off, etc.).')
|
||||
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')
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Sub 12 audit fix — discoverable handoff from fp.receiving (boxes
|
||||
# counted) to fp.racking.inspection (parts inspected by the racking
|
||||
# crew). The racking inspection is auto-created on fp.job.action_confirm
|
||||
# but until now there was no smart-button on the receiving form to find
|
||||
# it — racking crew had to navigate via a separate menu.
|
||||
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
class FpReceivingRackingLink(models.Model):
|
||||
_inherit = 'fp.receiving'
|
||||
|
||||
racking_inspection_count = fields.Integer(
|
||||
string='Racking Inspections', compute='_compute_racking_inspection_count',
|
||||
)
|
||||
|
||||
def _compute_racking_inspection_count(self):
|
||||
Inspection = self.env['fp.racking.inspection'] \
|
||||
if 'fp.racking.inspection' in self.env else None
|
||||
for rec in self:
|
||||
if Inspection is None or not rec.sale_order_id:
|
||||
rec.racking_inspection_count = 0
|
||||
continue
|
||||
rec.racking_inspection_count = Inspection.search_count([
|
||||
('sale_order_id', '=', rec.sale_order_id.id),
|
||||
])
|
||||
|
||||
def action_view_racking_inspections(self):
|
||||
"""Open the racking inspection(s) for this receiving's SO. If
|
||||
none exists yet, default-create context lets the user spawn one
|
||||
with the SO context pre-filled.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Inspection = self.env['fp.racking.inspection']
|
||||
domain = [('sale_order_id', '=', self.sale_order_id.id)] \
|
||||
if self.sale_order_id else []
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Racking Inspections'),
|
||||
'res_model': 'fp.racking.inspection',
|
||||
'view_mode': 'list,form',
|
||||
'domain': domain,
|
||||
'context': {
|
||||
'default_sale_order_id': self.sale_order_id.id
|
||||
if self.sale_order_id else False,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Phase C — extend fusion.shipment with dimension fields.
|
||||
|
||||
fusion_shipping's native model has `weight` but no length/width/height.
|
||||
The plating workflow needs all four captured at receiving time so the
|
||||
shipment record carries everything the carrier API would want. Added
|
||||
here (not in fusion_shipping) to keep the upstream module untouched.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionShipment(models.Model):
|
||||
_inherit = 'fusion.shipment'
|
||||
|
||||
x_fc_length = fields.Float(string='Length', digits=(10, 2))
|
||||
x_fc_width = fields.Float(string='Width', digits=(10, 2))
|
||||
x_fc_height = fields.Float(string='Height', digits=(10, 2))
|
||||
x_fc_dim_uom = fields.Selection(
|
||||
[('in', 'in'), ('cm', 'cm')],
|
||||
string='Dim UoM', default='in',
|
||||
)
|
||||
x_fc_weight_uom = fields.Selection(
|
||||
[('lb', 'lb'), ('kg', 'kg')],
|
||||
string='Weight UoM', default='lb',
|
||||
)
|
||||
|
||||
# Multi-piece label storage. label_attachment_id remains the
|
||||
# primary (first box) for backward-compat; this M2M holds the full
|
||||
# set so the operator can download any box's label individually.
|
||||
x_fc_label_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fusion_shipment_label_attachment_rel',
|
||||
'shipment_id', 'attachment_id',
|
||||
string='All Labels',
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# Phase C — resolved carrier tracking URL with the tracking number
|
||||
# substituted into the carrier.tracking_url template. Used by the
|
||||
# shipment_labeled email template and any other place that needs a
|
||||
# working clickable tracking link. Single source of truth so both
|
||||
# email + portal stay consistent.
|
||||
x_fc_tracking_url = fields.Char(
|
||||
string='Tracking URL (resolved)',
|
||||
compute='_compute_x_fc_tracking_url',
|
||||
help='carrier.tracking_url with <shipmenttrackingnumber> replaced '
|
||||
'by tracking_number. Empty when the carrier has no URL '
|
||||
'template or there is no tracking number yet.',
|
||||
)
|
||||
|
||||
@api.depends('carrier_id.tracking_url', 'tracking_number')
|
||||
def _compute_x_fc_tracking_url(self):
|
||||
for rec in self:
|
||||
tpl = (rec.carrier_id.tracking_url or '') if rec.carrier_id else ''
|
||||
tn = rec.tracking_number or ''
|
||||
if not tpl or not tn:
|
||||
rec.x_fc_tracking_url = ''
|
||||
continue
|
||||
placeholder = '<shipmenttrackingnumber>'
|
||||
if placeholder in tpl:
|
||||
rec.x_fc_tracking_url = tpl.replace(placeholder, tn)
|
||||
else:
|
||||
rec.x_fc_tracking_url = tpl + tn
|
||||
|
||||
def write(self, vals):
|
||||
"""Sync the carrier tracking number + label to the customer
|
||||
portal job whenever they land on the shipment. The portal_job
|
||||
currently shows `delivery.name` as 'tracking' — wrong; the
|
||||
customer wants the carrier's actual tracking number so the
|
||||
clickable link goes to FedEx/UPS/etc."""
|
||||
res = super().write(vals)
|
||||
sync_keys = {'tracking_number', 'label_attachment_id', 'status'}
|
||||
if not sync_keys & set(vals.keys()):
|
||||
return res
|
||||
for ship in self:
|
||||
try:
|
||||
ship._fp_sync_to_portal_job()
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Shipment %s: portal-job sync failed: %s',
|
||||
ship.name, e,
|
||||
)
|
||||
return res
|
||||
|
||||
def _fp_sync_to_portal_job(self):
|
||||
"""Walk shipment → SO → fp.job → fusion.plating.portal.job
|
||||
and push the carrier tracking number + label + delivery's
|
||||
packing slip onto the customer-facing record.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return
|
||||
Job = self.env.get('fp.job')
|
||||
if Job is None:
|
||||
return
|
||||
jobs = Job.sudo().search(
|
||||
[('sale_order_id', '=', self.sale_order_id.id)],
|
||||
)
|
||||
if not jobs:
|
||||
return
|
||||
for job in jobs:
|
||||
portal = job.portal_job_id
|
||||
if not portal:
|
||||
continue
|
||||
vals = {}
|
||||
if self.tracking_number and portal.tracking_ref != self.tracking_number:
|
||||
vals['tracking_ref'] = self.tracking_number
|
||||
# Packing slip lives on the linked fp.delivery, not the
|
||||
# shipment. Walk it lazily here so a packing-slip generated
|
||||
# earlier on the delivery also lands on the portal job.
|
||||
delivery = job.delivery_id
|
||||
if (delivery
|
||||
and 'packing_list_attachment_id' in delivery._fields
|
||||
and delivery.packing_list_attachment_id
|
||||
and portal.packing_list_attachment_id !=
|
||||
delivery.packing_list_attachment_id):
|
||||
vals['packing_list_attachment_id'] = (
|
||||
delivery.packing_list_attachment_id.id
|
||||
)
|
||||
# Once a tracking number exists, the parts have been picked
|
||||
# by the carrier (or are about to be) — advance the portal
|
||||
# state to 'shipped' so the customer sees their order is
|
||||
# on its way. The 'delivered' status flips when FedEx
|
||||
# tracking reports the delivery.
|
||||
if self.tracking_number and portal.state in (
|
||||
'received', 'in_progress', 'ready_to_ship',
|
||||
):
|
||||
vals['state'] = 'shipped'
|
||||
if vals:
|
||||
portal.sudo().write(vals)
|
||||
@@ -22,25 +22,41 @@ class SaleOrder(models.Model):
|
||||
rec.x_fc_receiving_count = len(rec.x_fc_receiving_ids)
|
||||
|
||||
def action_confirm(self):
|
||||
"""Override to auto-create receiving record on SO confirmation."""
|
||||
"""Override to auto-create receiving record on SO confirmation.
|
||||
|
||||
Per-line metadata (part catalog, part number) is sourced from
|
||||
``sale.order.line.x_fc_part_catalog_id`` — NOT from the SO header.
|
||||
The header field exists too but is rarely populated; the line
|
||||
carries the authoritative part link in the configurator flow.
|
||||
|
||||
Each receiving line prefills ``received_qty`` to ``expected_qty``
|
||||
so the racking crew only types when the count is off (mirrors
|
||||
the header behaviour in fp_receiving.py:create).
|
||||
"""
|
||||
res = super().action_confirm()
|
||||
for order in self:
|
||||
# Only create if no receiving record exists yet
|
||||
if not order.x_fc_receiving_ids:
|
||||
total_qty = sum(order.order_line.mapped('product_uom_qty'))
|
||||
receiving_vals = {
|
||||
'sale_order_id': order.id,
|
||||
'expected_qty': int(total_qty),
|
||||
'line_ids': [],
|
||||
}
|
||||
# Auto-create lines from SO lines
|
||||
for line in order.order_line:
|
||||
receiving_vals['line_ids'].append((0, 0, {
|
||||
'part_number': order.x_fc_part_catalog_id.part_number if order.x_fc_part_catalog_id else '',
|
||||
'description': line.name or '',
|
||||
'expected_qty': int(line.product_uom_qty),
|
||||
}))
|
||||
self.env['fp.receiving'].create(receiving_vals)
|
||||
if order.x_fc_receiving_ids:
|
||||
continue
|
||||
total_qty = sum(order.order_line.mapped('product_uom_qty'))
|
||||
line_vals = []
|
||||
for line in order.order_line:
|
||||
part = (
|
||||
line.x_fc_part_catalog_id
|
||||
if 'x_fc_part_catalog_id' in line._fields else False
|
||||
)
|
||||
expected = int(line.product_uom_qty or 0)
|
||||
line_vals.append((0, 0, {
|
||||
'part_catalog_id': part.id if part else False,
|
||||
'part_number': (part.part_number if part else '') or '',
|
||||
'description': line.name or '',
|
||||
'expected_qty': expected,
|
||||
'received_qty': expected,
|
||||
}))
|
||||
self.env['fp.receiving'].create({
|
||||
'sale_order_id': order.id,
|
||||
'expected_qty': int(total_qty),
|
||||
'line_ids': line_vals,
|
||||
})
|
||||
return res
|
||||
|
||||
def action_view_receiving(self):
|
||||
|
||||
Reference in New Issue
Block a user