This commit is contained in:
gsinghpal
2026-05-21 03:37:25 -04:00
parent b2f483d67c
commit 1314f4581d
47 changed files with 5730 additions and 177 deletions

View File

@@ -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.

View File

@@ -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