changes
This commit is contained in:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user