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