changes
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Receiving & Inspection',
|
||||
'version': '19.0.3.20.0',
|
||||
'version': '19.0.3.25.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||
'description': """
|
||||
@@ -46,7 +46,20 @@ Provides:
|
||||
'views/fp_receiving_menu.xml',
|
||||
'views/fusion_shipment_inherit_views.xml',
|
||||
'wizards/fp_label_manual_wizard_views.xml',
|
||||
'wizards/fp_label_generate_wizard_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
# Theme-aware shipping-quote callout. Registered in BOTH
|
||||
# bundles so the dark-mode compile picks up the @if branch
|
||||
# (see CLAUDE.md "Dark Mode" — no runtime DOM toggle in
|
||||
# Odoo 19).
|
||||
'web.assets_backend': [
|
||||
'fusion_plating_receiving/static/src/scss/fp_shipping_quote.scss',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
'fusion_plating_receiving/static/src/scss/fp_shipping_quote.scss',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
import requests
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
@@ -12,6 +15,13 @@ from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# labelary.com — free ZPL→PDF rasterization service. 8dpmm = 203dpi
|
||||
# (ZD450 default), label size 4x6 in. No API key required for the
|
||||
# typical low-volume use case (limit: ~5 req/s anonymous). PDF output
|
||||
# is requested via the Accept header.
|
||||
_LABELARY_URL = 'https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/'
|
||||
_LABELARY_TIMEOUT = 10
|
||||
|
||||
|
||||
class FpReceiving(models.Model):
|
||||
"""Parts receiving record.
|
||||
@@ -101,6 +111,65 @@ class FpReceiving(models.Model):
|
||||
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_service_type = fields.Selection(
|
||||
selection='_fp_get_service_type_selection',
|
||||
string='Service Type',
|
||||
tracking=True,
|
||||
help='Override the carrier default for this shipment. Leave '
|
||||
'blank to use the carrier-level default (typically Ground '
|
||||
'or Priority depending on configuration). Pick a faster '
|
||||
'tier (e.g. Priority Overnight) when the customer is '
|
||||
'paying for expedited delivery.',
|
||||
)
|
||||
|
||||
# Curated FedEx services for a Canadian B2B plating shop. The
|
||||
# carrier-level selection (~38 options) is overwhelming and mostly
|
||||
# noise — frieght tiers want 150+ lb, regional services don't apply
|
||||
# to CA-origin shipments, distribution-program services need extra
|
||||
# account config. Sweep against the live sandbox (see
|
||||
# scripts/fp_fedex_service_matrix.py) confirmed these 12 are the
|
||||
# only ones realistically usable for parts shipments. If a future
|
||||
# contract enables more, append here.
|
||||
_FP_USABLE_FEDEX_SERVICES = (
|
||||
# CA domestic
|
||||
'FEDEX_GROUND', # Cheapest, 1-7 days
|
||||
'FEDEX_EXPRESS_SAVER', # 3-day economy
|
||||
'FEDEX_2_DAY', # 2 business days
|
||||
'FEDEX_2_DAY_AM', # 2 business days, 10:30am
|
||||
'STANDARD_OVERNIGHT', # Next day, end of day
|
||||
'PRIORITY_OVERNIGHT', # Next day, 10:30am
|
||||
'FIRST_OVERNIGHT', # Next day, 8:00am
|
||||
# International (CA -> US / EU / APAC)
|
||||
'FEDEX_INTERNATIONAL_CONNECT_PLUS', # Mid-tier intl
|
||||
'INTERNATIONAL_ECONOMY', # 4-5 day intl economy
|
||||
'FEDEX_INTERNATIONAL_PRIORITY', # 1-3 day intl
|
||||
'FEDEX_INTERNATIONAL_PRIORITY_EXPRESS', # 1-3 day intl premium
|
||||
'INTERNATIONAL_FIRST', # Earliest intl available
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _fp_get_service_type_selection(self):
|
||||
"""Curated FedEx service selection for the receiving form.
|
||||
|
||||
Pulls labels from the carrier's full selection (so they match
|
||||
whatever fusion_shipping ships) but filters to the codes in
|
||||
_FP_USABLE_FEDEX_SERVICES. Order in the dropdown follows the
|
||||
tuple — cheapest CA-domestic first, premium international last.
|
||||
|
||||
Empty list when fusion_shipping isn't installed.
|
||||
"""
|
||||
Carrier = self.env.get('delivery.carrier')
|
||||
if Carrier is None:
|
||||
return []
|
||||
field = Carrier._fields.get('fedex_rest_service_type')
|
||||
if not field:
|
||||
return []
|
||||
labels = dict(field.selection)
|
||||
return [
|
||||
(code, labels.get(code, code))
|
||||
for code in self._FP_USABLE_FEDEX_SERVICES
|
||||
if code in labels
|
||||
]
|
||||
x_fc_outbound_shipment_id = fields.Many2one(
|
||||
'fusion.shipment', string='Outbound Shipment', tracking=True,
|
||||
ondelete='set null',
|
||||
@@ -117,13 +186,29 @@ class FpReceiving(models.Model):
|
||||
help='True when the linked outbound shipment has a label PDF '
|
||||
'attached. Drives the Print Label smart-button visibility.',
|
||||
)
|
||||
x_fc_has_label_zpl = fields.Boolean(
|
||||
compute='_compute_x_fc_has_label',
|
||||
help='True when the linked outbound shipment has a ZPL label '
|
||||
'attached. Drives the Print ZPL smart-button visibility.',
|
||||
)
|
||||
x_fc_shipping_quote_html = fields.Html(
|
||||
string='Shipping Quote',
|
||||
readonly=True, copy=False, sanitize=False,
|
||||
help='Estimated cost + delivery date from the carrier API. '
|
||||
'Click "Refresh Quote" to fetch the latest. Reflects the '
|
||||
'currently-selected Service Type, weight, and dimensions.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_outbound_shipment_id.label_attachment_id')
|
||||
@api.depends(
|
||||
'x_fc_outbound_shipment_id.label_attachment_id',
|
||||
'x_fc_outbound_shipment_id.x_fc_label_zpl_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
|
||||
ship = rec.x_fc_outbound_shipment_id
|
||||
rec.x_fc_has_label = bool(ship and ship.label_attachment_id)
|
||||
rec.x_fc_has_label_zpl = bool(
|
||||
ship and ship.x_fc_label_zpl_attachment_id
|
||||
)
|
||||
|
||||
# ---- Phase C — Outbound packaging fields -----------------------------
|
||||
@@ -293,15 +378,54 @@ class FpReceiving(models.Model):
|
||||
|
||||
# ---- Phase C — Generate Outbound Label -------------------------------
|
||||
def action_generate_outbound_label(self):
|
||||
"""One-button label generation.
|
||||
"""Open the confirmation wizard before the actual API call.
|
||||
|
||||
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.
|
||||
Two guards live here so the user can't accidentally bill
|
||||
themselves for duplicate shipments:
|
||||
1. If a label is already attached to the linked shipment,
|
||||
refuse to regenerate — operator must void the shipment
|
||||
first.
|
||||
2. Otherwise pop fp.label.generate.wizard so the operator
|
||||
confirms carrier + service tier + weight before any API
|
||||
call. The wizard's action_confirm calls
|
||||
_fp_actually_generate_outbound_label.
|
||||
"""
|
||||
self.ensure_one()
|
||||
self._fp_validate_label_inputs()
|
||||
if self.x_fc_outbound_shipment_id \
|
||||
and self.x_fc_outbound_shipment_id.label_attachment_id:
|
||||
raise UserError(_(
|
||||
'A shipping label already exists for this receiving '
|
||||
'(shipment %s). Void that shipment first if you need '
|
||||
'to regenerate — otherwise every click would create a '
|
||||
'new billable FedEx shipment with its own tracking '
|
||||
'number.'
|
||||
) % self.x_fc_outbound_shipment_id.name)
|
||||
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)
|
||||
Wizard = self.env['fp.label.generate.wizard']
|
||||
wiz = Wizard.create(Wizard._fp_default_from_receiving(self.env, self))
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Generate Label — %s') % self.name,
|
||||
'res_model': 'fp.label.generate.wizard',
|
||||
'res_id': wiz.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def _fp_actually_generate_outbound_label(self):
|
||||
"""Make the actual carrier API call to create the shipping
|
||||
label. Called by fp.label.generate.wizard.action_confirm after
|
||||
the operator has confirmed service + weight in the wizard.
|
||||
|
||||
Same fall-through behaviour as before: API failure drops to
|
||||
the manual-label wizard with the error pre-filled.
|
||||
"""
|
||||
self.ensure_one()
|
||||
self._fp_validate_label_inputs()
|
||||
@@ -320,7 +444,17 @@ class FpReceiving(models.Model):
|
||||
self._fp_sync_packaging_to_shipment()
|
||||
try:
|
||||
picking = self._fp_build_shipping_picking()
|
||||
shipping_data = carrier.send_shipping(picking)
|
||||
# Per-shipment service override (e.g. Priority Overnight)
|
||||
# rides through to the carrier API via context. Empty
|
||||
# falls back to the carrier default. See
|
||||
# fusion_shipping.fusion_fedex_rest_send_shipping for the
|
||||
# consumer.
|
||||
ship_carrier = carrier
|
||||
if self.x_fc_outbound_service_type:
|
||||
ship_carrier = carrier.with_context(
|
||||
fp_service_type_override=self.x_fc_outbound_service_type,
|
||||
)
|
||||
shipping_data = ship_carrier.send_shipping(picking)
|
||||
self._fp_apply_shipping_result(picking, shipping_data)
|
||||
except UserError:
|
||||
raise
|
||||
@@ -413,8 +547,24 @@ class FpReceiving(models.Model):
|
||||
"""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.
|
||||
|
||||
Idempotent: if a prior call left a non-validated picking on
|
||||
x_fc_shipping_picking_id (e.g. the API call crashed before
|
||||
reaching button_validate), cancel it before building a fresh
|
||||
one. Without this guard, every retry of "Generate Outbound
|
||||
Label" leaks another WH/OUT picking into the Ready queue.
|
||||
"""
|
||||
self.ensure_one()
|
||||
prior = self.x_fc_shipping_picking_id
|
||||
if prior and prior.state not in ('done', 'cancel'):
|
||||
try:
|
||||
prior.sudo().action_cancel()
|
||||
except Exception:
|
||||
_logger.warning(
|
||||
'Receiving %s: could not cancel stale shipping '
|
||||
'picking %s (state=%s); leaving it in place.',
|
||||
self.name, prior.name, prior.state,
|
||||
)
|
||||
Picking = self.env['stock.picking'].sudo()
|
||||
warehouse = self.env['stock.warehouse'].sudo().search(
|
||||
[('company_id', '=', self.env.company.id)], limit=1,
|
||||
@@ -525,11 +675,6 @@ class FpReceiving(models.Model):
|
||||
'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
|
||||
|
||||
@@ -581,6 +726,72 @@ class FpReceiving(models.Model):
|
||||
('res_model', '=', 'stock.picking'),
|
||||
('res_id', '=', picking.id),
|
||||
], order='id asc')
|
||||
# Split labels by format so the two smart buttons (Print PDF /
|
||||
# Print ZPL) on the receiving form each open the right file.
|
||||
# FedEx names ZPL labels '...ZPLII'; PDFs are 'application/pdf'.
|
||||
pdf_atts = label_atts.filtered(
|
||||
lambda a: (a.mimetype or '').lower() == 'application/pdf'
|
||||
or (a.name or '').lower().endswith('.pdf')
|
||||
)
|
||||
zpl_atts = label_atts.filtered(
|
||||
lambda a: 'zpl' in (a.name or '').lower()
|
||||
)
|
||||
# FedEx ZPL ships with `^POI` (print-orientation invert), which
|
||||
# flips the label 180° on the printer. On a desktop thermal
|
||||
# like the Zebra ZD450 that comes out upside-down for the
|
||||
# operator AND labelary renders the PDF preview inverted to
|
||||
# match. Strip ^POI from a copy of the ZPL so both surfaces
|
||||
# show right-side-up. Original FedEx ZPL on the picking is
|
||||
# left untouched for audit. The cleaned copy is what operators
|
||||
# see (PDF preview + ZPL download).
|
||||
Attachment = self.env['ir.attachment'].sudo()
|
||||
cleaned_zpl_atts = self.env['ir.attachment'].sudo()
|
||||
for zpl in zpl_atts:
|
||||
raw = base64.b64decode(zpl.datas) if zpl.datas else b''
|
||||
if not raw:
|
||||
continue
|
||||
cleaned = raw.replace(b'^POI', b'')
|
||||
if cleaned == raw:
|
||||
# No ^POI present — keep using the original attachment.
|
||||
cleaned_zpl_atts |= zpl
|
||||
continue
|
||||
cleaned_name = (zpl.name or 'label.zpl').rsplit('.', 1)
|
||||
cleaned_name = '%s-fixed.%s' % (
|
||||
cleaned_name[0],
|
||||
cleaned_name[1] if len(cleaned_name) > 1 else 'zpl',
|
||||
)
|
||||
cleaned_zpl_atts |= Attachment.create({
|
||||
'name': cleaned_name,
|
||||
'res_model': 'stock.picking',
|
||||
'res_id': picking.id,
|
||||
'datas': base64.b64encode(cleaned),
|
||||
'mimetype': 'text/plain',
|
||||
})
|
||||
zpl_atts = cleaned_zpl_atts or zpl_atts
|
||||
# When the carrier returned ZPL but not PDF, render a PDF
|
||||
# rasterization via labelary so the Print PDF smart button has
|
||||
# something to open. One FedEx ship call → two smart buttons.
|
||||
# Best-effort: if labelary is unreachable, the ZPL button still
|
||||
# works and the operator can print from the Zebra directly.
|
||||
if zpl_atts and not pdf_atts:
|
||||
for zpl in zpl_atts:
|
||||
pdf_bytes = self._fp_zpl_to_pdf_via_labelary(
|
||||
base64.b64decode(zpl.datas) if zpl.datas else None
|
||||
)
|
||||
if not pdf_bytes:
|
||||
continue
|
||||
pdf_name = (zpl.name or 'label.zpl').rsplit('.', 1)[0] + '.pdf'
|
||||
new_pdf = Attachment.create({
|
||||
'name': pdf_name,
|
||||
'res_model': 'stock.picking',
|
||||
'res_id': picking.id,
|
||||
'datas': base64.b64encode(pdf_bytes),
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
pdf_atts |= new_pdf
|
||||
# Primary slot keeps backward-compat: prefer PDF for the main
|
||||
# button, fall back to whatever the carrier returned otherwise.
|
||||
primary_atts = pdf_atts or label_atts
|
||||
# Per-package shipping_data list — one entry per package.
|
||||
sd_list = shipping_data if isinstance(shipping_data, list) else [
|
||||
shipping_data
|
||||
@@ -608,23 +819,28 @@ class FpReceiving(models.Model):
|
||||
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.
|
||||
# Use primary_atts (PDF-preferred) so the per-row "Label" link
|
||||
# opens a printable PDF, not raw ZPL.
|
||||
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 idx < len(primary_atts):
|
||||
row_vals['label_attachment_id'] = primary_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.
|
||||
# Shipment-level fields. Primary label = PDF (or first attachment
|
||||
# if carrier didn't return PDF); ZPL goes into its own slot so
|
||||
# the Print ZPL button can find it.
|
||||
vals = {'status': 'confirmed'}
|
||||
if primary_tracking:
|
||||
vals['tracking_number'] = primary_tracking
|
||||
if label_atts:
|
||||
vals['label_attachment_id'] = label_atts[0].id
|
||||
if primary_atts:
|
||||
vals['label_attachment_id'] = primary_atts[0].id
|
||||
if 'x_fc_label_attachment_ids' in ship._fields:
|
||||
vals['x_fc_label_attachment_ids'] = [(6, 0, label_atts.ids)]
|
||||
vals['x_fc_label_attachment_ids'] = [(6, 0, primary_atts.ids)]
|
||||
if zpl_atts and 'x_fc_label_zpl_attachment_id' in ship._fields:
|
||||
vals['x_fc_label_zpl_attachment_id'] = zpl_atts[0].id
|
||||
# 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
|
||||
@@ -638,7 +854,7 @@ class FpReceiving(models.Model):
|
||||
ship.sudo().write(vals)
|
||||
self.message_post(body=Markup(_(
|
||||
'Outbound label generated. Tracking: <b>%s</b>'
|
||||
)) % (tracking_number or '(see attached PDF)'))
|
||||
)) % (primary_tracking 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
|
||||
@@ -676,12 +892,169 @@ class FpReceiving(models.Model):
|
||||
self.name, picking.name, e,
|
||||
)
|
||||
|
||||
def action_print_label(self):
|
||||
"""Open the label PDF for printing.
|
||||
def action_refresh_shipping_quote(self):
|
||||
"""Fetch a rate quote from FedEx for the current carrier +
|
||||
service + weight/dims and store it as HTML for the preview
|
||||
panel. Best-effort: any failure renders a friendly error
|
||||
message in the same panel instead of raising.
|
||||
|
||||
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.
|
||||
Only wired up for FedEx REST today; other carriers fall back
|
||||
to a "not supported" message. Add a branch here when wiring
|
||||
Canada Post / UPS rate quotes.
|
||||
"""
|
||||
self.ensure_one()
|
||||
try:
|
||||
carrier = self.x_fc_carrier_id
|
||||
if not carrier:
|
||||
raise UserError(_('Pick an Outbound Carrier first.'))
|
||||
if not self.x_fc_weight or self.x_fc_weight <= 0:
|
||||
raise UserError(_('Enter a non-zero Weight first.'))
|
||||
so = self.sale_order_id
|
||||
if not so:
|
||||
raise UserError(_(
|
||||
'No sale order linked — cannot resolve sender / '
|
||||
'recipient addresses for the quote.'
|
||||
))
|
||||
if carrier.delivery_type != 'fusion_fedex_rest':
|
||||
self.x_fc_shipping_quote_html = self._fp_quote_html_msg(
|
||||
_('Rate quote is only wired up for FedEx REST '
|
||||
'right now. Carrier "%s" is not supported.') % (
|
||||
carrier.name,
|
||||
),
|
||||
is_error=True,
|
||||
)
|
||||
return
|
||||
result = self._fp_quote_fedex_rate(carrier, so)
|
||||
self.x_fc_shipping_quote_html = self._fp_format_shipping_quote(
|
||||
result
|
||||
)
|
||||
except UserError as exc:
|
||||
self.x_fc_shipping_quote_html = self._fp_quote_html_msg(
|
||||
str(exc), is_error=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.warning(
|
||||
'Receiving %s: shipping quote failed: %s', self.name, exc,
|
||||
)
|
||||
self.x_fc_shipping_quote_html = self._fp_quote_html_msg(
|
||||
_('Quote failed: %s') % exc, is_error=True,
|
||||
)
|
||||
|
||||
def _fp_quote_fedex_rate(self, carrier, so):
|
||||
"""Call FedEx /rate/v1/rates/quotes with the receiving's
|
||||
current weight + service-type override. Returns the dict from
|
||||
FedexRestRequest._get_shipping_price (price, service_name,
|
||||
delivery_timestamp, etc.).
|
||||
"""
|
||||
# Lazy import — fusion_plating_receiving depends on
|
||||
# fusion_shipping but importing at module load order can race
|
||||
# with the registry. Inside-method keeps everything sane.
|
||||
from odoo.addons.fusion_shipping.api.fedex_rest.request import (
|
||||
FedexRequest as FedexRestRequest,
|
||||
)
|
||||
srm = FedexRestRequest(carrier)
|
||||
if self.x_fc_outbound_service_type:
|
||||
srm.service_type = self.x_fc_outbound_service_type
|
||||
package_type = (
|
||||
carrier.fedex_rest_default_package_type_id.shipper_package_code
|
||||
or 'YOUR_PACKAGING'
|
||||
) if carrier.fedex_rest_default_package_type_id else 'YOUR_PACKAGING'
|
||||
pkg = SimpleNamespace(
|
||||
weight=self.x_fc_weight,
|
||||
dimension={
|
||||
'length': self.x_fc_length or 0,
|
||||
'width': self.x_fc_width or 0,
|
||||
'height': self.x_fc_height or 0,
|
||||
},
|
||||
packaging_type=package_type,
|
||||
total_cost=0,
|
||||
commodities=[],
|
||||
currency_id=so.currency_id,
|
||||
)
|
||||
ship_from = (
|
||||
so.warehouse_id.partner_id
|
||||
if so.warehouse_id else self.env.company.partner_id
|
||||
)
|
||||
return srm._get_shipping_price(
|
||||
ship_from=ship_from,
|
||||
ship_to=so.partner_shipping_id or so.partner_id,
|
||||
packages=[pkg],
|
||||
currency=so.currency_id.name,
|
||||
)
|
||||
|
||||
def _fp_format_shipping_quote(self, result):
|
||||
"""Render a rate-quote result dict as the HTML the form panel
|
||||
displays. Kept here so the styling decisions live next to the
|
||||
view that consumes them.
|
||||
"""
|
||||
price = result.get('price') or 0.0
|
||||
currency = result.get('currency') or ''
|
||||
service_name = result.get('service_name') or ''
|
||||
service_code = result.get('service_type') or ''
|
||||
delivery = result.get('delivery_timestamp') or ''
|
||||
day_of_week = result.get('day_of_week') or ''
|
||||
transit = result.get('transit_time') or ''
|
||||
# Trim the FedEx ISO timestamp to "YYYY-MM-DD HH:MM" if present.
|
||||
if delivery and 'T' in delivery:
|
||||
delivery = delivery.replace('T', ' ')[:16]
|
||||
eta_line = ''
|
||||
if delivery or day_of_week:
|
||||
eta_line = '<div><strong>Estimated delivery:</strong> %s%s</div>' % (
|
||||
delivery or '(date not provided)',
|
||||
' (%s)' % day_of_week.title() if day_of_week else '',
|
||||
)
|
||||
transit_line = (
|
||||
'<div><strong>Transit:</strong> %s</div>'
|
||||
% transit.replace('_', ' ').title()
|
||||
) if transit else ''
|
||||
# Colours come from fp_shipping_quote.scss (theme-aware). Only
|
||||
# structural styling lives inline (sizes, weights, spacing).
|
||||
return (
|
||||
'<div class="fp_shipping_quote_body" style="font-size: 14px;">'
|
||||
'<div style="font-size: 22px; font-weight: 700; margin-bottom: 8px;">'
|
||||
'%(currency)s %(price).2f'
|
||||
'</div>'
|
||||
'<div style="margin-bottom: 4px;"><strong>%(service)s</strong>'
|
||||
'%(code)s</div>'
|
||||
'%(eta)s'
|
||||
'%(transit)s'
|
||||
'<div class="fp_shipping_quote_footnote" '
|
||||
'style="font-size: 11px; opacity: 0.65; margin-top: 10px;">'
|
||||
'Quote is an estimate from FedEx — final charges may differ.'
|
||||
'</div>'
|
||||
'</div>'
|
||||
) % {
|
||||
'currency': currency,
|
||||
'price': price,
|
||||
'service': service_name or service_code or 'Carrier service',
|
||||
'code': ' <span style="opacity:0.65;">(%s)</span>' % service_code if (
|
||||
service_name and service_code and service_name != service_code
|
||||
) else '',
|
||||
'eta': eta_line,
|
||||
'transit': transit_line,
|
||||
}
|
||||
|
||||
def _fp_quote_html_msg(self, msg, is_error=False):
|
||||
"""Wrap a one-line message in the same styling as the quote
|
||||
panel so the right-side column doesn't flicker between layouts.
|
||||
Colour comes from the wrapper class (theme-aware); errors use
|
||||
the Bootstrap danger semantic so dark/light both look right.
|
||||
"""
|
||||
klass = 'text-danger' if is_error else ''
|
||||
icon = 'fa-exclamation-triangle' if is_error else 'fa-info-circle'
|
||||
return (
|
||||
'<div class="%s" style="font-size: 14px;">'
|
||||
'<i class="fa %s me-2"/>%s'
|
||||
'</div>'
|
||||
) % (klass, icon, msg)
|
||||
|
||||
def action_print_label(self):
|
||||
"""Open the primary (PDF) label in the fusion_pdf_preview dialog.
|
||||
|
||||
Delegates to fusion.shipment._action_open_attachment, which
|
||||
routes PDFs through the preview client action and falls back
|
||||
to a new-tab URL when fusion_pdf_preview isn't installed. See
|
||||
CLAUDE.md "PDF Preview" for the contract.
|
||||
"""
|
||||
self.ensure_one()
|
||||
ship = self.x_fc_outbound_shipment_id
|
||||
@@ -690,11 +1063,64 @@ class FpReceiving(models.Model):
|
||||
'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',
|
||||
}
|
||||
return ship._action_open_attachment(ship.label_attachment_id)
|
||||
|
||||
def _fp_zpl_to_pdf_via_labelary(self, zpl_bytes):
|
||||
"""POST raw ZPL to labelary and return the rendered PDF bytes.
|
||||
|
||||
Returns None on any failure — caller treats labelary as a
|
||||
best-effort enhancement, never a blocker for label generation.
|
||||
See CLAUDE.md "labelary.com dependency" for privacy + ratelimit
|
||||
notes.
|
||||
"""
|
||||
if not zpl_bytes:
|
||||
return None
|
||||
try:
|
||||
res = requests.post(
|
||||
_LABELARY_URL,
|
||||
data=zpl_bytes,
|
||||
headers={
|
||||
'Accept': 'application/pdf',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
timeout=_LABELARY_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
_logger.warning(
|
||||
'Receiving %s: labelary ZPL→PDF request failed: %s',
|
||||
self.name, exc,
|
||||
)
|
||||
return None
|
||||
if not res.ok:
|
||||
_logger.warning(
|
||||
'Receiving %s: labelary returned %s — %s',
|
||||
self.name, res.status_code, res.text[:200],
|
||||
)
|
||||
return None
|
||||
return res.content
|
||||
|
||||
def action_print_label_zpl(self):
|
||||
"""Open the ZPL/ZPLII label for direct-to-thermal-printer use.
|
||||
|
||||
Visibility on the form is gated by x_fc_has_label_zpl so this
|
||||
only appears when a ZPL attachment is actually present — i.e.
|
||||
the carrier returned ZPL on Generate, or a ZPL fetch was added
|
||||
later. When no ZPL exists, the operator should use the PDF
|
||||
button instead (PDF prints on any printer).
|
||||
"""
|
||||
self.ensure_one()
|
||||
ship = self.x_fc_outbound_shipment_id
|
||||
if not ship or not ship.x_fc_label_zpl_attachment_id:
|
||||
raise UserError(_(
|
||||
'No ZPL label on this shipment. Use the PDF version, '
|
||||
'or switch the FedEx carrier label format to ZPLII and '
|
||||
'regenerate.'
|
||||
))
|
||||
return ship.x_fc_label_zpl_attachment_id.action_fusion_preview(
|
||||
title=ship.x_fc_label_zpl_attachment_id.name or 'ZPL Label',
|
||||
model_name=self._name,
|
||||
record_ids=self.id,
|
||||
)
|
||||
notes = fields.Html(string='Notes')
|
||||
|
||||
line_ids = fields.One2many('fp.receiving.line', 'receiving_id', string='Receiving Lines')
|
||||
@@ -804,6 +1230,47 @@ class FpReceiving(models.Model):
|
||||
rec._update_so_receiving_status()
|
||||
rec.message_post(body=_('Receiving closed.'))
|
||||
|
||||
def action_reset_to_counted(self):
|
||||
"""Reset a Closed receiving back to Counted.
|
||||
|
||||
Recovery escape hatch for when receiving was closed prematurely.
|
||||
Blocked once downstream work has begun — operator must cancel
|
||||
every fp.job spawned from this SO and avoid touching any step
|
||||
before the rewind is allowed. Without the gate it's trivial to
|
||||
rewind a receiving while jobs are mid-flight, which silently
|
||||
breaks the qty_received feed and the cert mark-done gate.
|
||||
"""
|
||||
Job = self.env.get('fp.job')
|
||||
for rec in self:
|
||||
if rec.state != 'closed':
|
||||
raise UserError(_('Only Closed receivings can be reset.'))
|
||||
if Job is not None and rec.sale_order_id:
|
||||
jobs = Job.sudo().search([
|
||||
('sale_order_id', '=', rec.sale_order_id.id),
|
||||
])
|
||||
if jobs:
|
||||
started = jobs.step_ids.filtered(
|
||||
lambda s: s.state in (
|
||||
'in_progress', 'paused', 'done', 'skipped',
|
||||
)
|
||||
)
|
||||
if started:
|
||||
raise UserError(_(
|
||||
'Cannot reset — %d step(s) on this order have '
|
||||
'been started. Reset is only allowed before '
|
||||
'work begins.'
|
||||
) % len(started))
|
||||
active = jobs.filtered(lambda j: j.state != 'cancelled')
|
||||
if active:
|
||||
raise UserError(_(
|
||||
'Cannot reset — %d work order(s) on this sale '
|
||||
'order are not cancelled. Cancel them first, '
|
||||
'then retry.'
|
||||
) % len(active))
|
||||
rec.state = 'counted'
|
||||
rec._update_so_receiving_status()
|
||||
rec.message_post(body=_('Receiving reset to Counted.'))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Legacy state actions — kept for backward compatibility.
|
||||
# Deprecated: Sub 8 moves part-level inspection to fp.racking.inspection.
|
||||
|
||||
@@ -10,7 +10,8 @@ here (not in fusion_shipping) to keep the upstream module untouched.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,6 +42,42 @@ class FusionShipment(models.Model):
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# Separate slot for the ZPL version of the label. FedEx (and most
|
||||
# carriers) return one format per ship-call; the primary
|
||||
# label_attachment_id holds whatever the carrier was configured to
|
||||
# return (we default to PDF). This field is populated only when a
|
||||
# ZPL variant has been fetched explicitly. Two slots = two smart
|
||||
# buttons on the receiving form, one per format.
|
||||
x_fc_label_zpl_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='ZPL Label',
|
||||
copy=False,
|
||||
help='ZPL/ZPLII version of the shipping label. Empty unless '
|
||||
'the carrier returned ZPL (or a ZPL fetch was triggered '
|
||||
'separately).',
|
||||
)
|
||||
|
||||
def action_view_label_zpl(self):
|
||||
"""Download the ZPL label for direct-to-thermal-printer use.
|
||||
|
||||
ZPL is text/plain — the PDF preview dialog can't render it, so
|
||||
this stays on the legacy download path (no preview, just a file
|
||||
the operator sends to their Zebra). Mirrors fp.receiving's
|
||||
action_print_label_zpl so the button exists on both forms.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_label_zpl_attachment_id:
|
||||
raise UserError(_(
|
||||
'No ZPL label on this shipment. Use the PDF version, '
|
||||
'or switch the carrier label format to ZPLII and '
|
||||
'regenerate.'
|
||||
))
|
||||
return self.x_fc_label_zpl_attachment_id.action_fusion_preview(
|
||||
title=self.x_fc_label_zpl_attachment_id.name or 'ZPL Label',
|
||||
model_name=self._name,
|
||||
record_ids=self.id,
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -17,6 +17,9 @@ access_fp_racking_inspection_line_manager,fp.racking.inspection.line.manager,mod
|
||||
access_fp_label_manual_wizard_receiver,fp.label.manual.wizard.receiver,model_fp_label_manual_wizard,group_fp_receiving,1,1,1,1
|
||||
access_fp_label_manual_wizard_supervisor,fp.label.manual.wizard.supervisor,model_fp_label_manual_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_label_manual_wizard_manager,fp.label.manual.wizard.manager,model_fp_label_manual_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_label_generate_wizard_receiver,fp.label.generate.wizard.receiver,model_fp_label_generate_wizard,group_fp_receiving,1,1,1,1
|
||||
access_fp_label_generate_wizard_supervisor,fp.label.generate.wizard.supervisor,model_fp_label_generate_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_label_generate_wizard_manager,fp.label.generate.wizard.manager,model_fp_label_generate_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_outbound_package_receiver,fp.outbound.package.receiver,model_fp_outbound_package,group_fp_receiving,1,1,1,1
|
||||
access_fp_outbound_package_supervisor,fp.outbound.package.supervisor,model_fp_outbound_package,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_outbound_package_manager,fp.outbound.package.manager,model_fp_outbound_package,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,78 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Shipping-Quote Callout Panel
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// Yellow-tinted info panel rendered on the right side of the receiving
|
||||
// form. Branches on Odoo's compile-time $o-webclient-color-scheme so it
|
||||
// produces readable contrast in BOTH light (warm cornsilk) and dark
|
||||
// (deep amber) bundles. See CLAUDE.md "Dark Mode" for why we branch at
|
||||
// compile time instead of using a runtime class selector — Odoo 19
|
||||
// serves two pre-compiled bundles, no .o_dark_mode toggle fires.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// Light (cornsilk on white page)
|
||||
$_fp-quote-bg-hex : #fff8dc;
|
||||
$_fp-quote-border-hex : #e6d28f;
|
||||
$_fp-quote-label-hex : #7a5b00;
|
||||
$_fp-quote-body-hex : #1f2937;
|
||||
$_fp-quote-muted-hex : #6b6452;
|
||||
|
||||
// Dark (warm-amber tint that still reads against $fp-page)
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fp-quote-bg-hex : #3a2f10 !global;
|
||||
$_fp-quote-border-hex : #5a4820 !global;
|
||||
$_fp-quote-label-hex : #ffd866 !global;
|
||||
$_fp-quote-body-hex : #e5e7eb !global;
|
||||
$_fp-quote-muted-hex : #b8a877 !global;
|
||||
}
|
||||
|
||||
// CSS custom-property fallback chain so a deployment can override
|
||||
// without touching SCSS.
|
||||
$fp-quote-bg : var(--fp-quote-bg, $_fp-quote-bg-hex);
|
||||
$fp-quote-border : var(--fp-quote-border, $_fp-quote-border-hex);
|
||||
$fp-quote-label : var(--fp-quote-label, $_fp-quote-label-hex);
|
||||
$fp-quote-body : var(--fp-quote-body, $_fp-quote-body-hex);
|
||||
$fp-quote-muted : var(--fp-quote-muted, $_fp-quote-muted-hex);
|
||||
|
||||
|
||||
.fp_shipping_quote_callout {
|
||||
background-color: $fp-quote-bg;
|
||||
border: 1px solid $fp-quote-border;
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
color: $fp-quote-body;
|
||||
|
||||
.fp_shipping_quote_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.fp_shipping_quote_label {
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 700;
|
||||
color: $fp-quote-label;
|
||||
|
||||
i.fa {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quote body — the HTML field rendered by _fp_format_shipping_quote.
|
||||
// Inherits the body colour from the wrapper; the .text-muted-style
|
||||
// small-print uses our muted token.
|
||||
.o_field_html,
|
||||
.o_field_html * {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.fp_shipping_quote_placeholder {
|
||||
color: $fp-quote-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,16 @@
|
||||
class="btn-primary"
|
||||
invisible="state not in ('draft', 'inspecting')"/>
|
||||
<button name="action_close"
|
||||
string="Close — Racking Confirmed"
|
||||
string="Close Receiving"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="state not in ('counted', 'staged', 'accepted', 'resolved')"/>
|
||||
<button name="action_reset_to_counted"
|
||||
string="Reset to Counted"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
confirm="Reset this receiving back to Counted? Use only if no work has started on the order."
|
||||
invisible="state != 'closed'"/>
|
||||
<!-- Legacy actions (hidden by default; surfaces for old records) -->
|
||||
<button name="action_accept"
|
||||
string="Accept (legacy)"
|
||||
@@ -77,7 +83,7 @@
|
||||
string="Generate Outbound Label"
|
||||
class="btn-primary"
|
||||
icon="fa-print"
|
||||
invisible="not x_fc_carrier_id or not x_fc_weight"/>
|
||||
invisible="not x_fc_carrier_id or not x_fc_weight or x_fc_has_label"/>
|
||||
<button name="action_print_label"
|
||||
type="object"
|
||||
string="Print Label"
|
||||
@@ -108,6 +114,17 @@
|
||||
</div>
|
||||
<field name="x_fc_has_label" invisible="1"/>
|
||||
</button>
|
||||
<button name="action_print_label_zpl"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-barcode"
|
||||
invisible="not x_fc_has_label_zpl">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">ZPL</span>
|
||||
<span class="o_stat_text">Print Label</span>
|
||||
</div>
|
||||
<field name="x_fc_has_label_zpl" invisible="1"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-2"/>
|
||||
@@ -139,6 +156,9 @@
|
||||
<field name="received_date"/>
|
||||
<field name="x_fc_carrier_id"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="x_fc_outbound_service_type"
|
||||
invisible="not x_fc_carrier_id"
|
||||
placeholder="Carrier default"/>
|
||||
<field name="carrier_tracking"/>
|
||||
<!--
|
||||
Legacy carrier_name (Char) is retained
|
||||
@@ -154,6 +174,35 @@
|
||||
-->
|
||||
<field name="carrier_name" invisible="1"/>
|
||||
</group>
|
||||
<!-- Shipping-quote preview. The .fp_shipping_quote_callout
|
||||
class in fp_shipping_quote.scss handles
|
||||
colour for both light + dark bundles —
|
||||
yellow tint that flips to deep amber on
|
||||
dark theme. Structure-only styling stays
|
||||
inline; semantic colour lives in SCSS. -->
|
||||
<div invisible="not x_fc_carrier_id"
|
||||
class="fp_shipping_quote_callout">
|
||||
<div class="fp_shipping_quote_header">
|
||||
<strong class="fp_shipping_quote_label">
|
||||
<i class="fa fa-truck"/>Shipping Quote
|
||||
</strong>
|
||||
<button name="action_refresh_shipping_quote"
|
||||
type="object"
|
||||
string="Refresh Quote"
|
||||
class="btn btn-sm btn-warning"
|
||||
icon="fa-refresh"/>
|
||||
</div>
|
||||
<field name="x_fc_shipping_quote_html"
|
||||
nolabel="1" readonly="1"
|
||||
widget="html"/>
|
||||
<div invisible="x_fc_shipping_quote_html"
|
||||
class="fp_shipping_quote_placeholder">
|
||||
Click <strong>Refresh Quote</strong> to
|
||||
fetch the price and estimated delivery
|
||||
date for the current carrier + service
|
||||
+ weight.
|
||||
</div>
|
||||
</div>
|
||||
</group>
|
||||
<group string="Outbound Packaging"
|
||||
invisible="not x_fc_carrier_id">
|
||||
|
||||
@@ -27,6 +27,22 @@
|
||||
</list>
|
||||
</field>
|
||||
</xpath>
|
||||
|
||||
<!-- Mirror the receiving form's two-button layout: the
|
||||
existing "Print Label PDF" smart button (rendered by
|
||||
fusion_shipping) handles the primary PDF; this adds a
|
||||
sibling ZPL button only when a ZPL attachment exists. -->
|
||||
<xpath expr="//div[@name='button_box']/button[@name='action_view_label']" position="after">
|
||||
<button name="action_view_label_zpl" type="object"
|
||||
class="oe_stat_button" icon="fa-barcode"
|
||||
invisible="not x_fc_label_zpl_attachment_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">ZPL</span>
|
||||
<span class="o_stat_text">Print Label</span>
|
||||
</div>
|
||||
<field name="x_fc_label_zpl_attachment_id" invisible="1"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_label_manual_wizard
|
||||
from . import fp_label_generate_wizard
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Service-type confirmation wizard.
|
||||
|
||||
Opens when fp.receiving.action_generate_outbound_label fires (assuming
|
||||
a label hasn't already been generated). Forces the operator to look at
|
||||
carrier + service tier + weight before the API call is made — cheaper
|
||||
shipping decisions, no surprise charges, and a hard gate against the
|
||||
accidental-double-click bug where every Generate click leaked a fresh
|
||||
FedEx shipment + tracking number.
|
||||
|
||||
Picked service rides through to the FedEx API via the
|
||||
`fp_service_type_override` context key (see CLAUDE.md).
|
||||
"""
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpLabelGenerateWizard(models.TransientModel):
|
||||
_name = 'fp.label.generate.wizard'
|
||||
_description = 'Fusion Plating — Confirm Label Generation'
|
||||
|
||||
receiving_id = fields.Many2one(
|
||||
'fp.receiving', required=True, readonly=True, ondelete='cascade',
|
||||
)
|
||||
receiving_name = fields.Char(related='receiving_id.name', readonly=True)
|
||||
carrier_id = fields.Many2one(
|
||||
related='receiving_id.x_fc_carrier_id', readonly=True,
|
||||
)
|
||||
carrier_default_service = fields.Char(
|
||||
compute='_compute_carrier_default_service',
|
||||
string='Carrier Default',
|
||||
help='What the carrier would use if no override is set.',
|
||||
)
|
||||
service_type = fields.Selection(
|
||||
selection='_fp_get_service_type_selection',
|
||||
string='Service Type',
|
||||
help='Leave blank to use the carrier default shown above. Pick '
|
||||
'a faster tier (Priority Overnight, 2Day, etc.) when the '
|
||||
'customer is paying for expedited delivery.',
|
||||
)
|
||||
weight = fields.Float(string='Weight', digits=(10, 3), required=True)
|
||||
weight_uom = fields.Selection(
|
||||
related='receiving_id.x_fc_weight_uom', readonly=True,
|
||||
)
|
||||
length = fields.Float(string='Length', digits=(10, 2))
|
||||
width = fields.Float(string='Width', digits=(10, 2))
|
||||
height = fields.Float(string='Height', digits=(10, 2))
|
||||
dim_uom = fields.Selection(
|
||||
related='receiving_id.x_fc_dim_uom', readonly=True,
|
||||
)
|
||||
|
||||
def _fp_get_service_type_selection(self):
|
||||
# Single source of truth — pulls the curated list from
|
||||
# fp.receiving so both the form dropdown and the wizard stay
|
||||
# in sync. See fp.receiving._FP_USABLE_FEDEX_SERVICES.
|
||||
Receiving = self.env.get('fp.receiving')
|
||||
if Receiving is None:
|
||||
return []
|
||||
return Receiving._fp_get_service_type_selection()
|
||||
|
||||
def _compute_carrier_default_service(self):
|
||||
for wiz in self:
|
||||
carrier = wiz.carrier_id
|
||||
if not carrier or 'fedex_rest_service_type' not in carrier._fields:
|
||||
wiz.carrier_default_service = ''
|
||||
continue
|
||||
code = carrier.fedex_rest_service_type or ''
|
||||
label = dict(
|
||||
carrier._fields['fedex_rest_service_type'].selection
|
||||
).get(code, code)
|
||||
wiz.carrier_default_service = label or _('(none set)')
|
||||
|
||||
@classmethod
|
||||
def _fp_default_from_receiving(cls, env, rec):
|
||||
"""Build the wizard create-vals from a receiving record."""
|
||||
return {
|
||||
'receiving_id': rec.id,
|
||||
'service_type': rec.x_fc_outbound_service_type or False,
|
||||
'weight': rec.x_fc_weight or 0.0,
|
||||
'length': rec.x_fc_length or 0.0,
|
||||
'width': rec.x_fc_width or 0.0,
|
||||
'height': rec.x_fc_height or 0.0,
|
||||
}
|
||||
|
||||
def action_confirm(self):
|
||||
"""Apply the operator's choices back to the receiving, then
|
||||
delegate to fp.receiving._fp_actually_generate_outbound_label
|
||||
which makes the API call. Wizard closes; result action is the
|
||||
outbound shipment view (or the manual-fallback wizard on error).
|
||||
"""
|
||||
self.ensure_one()
|
||||
rec = self.receiving_id
|
||||
if not rec:
|
||||
raise UserError(_('Wizard is detached from the receiving.'))
|
||||
if not self.weight or self.weight <= 0:
|
||||
raise UserError(_('Enter a non-zero weight before generating.'))
|
||||
rec.write({
|
||||
'x_fc_outbound_service_type': self.service_type or False,
|
||||
'x_fc_weight': self.weight,
|
||||
'x_fc_length': self.length,
|
||||
'x_fc_width': self.width,
|
||||
'x_fc_height': self.height,
|
||||
})
|
||||
return rec._fp_actually_generate_outbound_label()
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Service-type confirmation wizard. Pops up before every
|
||||
"Generate Outbound Label" so the operator can pick the FedEx
|
||||
service tier (or accept the carrier default) and double-check
|
||||
weight/dimensions before any API call is made.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_label_generate_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.label.generate.wizard.form</field>
|
||||
<field name="model">fp.label.generate.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Generate Outbound Label">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>Generate Label —
|
||||
<field name="receiving_name"
|
||||
readonly="1" nolabel="1" class="oe_inline"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-2"/>
|
||||
Confirm the service tier and package details before
|
||||
the carrier API is called. Each generation creates
|
||||
a billable shipment.
|
||||
</div>
|
||||
<group>
|
||||
<group string="Carrier">
|
||||
<field name="carrier_id" readonly="1"/>
|
||||
<field name="carrier_default_service"
|
||||
readonly="1"/>
|
||||
<field name="service_type"
|
||||
placeholder="Use carrier default"/>
|
||||
</group>
|
||||
<group string="Package">
|
||||
<label for="weight"/>
|
||||
<div class="o_row">
|
||||
<field name="weight" class="oe_inline"/>
|
||||
<field name="weight_uom" nolabel="1"
|
||||
readonly="1" class="oe_inline"/>
|
||||
</div>
|
||||
<label for="length" string="Dimensions (L×W×H)"/>
|
||||
<div class="o_row">
|
||||
<field name="length" class="oe_inline"/>
|
||||
<span>×</span>
|
||||
<field name="width" class="oe_inline"/>
|
||||
<span>×</span>
|
||||
<field name="height" class="oe_inline"/>
|
||||
<field name="dim_uom" nolabel="1"
|
||||
readonly="1" class="oe_inline"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Generate Label" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user