diff --git a/fusion-plating/%{http_code} b/fusion-plating/%{http_code}
new file mode 100644
index 00000000..e69de29b
diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md
index d5549f3d..6739805d 100644
--- a/fusion_plating/CLAUDE.md
+++ b/fusion_plating/CLAUDE.md
@@ -27,6 +27,11 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
| **Signature unification** | All FP reports (WO Detail, CoC, CoC Chronological) now read signatures from a single source: `signer_user.x_fc_signature_image` (Plating Signature). Retired: HR Employee signature lookup AND `res.company.x_fc_coc_signature_override` (UI removed; column kept, no migration). See rule 14b. | `fusion_plating_certificates`, `fusion_plating_reports`, `fusion_plating_jobs` |
| **Report palette overhaul** | Green `res.company.primary_color` → hardcoded neutral palette: `#c1c1c1` header backgrounds, `#1d1f1e` th text, `#2e2e2e` h2/h4 titles (bumped to 20pt portrait / 22pt landscape). Grand Total row also `#c1c1c1`. Work Order Detail blue `#1a4d80` retired in favour of the same palette. Title format now "Type # Number" (Quotation # …, Sales Order # …, Invoice # …, Packing Slip # …, Work Order Traveller # …). See rule 14a. | `fusion_plating_reports` 19.0.11.14.0, `fusion_plating_jobs` 19.0.10.8.0 |
| **Report border rendering** | After two failed attempts (px→mm conversion + dpi bump; then `border-collapse: separate` single-side-per-cell), settled on **`border-collapse: collapse` + longhand borders + `background-clip: padding-box`**. Verticals are a hair softer than horizontals on entech wkhtmltopdf — accepted as the lesser evil vs misaligned tables. See rule 14a, last paragraph. **Don't retry the single-side pattern.** | `fusion_plating_reports` |
+| **CoC + thickness = ONE cert (page 2 merge)** | When a customer has both `x_fc_send_coc` and `x_fc_send_thickness_report` on (or part has `certificate_requirement='coc_thickness'`), `_resolve_required_cert_types` returns **`{'coc'}` only** — the thickness data is delivered as page 2 of the CoC PDF via `_fp_merge_thickness_into_pdf`, not as a separate `thickness_report` cert. Standalone `thickness_report` certs are only created when CoC is OFF and thickness is ON (rare). The earlier "two certs" behavior was a bug — don't restore it. | `fusion_plating_jobs`, `fusion_plating_certificates` |
+| **Smart-button "create or view" pattern** | For a smart button that toggles between "create" and "view" states, use **one** idempotent button with `widget="statinfo"`, not two sibling buttons gated by mutually-exclusive `invisible` expressions. Custom `
- No Fischerscope PDF on the linked QC.
- If this customer expects an XRF report with the CoC,
- have the operator upload the Fischerscope PDF on the
- QC check before issuing.
+ No Fischerscope PDF available.
+ Drop the PDF into the Thickness Report
+ (Fischerscope) tab below, or upload it on the
+ linked QC check, before issuing. Thickness Report
+ certs cannot issue without thickness data.
No Fischerscope thickness PDF has been
- uploaded on the linked QC yet. The CoC will
- be issued without an appended thickness
- report. To attach one:
+ uploaded yet. The CoC will be issued without
+ an appended thickness report. Either drop the
+ PDF into the upload field above, OR upload it
+ on the linked QC check and re-open this cert.
-
-
Open the linked Plating Job (smart
- button above)
-
Click into the auto-spawned Quality
- Check
-
Go to the Thickness Report tab
- and upload the PDF from the Fischerscope
- / XDAL 600 export
-
Pass the QC, then come back here and
- click Issue
-
@@ -120,8 +118,8 @@
Click Issue in the header
- and the Fischerscope PDF above will be
- merged into page 2 of the CoC.
+ and the Fischerscope PDF will be merged into
+ page 2 of the CoC.
diff --git a/fusion_plating/fusion_plating_jobs/views/fp_job_cert_backfill.xml b/fusion_plating/fusion_plating_jobs/views/fp_job_cert_backfill.xml
new file mode 100644
index 00000000..dedbbd97
--- /dev/null
+++ b/fusion_plating/fusion_plating_jobs/views/fp_job_cert_backfill.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ Generate Missing Certs for Closed Jobs
+
+
+ list
+
+ code
+ action = env['fp.job'].action_backfill_missing_certs()
+
+
diff --git a/fusion_plating/fusion_plating_jobs/wizards/__init__.py b/fusion_plating/fusion_plating_jobs/wizards/__init__.py
index deae8927..98043e5e 100644
--- a/fusion_plating/fusion_plating_jobs/wizards/__init__.py
+++ b/fusion_plating/fusion_plating_jobs/wizards/__init__.py
@@ -4,3 +4,4 @@
from . import fp_job_step_move_wizard
from . import fp_job_step_input_wizard
+from . import fp_cert_issue_wizard
diff --git a/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard.py b/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard.py
new file mode 100644
index 00000000..22186a5e
--- /dev/null
+++ b/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard.py
@@ -0,0 +1,375 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+"""Issue Certs Wizard.
+
+Opened from a job's "Issue Certs" milestone button. Walks each draft
+cert on the job, lets the manager upload the Fischerscope/XDAL output
+(PDF or .docx) per cert that needs thickness data, and tries to parse
+the .docx to pre-populate the readings table. Manager can edit/add
+readings before confirming. On confirm:
+
+ - PDF uploads land on cert.x_fc_local_thickness_pdf (merged as page 2
+ of the issued CoC).
+ - .docx uploads are attached as ir.attachment on the cert (evidence)
+ and the parsed readings are written as fp.thickness.reading rows.
+ - cert.action_issue() is called for each cert.
+
+The wizard is a convenience layer — it does NOT replace the per-cert
+Issue button on the cert form, which stays as the fallback path.
+"""
+import base64
+import io
+import logging
+import re
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+# Fischerscope XDAL 600 reading line, e.g.
+# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
+_FISCHER_READING_RE = re.compile(
+ r'n\s*=\s*(\d+)'
+ r'\s+NiP\s+\d+\s*=\s*([\d.]+)\s*mils'
+ r'\s+Ni\s+\d+\s*=\s*([\d.]+)\s*%'
+ r'\s+P\s+\d+\s*=\s*([\d.]+)\s*%',
+ re.IGNORECASE,
+)
+_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+)', re.IGNORECASE)
+_FISCHER_OPERATOR_RE = re.compile(r'Operator:\s*(\S+)', re.IGNORECASE)
+_FISCHER_DATE_RE = re.compile(r'Date:\s*([\d/]+)', re.IGNORECASE)
+_FISCHER_TIME_RE = re.compile(r'Time:\s*([\d:]+\s*[APMapm]*)')
+
+
+def _fp_parse_fischerscope_docx(raw_bytes):
+ """Best-effort parse of a Fischerscope XDAL 600 .docx report.
+
+ Returns dict:
+ {
+ 'readings': [(nip_mils, ni_pct, p_pct), ...],
+ 'calibration': str or '',
+ 'operator': str or '',
+ 'date_str': str or '',
+ 'time_str': str or '',
+ 'raw_text': str (the extracted document body, for chatter),
+ }
+
+ Soft-fails to an empty dict-like result when python-docx isn't
+ installed or the bytes don't parse — the wizard still works, the
+ operator just has to type readings manually.
+ """
+ empty = {
+ 'readings': [], 'calibration': '', 'operator': '',
+ 'date_str': '', 'time_str': '', 'raw_text': '',
+ }
+ if not raw_bytes:
+ return empty
+ try:
+ import docx # python-docx
+ except ImportError:
+ _logger.info(
+ 'python-docx not installed — Fischerscope auto-parse '
+ 'skipped. Operator will enter readings manually.'
+ )
+ return empty
+ try:
+ doc = docx.Document(io.BytesIO(raw_bytes))
+ except Exception as e:
+ _logger.warning('Fischerscope .docx parse failed: %s', e)
+ return empty
+ # Pull text from paragraphs AND tables (Fischerscope reports
+ # sometimes lay the readings inside a table cell).
+ parts = [p.text for p in doc.paragraphs]
+ for tbl in doc.tables:
+ for row in tbl.rows:
+ for cell in row.cells:
+ parts.append(cell.text)
+ text = '\n'.join(parts)
+ readings = []
+ for m in _FISCHER_READING_RE.finditer(text):
+ try:
+ readings.append((
+ float(m.group(2)), # nip mils
+ float(m.group(3)), # Ni %
+ float(m.group(4)), # P %
+ ))
+ except ValueError:
+ continue
+ calib = ''
+ m = _FISCHER_CALIB_RE.search(text)
+ if m:
+ calib = m.group(1).strip()
+ operator = ''
+ m = _FISCHER_OPERATOR_RE.search(text)
+ if m:
+ operator = m.group(1).strip()
+ date_str = ''
+ m = _FISCHER_DATE_RE.search(text)
+ if m:
+ date_str = m.group(1).strip()
+ time_str = ''
+ m = _FISCHER_TIME_RE.search(text)
+ if m:
+ time_str = m.group(1).strip()
+ return {
+ 'readings': readings,
+ 'calibration': calib,
+ 'operator': operator,
+ 'date_str': date_str,
+ 'time_str': time_str,
+ 'raw_text': text,
+ }
+
+
+class FpCertIssueWizard(models.TransientModel):
+ _name = 'fp.cert.issue.wizard'
+ _description = 'Fusion Plating — Issue Certs Wizard'
+
+ job_id = fields.Many2one(
+ 'fp.job', string='Job', required=True, readonly=True,
+ )
+ line_ids = fields.One2many(
+ 'fp.cert.issue.wizard.line', 'wizard_id', string='Certs to Issue',
+ )
+ has_blocking_lines = fields.Boolean(
+ compute='_compute_has_blocking_lines',
+ help='True when at least one line is missing data the gate '
+ 'requires (no readings, no file, etc.). Used to disable '
+ 'the Confirm button.',
+ )
+
+ @api.depends('line_ids', 'line_ids.is_ready')
+ def _compute_has_blocking_lines(self):
+ for w in self:
+ w.has_blocking_lines = any(not ln.is_ready for ln in w.line_ids)
+
+ @api.model
+ def open_for_job(self, job):
+ """Factory — create a wizard pre-populated with one line per
+ draft cert on the job. Returns an action dict that opens the
+ wizard form."""
+ Cert = self.env['fp.certificate'].sudo()
+ certs = Cert.search([
+ ('x_fc_job_id', '=', job.id),
+ ('state', '=', 'draft'),
+ ])
+ if not certs:
+ raise UserError(_(
+ 'No draft certificates on %s to issue.'
+ ) % job.name)
+ wiz = self.create({
+ 'job_id': job.id,
+ 'line_ids': [(0, 0, {'cert_id': c.id}) for c in certs],
+ })
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Issue Certs — %s') % job.name,
+ 'res_model': self._name,
+ 'res_id': wiz.id,
+ 'view_mode': 'form',
+ 'target': 'new',
+ }
+
+ def action_confirm(self):
+ """Apply every line's file + readings, then issue each cert.
+
+ Order matters: write the file/readings BEFORE calling action_issue
+ so the gate sees the populated data. If a single cert raises on
+ issue, the whole wizard rolls back (transactional).
+ """
+ self.ensure_one()
+ issued = []
+ for ln in self.line_ids:
+ ln._apply_to_cert()
+ cert = ln.cert_id
+ if cert.state == 'draft':
+ cert.action_issue()
+ issued.append(cert.name)
+ if not issued:
+ return {'type': 'ir.actions.act_window_close'}
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': _('Certs Issued'),
+ 'message': _('%d cert(s) issued: %s') % (
+ len(issued), ', '.join(issued),
+ ),
+ 'sticky': False,
+ 'type': 'success',
+ 'next': {'type': 'ir.actions.act_window_close'},
+ },
+ }
+
+
+class FpCertIssueWizardLine(models.TransientModel):
+ _name = 'fp.cert.issue.wizard.line'
+ _description = 'Fusion Plating — Issue Certs Wizard Line'
+
+ wizard_id = fields.Many2one(
+ 'fp.cert.issue.wizard', required=True, ondelete='cascade',
+ )
+ cert_id = fields.Many2one(
+ 'fp.certificate', string='Certificate', required=True, readonly=True,
+ )
+ cert_name = fields.Char(related='cert_id.name', readonly=True)
+ cert_type = fields.Selection(
+ related='cert_id.certificate_type', readonly=True,
+ )
+ partner_id = fields.Many2one(
+ related='cert_id.partner_id', readonly=True,
+ )
+ needs_thickness = fields.Boolean(
+ compute='_compute_needs_thickness', store=False,
+ )
+ fischer_file = fields.Binary(string='Fischerscope File (PDF or .docx)')
+ fischer_filename = fields.Char(string='Filename')
+ parsed_summary = fields.Text(
+ string='Parsed Summary', readonly=True,
+ help='Output of the .docx parser. Populated when you attach a '
+ 'Fischerscope .docx; the readings table below is auto-'
+ 'filled from the same parse. Empty for PDF uploads.',
+ )
+ reading_line_ids = fields.One2many(
+ 'fp.cert.issue.wizard.reading', 'line_id', string='Readings',
+ )
+ is_ready = fields.Boolean(
+ compute='_compute_is_ready',
+ help='True when this cert has enough data to issue: thickness '
+ 'data present if needed.',
+ )
+
+ @api.depends('cert_id.certificate_type',
+ 'cert_id.partner_id.x_fc_send_thickness_report',
+ 'cert_id.partner_id.x_fc_strict_thickness_required')
+ def _compute_needs_thickness(self):
+ for ln in self:
+ cert = ln.cert_id
+ partner = cert.partner_id
+ ln.needs_thickness = (
+ cert.certificate_type == 'thickness_report'
+ or (cert.certificate_type == 'coc' and partner and (
+ partner.x_fc_strict_thickness_required
+ or partner.x_fc_send_thickness_report
+ ))
+ )
+
+ @api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
+ 'cert_id.thickness_reading_ids',
+ 'cert_id.x_fc_local_thickness_pdf')
+ def _compute_is_ready(self):
+ for ln in self:
+ if not ln.needs_thickness:
+ ln.is_ready = True
+ continue
+ ln.is_ready = bool(
+ ln.fischer_file
+ or ln.reading_line_ids
+ or ln.cert_id.thickness_reading_ids
+ or ln.cert_id.x_fc_local_thickness_pdf
+ )
+
+ @api.onchange('fischer_file', 'fischer_filename')
+ def _onchange_fischer_file(self):
+ """Try to parse .docx on upload; prefill the readings + summary."""
+ if not self.fischer_file:
+ return
+ name = (self.fischer_filename or '').lower()
+ if not name.endswith('.docx'):
+ self.parsed_summary = _(
+ 'Non-.docx upload (%s) — file will be attached as '
+ 'evidence. Type readings manually below if needed.'
+ ) % (self.fischer_filename or 'unnamed')
+ return
+ try:
+ raw = base64.b64decode(self.fischer_file)
+ except Exception:
+ self.parsed_summary = _('Could not decode the uploaded file.')
+ return
+ parsed = _fp_parse_fischerscope_docx(raw)
+ readings = parsed.get('readings') or []
+ if readings:
+ self.reading_line_ids = [(5, 0, 0)] + [
+ (0, 0, {
+ 'sequence': i + 1,
+ 'nip_mils': nip,
+ 'ni_percent': ni,
+ 'p_percent': p,
+ })
+ for i, (nip, ni, p) in enumerate(readings)
+ ]
+ self.parsed_summary = _(
+ 'Parsed %(n)d reading(s) · Calibration: %(c)s · '
+ 'Operator: %(o)s · Date: %(d)s %(t)s'
+ ) % {
+ 'n': len(readings),
+ 'c': parsed.get('calibration') or '—',
+ 'o': parsed.get('operator') or '—',
+ 'd': parsed.get('date_str') or '—',
+ 't': parsed.get('time_str') or '',
+ }
+
+ def _apply_to_cert(self):
+ """Write this line's data into the cert."""
+ self.ensure_one()
+ cert = self.cert_id.sudo()
+ if not self.fischer_file:
+ # Just push manual readings, if any.
+ self._push_readings_to_cert()
+ return
+ name = (self.fischer_filename or 'fischerscope').lower()
+ if name.endswith('.pdf'):
+ # Drop the PDF into the cert-local field — merges into page 2.
+ cert.write({
+ 'x_fc_local_thickness_pdf': self.fischer_file,
+ 'x_fc_local_thickness_pdf_filename': self.fischer_filename,
+ })
+ else:
+ # .doc / .docx / anything else — attach as evidence.
+ self.env['ir.attachment'].sudo().create({
+ 'name': self.fischer_filename or 'fischerscope-report',
+ 'type': 'binary',
+ 'datas': self.fischer_file,
+ 'res_model': 'fp.certificate',
+ 'res_id': cert.id,
+ })
+ cert.message_post(body=_(
+ 'Fischerscope file %s attached via Issue wizard.'
+ ) % (self.fischer_filename or 'unnamed'))
+ self._push_readings_to_cert()
+
+ def _push_readings_to_cert(self):
+ """Create fp.thickness.reading rows on the cert from wizard rows.
+ Skips when no rows. Does not deduplicate against existing
+ readings — the manager has just told us this is the new data."""
+ self.ensure_one()
+ Reading = self.env.get('fp.thickness.reading')
+ if Reading is None or not self.reading_line_ids:
+ return
+ for r in self.reading_line_ids:
+ vals = {
+ 'certificate_id': self.cert_id.id,
+ 'nip_mils': r.nip_mils,
+ 'ni_percent': r.ni_percent,
+ 'p_percent': r.p_percent,
+ }
+ if 'reading_number' in Reading._fields:
+ vals['reading_number'] = r.sequence
+ Reading.sudo().create(vals)
+
+
+class FpCertIssueWizardReading(models.TransientModel):
+ _name = 'fp.cert.issue.wizard.reading'
+ _description = 'Fusion Plating — Issue Certs Wizard Reading Row'
+ _order = 'sequence, id'
+
+ line_id = fields.Many2one(
+ 'fp.cert.issue.wizard.line', required=True, ondelete='cascade',
+ )
+ sequence = fields.Integer(default=1)
+ nip_mils = fields.Float(string='NiP (mils)', digits=(10, 4))
+ ni_percent = fields.Float(string='Ni %', digits=(6, 3))
+ p_percent = fields.Float(string='P %', digits=(6, 3))
diff --git a/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard_views.xml b/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard_views.xml
new file mode 100644
index 00000000..31e1f620
--- /dev/null
+++ b/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard_views.xml
@@ -0,0 +1,101 @@
+
+
+
+
+ fp.cert.issue.wizard.form
+ fp.cert.issue.wizard
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_logistics/__manifest__.py b/fusion_plating/fusion_plating_logistics/__manifest__.py
index c5c9a9a4..fabaa88f 100644
--- a/fusion_plating/fusion_plating_logistics/__manifest__.py
+++ b/fusion_plating/fusion_plating_logistics/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Logistics',
- 'version': '19.0.3.8.0',
+ 'version': '19.0.3.9.0',
'category': 'Manufacturing/Plating',
'summary': (
'Pickup & delivery for plating shops: vehicle master, driver '
@@ -43,6 +43,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating',
'fusion_plating_configurator',
'fusion_plating_receiving', # Shared "Shipping & Receiving" menu root
+ 'fusion_shipping',
'hr',
'mail',
],
diff --git a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py
index 4f57bfd4..5758eb26 100644
--- a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py
+++ b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py
@@ -123,6 +123,86 @@ class FpDelivery(models.Model):
'ir.attachment',
string='Packing List',
)
+
+ # ---- Phase A — outbound carrier + shipment link ----------------------
+ # Mirrors the fields on fp.receiving. Populated by
+ # fp.job._fp_create_delivery from the linked receiving when this
+ # delivery is auto-created on job-done; shipping crew can override
+ # at ship time.
+ x_fc_carrier_id = fields.Many2one(
+ 'delivery.carrier', string='Outbound Carrier', tracking=True,
+ ondelete='set null',
+ help='Carrier picked at receiving time; can be overridden by '
+ 'the shipping crew before issuing the label.',
+ )
+ x_fc_outbound_shipment_id = fields.Many2one(
+ 'fusion.shipment', string='Outbound Shipment', tracking=True,
+ ondelete='set null',
+ copy=False,
+ help='The shipment record carrying weight, dimensions, label '
+ 'PDF, and tracking. Usually the same shipment that was '
+ 'created at receiving time.',
+ )
+ x_fc_outbound_shipment_count = fields.Integer(
+ compute='_compute_x_fc_outbound_shipment_count',
+ )
+
+ @api.depends('x_fc_outbound_shipment_id')
+ def _compute_x_fc_outbound_shipment_count(self):
+ for rec in self:
+ rec.x_fc_outbound_shipment_count = (
+ 1 if rec.x_fc_outbound_shipment_id else 0
+ )
+
+ @api.onchange('x_fc_carrier_id')
+ def _onchange_x_fc_carrier_id(self):
+ for rec in self:
+ ship = rec.x_fc_outbound_shipment_id
+ if ship and ship.status == 'draft' and rec.x_fc_carrier_id:
+ ship.carrier_id = rec.x_fc_carrier_id.id
+
+ def action_create_outbound_shipment(self):
+ self.ensure_one()
+ if self.x_fc_outbound_shipment_id:
+ return self.action_view_outbound_shipment()
+ if 'fusion.shipment' not in self.env:
+ raise UserError(_(
+ 'fusion_shipping module is not installed. '
+ 'Cannot create an outbound shipment.'
+ ))
+ SO = self.env['sale.order'].sudo()
+ so = False
+ if self.job_ref:
+ Job = self.env.get('fp.job')
+ if Job is not None:
+ job = Job.sudo().search(
+ [('name', '=', self.job_ref)], limit=1,
+ )
+ so = job.sale_order_id if job else False
+ vals = {
+ 'sale_order_id': so.id if so else False,
+ 'carrier_id': self.x_fc_carrier_id.id if self.x_fc_carrier_id else False,
+ 'status': 'draft',
+ }
+ shipment = self.env['fusion.shipment'].sudo().create(vals)
+ self.x_fc_outbound_shipment_id = shipment.id
+ self.message_post(body=_(
+ 'Outbound shipment %s created (draft).'
+ ) % shipment.name)
+ return self.action_view_outbound_shipment()
+
+ def action_view_outbound_shipment(self):
+ self.ensure_one()
+ if not self.x_fc_outbound_shipment_id:
+ return False
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_outbound_shipment_id.name,
+ 'res_model': 'fusion.shipment',
+ 'res_id': self.x_fc_outbound_shipment_id.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
state = fields.Selection(
[
('draft', 'Draft'),
diff --git a/fusion_plating/fusion_plating_logistics/tests/__init__.py b/fusion_plating/fusion_plating_logistics/tests/__init__.py
new file mode 100644
index 00000000..337790fa
--- /dev/null
+++ b/fusion_plating/fusion_plating_logistics/tests/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import test_delivery_shipping_fields
diff --git a/fusion_plating/fusion_plating_logistics/tests/test_delivery_shipping_fields.py b/fusion_plating/fusion_plating_logistics/tests/test_delivery_shipping_fields.py
new file mode 100644
index 00000000..f1661f29
--- /dev/null
+++ b/fusion_plating/fusion_plating_logistics/tests/test_delivery_shipping_fields.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+"""Phase A — mirror carrier + outbound shipment fields on fp.delivery."""
+from odoo.tests.common import TransactionCase
+
+
+class TestDeliveryShippingFields(TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.partner = cls.env['res.partner'].create({'name': 'ShipCust'})
+
+ def test_carrier_id_field_exists_on_delivery(self):
+ delivery = self.env['fusion.plating.delivery'].create({
+ 'partner_id': self.partner.id,
+ })
+ self.assertIn('x_fc_carrier_id', delivery._fields)
+
+ def test_outbound_shipment_id_field_exists_on_delivery(self):
+ delivery = self.env['fusion.plating.delivery'].create({
+ 'partner_id': self.partner.id,
+ })
+ self.assertIn('x_fc_outbound_shipment_id', delivery._fields)
diff --git a/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml b/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml
index 1f22c892..7287c37f 100644
--- a/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml
+++ b/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml
@@ -59,6 +59,16 @@
statusbar_visible="draft,scheduled,en_route,delivered"/>
+
+
+
@@ -84,7 +94,9 @@
-
+
+
diff --git a/fusion_plating/fusion_plating_notifications/__manifest__.py b/fusion_plating/fusion_plating_notifications/__manifest__.py
index 7ac43fd4..82c680e1 100644
--- a/fusion_plating/fusion_plating_notifications/__manifest__.py
+++ b/fusion_plating/fusion_plating_notifications/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Notifications',
- 'version': '19.0.6.4.0',
+ 'version': '19.0.6.6.0',
'category': 'Manufacturing/Plating',
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
'author': 'Nexa Systems Inc.',
@@ -22,6 +22,7 @@
'fusion_plating_invoicing',
'fusion_plating_logistics',
'fusion_plating_reports',
+ 'fusion_shipping',
'sale_management',
'account',
'mail',
diff --git a/fusion_plating/fusion_plating_notifications/data/fp_notification_template_data.xml b/fusion_plating/fusion_plating_notifications/data/fp_notification_template_data.xml
index 867c45d2..38c2e64a 100644
--- a/fusion_plating/fusion_plating_notifications/data/fp_notification_template_data.xml
+++ b/fusion_plating/fusion_plating_notifications/data/fp_notification_template_data.xml
@@ -35,6 +35,13 @@
+
+ Shipping Label Generated
+ shipment_labeled
+
+
+
+
Shipped / Deliveredshipped
diff --git a/fusion_plating/fusion_plating_notifications/data/mail_template_data.xml b/fusion_plating/fusion_plating_notifications/data/mail_template_data.xml
index 7fe208b6..de6ca1b0 100644
--- a/fusion_plating/fusion_plating_notifications/data/mail_template_data.xml
+++ b/fusion_plating/fusion_plating_notifications/data/mail_template_data.xml
@@ -184,6 +184,70 @@
fp.notification.template's `job_complete` trigger, defined
in fp_notification_template_data.xml. -->
+
+
+
+
+
+
+
+ FP: Shipping Label Generated
+
+ Tracking #{{ object.tracking_number }} — your order is being prepared for shipment
+ {{ (object.company_id.email or user.email) }}
+ {{ (object.sale_order_id and object.sale_order_id.partner_id.email) or '' }}
+
+
+
+
+
+ EN Technologies
+
+
Your Order Is Being Prepared for Shipment
+
+ Hi , the shipping label has been generated for your order. Tracking starts as soon as our shipping crew hands the package to the carrier.
+
diff --git a/fusion_plating/fusion_plating_receiving/__init__.py b/fusion_plating/fusion_plating_receiving/__init__.py
index 3c90fa80..cf9f201b 100644
--- a/fusion_plating/fusion_plating_receiving/__init__.py
+++ b/fusion_plating/fusion_plating_receiving/__init__.py
@@ -4,3 +4,4 @@
# Part of the Fusion Plating product family.
from . import models
+from . import wizards
diff --git a/fusion_plating/fusion_plating_receiving/__manifest__.py b/fusion_plating/fusion_plating_receiving/__manifest__.py
index bfd9460e..c32c57e1 100644
--- a/fusion_plating/fusion_plating_receiving/__manifest__.py
+++ b/fusion_plating/fusion_plating_receiving/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
- 'version': '19.0.3.8.0',
+ 'version': '19.0.3.18.0',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """
@@ -29,17 +29,23 @@ Provides:
'price': 0.00,
'currency': 'CAD',
'depends': [
+ 'delivery',
'fusion_plating_configurator',
+ 'fusion_shipping',
'sale_management',
+ 'stock',
],
'data': [
'security/fp_receiving_security.xml',
'security/ir.model.access.csv',
'data/fp_receiving_sequence_data.xml',
+ 'data/delivery_carrier_seed_data.xml',
'views/fp_receiving_views.xml',
'views/fp_racking_inspection_views.xml',
'views/sale_order_views.xml',
'views/fp_receiving_menu.xml',
+ 'views/fusion_shipment_inherit_views.xml',
+ 'wizards/fp_label_manual_wizard_views.xml',
],
'installable': True,
'application': False,
diff --git a/fusion_plating/fusion_plating_receiving/data/delivery_carrier_seed_data.xml b/fusion_plating/fusion_plating_receiving/data/delivery_carrier_seed_data.xml
new file mode 100644
index 00000000..794fbd7b
--- /dev/null
+++ b/fusion_plating/fusion_plating_receiving/data/delivery_carrier_seed_data.xml
@@ -0,0 +1,120 @@
+
+
+
+
+ UPS
+ fixed
+
+ 0
+ 20
+
+
+
+ FedEx
+ fixed
+
+ 0
+ 21
+
+
+
+ USPS
+ fixed
+
+ 0
+ 22
+
+
+
+ DHL
+ fixed
+
+ 0
+ 23
+
+
+
+ Purolator
+ fixed
+
+ 0
+ 24
+
+
+
+ CCT
+ fixed
+
+ 0
+ 25
+
+
+
+ Canpar Express
+ fixed
+
+ 0
+ 26
+
+
+
+ GLS Canada
+ fixed
+
+ 0
+ 27
+
+
+
+ Loomis Express
+ fixed
+
+ 0
+ 28
+
+
+
+ Day & Ross
+ fixed
+
+ 0
+ 29
+
+
+
+ Dicom Transportation
+ fixed
+
+ 0
+ 30
+
+
+
+ Customer Drop-off
+ fixed
+
+ 0
+ 31
+
+
+
+ Local Delivery
+ fixed
+
+ 0
+ 32
+
+
diff --git a/fusion_plating/fusion_plating_receiving/migrations/19.0.3.10.0/post-migrate.py b/fusion_plating/fusion_plating_receiving/migrations/19.0.3.10.0/post-migrate.py
new file mode 100644
index 00000000..3b4cb41d
--- /dev/null
+++ b/fusion_plating/fusion_plating_receiving/migrations/19.0.3.10.0/post-migrate.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+"""Name-match existing fp_receiving.carrier_name → x_fc_carrier_id.
+
+Phase A of the shipping integration replaces the free-text carrier
+field with a Many2one to delivery.carrier. Existing records (16 on
+entech at write time) have free-text values like "FedEx", "Purolator"
+in carrier_name. This migration walks them and populates the new M2O
+when a unique case-insensitive name match exists.
+
+delivery.carrier.name is jsonb (translatable) in Odoo 19 — match
+strips to the en_US translation. Ambiguous values stay as text in
+carrier_name for the operator to pick manually.
+"""
+
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+def migrate(cr, version):
+ # Skip if the field doesn't exist yet (defensive — the column is
+ # added by the registry update that runs before post-migrate).
+ cr.execute("""
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_name = 'fp_receiving'
+ AND column_name = 'x_fc_carrier_id'
+ """)
+ if not cr.fetchone():
+ _logger.warning('x_fc_carrier_id column not present — skip.')
+ return
+
+ cr.execute("""
+ UPDATE fp_receiving r
+ SET x_fc_carrier_id = dc.id
+ FROM delivery_carrier dc
+ WHERE r.carrier_name IS NOT NULL
+ AND r.carrier_name <> ''
+ AND r.x_fc_carrier_id IS NULL
+ AND LOWER(TRIM(r.carrier_name)) =
+ LOWER(TRIM((dc.name->>'en_US')))
+ """)
+ matched = cr.rowcount
+ _logger.info(
+ 'Receiving carrier migration: matched %d record(s) by name.',
+ matched,
+ )
diff --git a/fusion_plating/fusion_plating_receiving/migrations/19.0.3.9.0/post-migrate.py b/fusion_plating/fusion_plating_receiving/migrations/19.0.3.9.0/post-migrate.py
new file mode 100644
index 00000000..8018b90d
--- /dev/null
+++ b/fusion_plating/fusion_plating_receiving/migrations/19.0.3.9.0/post-migrate.py
@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+"""Backfill missing part metadata + received_qty on fp.receiving.line.
+
+A bug in fp.receiving auto-create (now fixed in
+fusion_plating_receiving/models/sale_order.py) read
+``order.x_fc_part_catalog_id`` (the rarely-populated SO header field)
+instead of ``line.x_fc_part_catalog_id`` (the authoritative per-line
+field), leaving every auto-generated receiving line with an empty
+``part_number`` and ``part_catalog_id``. Same auto-create also forgot
+to prefill ``received_qty``.
+
+This migration walks existing receiving records and rebuilds the line
+metadata from the linked SO's order lines via position-based zip — only
+when the receiving line count matches the SO line count (otherwise the
+mapping isn't safe and we leave the record alone for manual review).
+"""
+
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+def migrate(cr, version):
+ # Find candidates: receiving lines with empty part_catalog_id AND
+ # empty part_number, scoped to receivings with a linked SO.
+ cr.execute("""
+ SELECT r.id AS receiving_id,
+ r.sale_order_id AS so_id,
+ array_agg(rl.id ORDER BY rl.id) AS line_ids
+ FROM fp_receiving r
+ JOIN fp_receiving_line rl ON rl.receiving_id = r.id
+ WHERE r.sale_order_id IS NOT NULL
+ AND (rl.part_catalog_id IS NULL
+ AND (rl.part_number IS NULL OR rl.part_number = ''))
+ GROUP BY r.id, r.sale_order_id
+ """)
+ candidates = cr.fetchall()
+ if not candidates:
+ _logger.info('Receiving line backfill: no candidates.')
+ return
+
+ fixed = 0
+ skipped = 0
+ for receiving_id, so_id, recv_line_ids in candidates:
+ # Pull the SO's order lines in stable order.
+ cr.execute("""
+ SELECT id, x_fc_part_catalog_id, product_uom_qty, name
+ FROM sale_order_line
+ WHERE order_id = %s
+ ORDER BY sequence, id
+ """, (so_id,))
+ so_lines = cr.fetchall()
+ if len(so_lines) != len(recv_line_ids):
+ # Mismatch — don't risk corrupting a non-trivial mapping.
+ skipped += 1
+ continue
+ # Receiving lines come ordered by id ascending (the create call
+ # in sale_order.py emits them in order_line order, so id-order
+ # = sequence-order on the SO side).
+ for recv_line_id, (sol_id, part_id, qty, name) in zip(
+ recv_line_ids, so_lines,
+ ):
+ part_number = ''
+ if part_id:
+ cr.execute(
+ "SELECT part_number FROM fp_part_catalog WHERE id = %s",
+ (part_id,),
+ )
+ row = cr.fetchone()
+ part_number = (row and row[0]) or ''
+ cr.execute("""
+ UPDATE fp_receiving_line
+ SET part_catalog_id = %s,
+ part_number = %s,
+ received_qty = COALESCE(NULLIF(received_qty, 0),
+ %s)
+ WHERE id = %s
+ """, (
+ part_id or None,
+ part_number,
+ int(qty or 0),
+ recv_line_id,
+ ))
+ fixed += 1
+ _logger.info(
+ 'Receiving line backfill: fixed %d lines, skipped %d receivings '
+ '(line-count mismatch).', fixed, skipped,
+ )
diff --git a/fusion_plating/fusion_plating_receiving/models/__init__.py b/fusion_plating/fusion_plating_receiving/models/__init__.py
index 30ac79b8..ee87ba4e 100644
--- a/fusion_plating/fusion_plating_receiving/models/__init__.py
+++ b/fusion_plating/fusion_plating_receiving/models/__init__.py
@@ -5,7 +5,8 @@
from . import fp_receiving_damage
from . import fp_receiving_line
+from . import fp_outbound_package
from . import fp_receiving
from . import fp_racking_inspection
-from . import fp_receiving_racking_link
from . import sale_order
+from . import fusion_shipment
diff --git a/fusion_plating/fusion_plating_receiving/models/fp_outbound_package.py b/fusion_plating/fusion_plating_receiving/models/fp_outbound_package.py
new file mode 100644
index 00000000..7e4b0c71
--- /dev/null
+++ b/fusion_plating/fusion_plating_receiving/models/fp_outbound_package.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+"""Per-package row for outbound multi-piece shipments.
+
+Each fp.receiving has zero-or-more fp.outbound.package rows. When the
+operator clicks Generate Outbound Label, one stock.package + one
+carrier label is generated per row.
+
+Single-box scenario: the form auto-fills one row when the receiving's
+top-level weight/dim are set, so existing UX still works.
+Multi-box scenario: operator adds more rows. Each row gets its own
+tracking number + label PDF/ZPL stored back on the row after the API
+call returns.
+"""
+from odoo import fields, models
+
+
+class FpOutboundPackage(models.Model):
+ _name = 'fp.outbound.package'
+ _description = 'Fusion Plating — Outbound Package (per-box detail)'
+ _order = 'sequence, id'
+
+ receiving_id = fields.Many2one(
+ 'fp.receiving', required=True, ondelete='cascade', index=True,
+ )
+ sequence = fields.Integer(default=10)
+ weight = fields.Float(string='Weight', digits=(10, 3))
+ length = fields.Float(string='Length', digits=(10, 2))
+ width = fields.Float(string='Width', digits=(10, 2))
+ height = fields.Float(string='Height', digits=(10, 2))
+ # Populated by the carrier API once Generate Label fires.
+ tracking_number = fields.Char(readonly=True, copy=False)
+ label_attachment_id = fields.Many2one(
+ 'ir.attachment',
+ string='Label',
+ ondelete='set null',
+ readonly=True,
+ copy=False,
+ )
+ # Computed convenience: filename of the label (for download UX).
+ label_filename = fields.Char(
+ related='label_attachment_id.name', readonly=True,
+ )
diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py
index 70a63dfe..b9a143d6 100644
--- a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py
+++ b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py
@@ -3,9 +3,15 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
+import logging
+
+from markupsafe import Markup
+
from odoo import api, fields, models, _
from odoo.exceptions import UserError
+_logger = logging.getLogger(__name__)
+
class FpReceiving(models.Model):
"""Parts receiving record.
@@ -70,8 +76,620 @@ class FpReceiving(models.Model):
qty_match = fields.Boolean(
string='Qty Match', compute='_compute_qty_match', store=True,
)
- carrier_name = fields.Char(string='Carrier', help='Who delivered the parts (Purolator, customer drop-off, etc.).')
+ carrier_name = fields.Char(
+ string='Carrier (Legacy)',
+ help='Legacy free-text carrier field. Kept for back-compat with '
+ 'records that predate the carrier_id M2O. New records use '
+ 'x_fc_carrier_id instead.',
+ )
carrier_tracking = fields.Char(string='Inbound Tracking #')
+
+ # ---- Phase A — outbound carrier + shipment link ----------------------
+ # The receiver picks the OUTBOUND (return) carrier here; clicking
+ # "Create Outbound Shipment" creates a draft fusion.shipment which
+ # owns weight, dimensions, label PDF, tracking. The shop's workflow
+ # generates the return label at receiving time so the printed label
+ # can travel with the parts.
+ x_fc_carrier_id = fields.Many2one(
+ 'delivery.carrier', string='Outbound Carrier', tracking=True,
+ ondelete='set null',
+ help='Who picks up the parts when work is done. Used to generate '
+ 'the return shipping label on the linked Outbound Shipment.',
+ )
+ x_fc_outbound_shipment_id = fields.Many2one(
+ 'fusion.shipment', string='Outbound Shipment', tracking=True,
+ ondelete='set null',
+ copy=False,
+ help='The shipment record carrying weight, dimensions, label PDF, '
+ 'and tracking. Created via the "Create Outbound Shipment" '
+ 'button on this form.',
+ )
+ x_fc_outbound_shipment_count = fields.Integer(
+ compute='_compute_x_fc_outbound_shipment_count',
+ )
+ x_fc_has_label = fields.Boolean(
+ compute='_compute_x_fc_has_label',
+ help='True when the linked outbound shipment has a label PDF '
+ 'attached. Drives the Print Label smart-button visibility.',
+ )
+
+ @api.depends('x_fc_outbound_shipment_id.label_attachment_id')
+ def _compute_x_fc_has_label(self):
+ for rec in self:
+ rec.x_fc_has_label = bool(
+ rec.x_fc_outbound_shipment_id
+ and rec.x_fc_outbound_shipment_id.label_attachment_id
+ )
+
+ # ---- Phase C — Outbound packaging fields -----------------------------
+ # Operator enters these at receiving time so the shipping label can be
+ # generated immediately. Pushed to the linked fusion.shipment when
+ # action_generate_outbound_label fires.
+ x_fc_weight = fields.Float(
+ string='Weight', digits=(10, 3), tracking=True,
+ help='Total package weight for outbound shipping. Used at label '
+ 'generation time.',
+ )
+ x_fc_weight_uom = fields.Selection(
+ [('lb', 'lb'), ('kg', 'kg')],
+ string='Weight UoM', default='lb', tracking=True,
+ )
+ x_fc_length = fields.Float(
+ string='Length', digits=(10, 2), tracking=True,
+ )
+ x_fc_width = fields.Float(
+ string='Width', digits=(10, 2), tracking=True,
+ )
+ x_fc_height = fields.Float(
+ string='Height', digits=(10, 2), tracking=True,
+ )
+ x_fc_dim_uom = fields.Selection(
+ [('in', 'in'), ('cm', 'cm')],
+ string='Dim UoM', default='in', tracking=True,
+ )
+
+ # Back-link to the synthetic stock.picking used at API-call time.
+ # Set by _fp_build_shipping_picking; kept for debugging / traceability.
+ x_fc_shipping_picking_id = fields.Many2one(
+ 'stock.picking', string='Shipping Picking',
+ readonly=True, copy=False,
+ help='The internal picking record used to drive the carrier API '
+ 'call. Hidden from operator UIs; kept for traceability.',
+ )
+
+ # Per-package detail for multi-piece shipments (MPS). Each row
+ # produces one stock.package + one carrier label. Single-box flow
+ # still works: when no rows are entered, _fp_build_shipping_picking
+ # falls back to the receiving's top-level weight/dim fields.
+ x_fc_outbound_package_ids = fields.One2many(
+ 'fp.outbound.package', 'receiving_id',
+ string='Outbound Packages',
+ )
+
+ @api.depends('x_fc_outbound_shipment_id')
+ def _compute_x_fc_outbound_shipment_count(self):
+ for rec in self:
+ rec.x_fc_outbound_shipment_count = (
+ 1 if rec.x_fc_outbound_shipment_id else 0
+ )
+
+ @api.onchange('x_fc_carrier_id')
+ def _onchange_x_fc_carrier_id(self):
+ """Propagate carrier change to a linked DRAFT shipment.
+
+ Once a shipment is confirmed / shipped / delivered, we leave it
+ alone — changing the carrier on a non-draft shipment is a
+ destructive operation that needs explicit user intent (cancel +
+ re-create), not a side-effect of editing the receiving form.
+ """
+ for rec in self:
+ ship = rec.x_fc_outbound_shipment_id
+ if ship and ship.status == 'draft' and rec.x_fc_carrier_id:
+ ship.carrier_id = rec.x_fc_carrier_id.id
+
+ # ---- Actions ----------------------------------------------------------
+ def action_create_outbound_shipment(self):
+ """Create a draft fusion.shipment linked to this receiving.
+
+ Idempotent: if a shipment is already linked, just open it.
+ Pre-fills carrier_type, sender + recipient name/address, and
+ service_type from the carrier's defaults so the operator never
+ sees an empty form.
+ """
+ self.ensure_one()
+ if self.x_fc_outbound_shipment_id:
+ return self.action_view_outbound_shipment()
+ if 'fusion.shipment' not in self.env:
+ raise UserError(_(
+ 'fusion_shipping module is not installed. '
+ 'Cannot create an outbound shipment.'
+ ))
+ vals = {
+ 'sale_order_id': self.sale_order_id.id if self.sale_order_id else False,
+ 'carrier_id': self.x_fc_carrier_id.id if self.x_fc_carrier_id else False,
+ 'status': 'draft',
+ }
+ vals.update(self._fp_resolve_shipment_defaults())
+ shipment = self.env['fusion.shipment'].sudo().create(vals)
+ self.x_fc_outbound_shipment_id = shipment.id
+ self.message_post(body=Markup(_(
+ 'Outbound shipment %s created (draft).'
+ )) % shipment.name)
+ return self.action_view_outbound_shipment()
+
+ def _fp_resolve_shipment_defaults(self):
+ """Build the dict of fusion.shipment field values that can be
+ derived from the receiving's context (carrier, SO, company).
+ Used at creation time and re-used by the generate-label flow
+ to refresh fields if the operator changes carrier mid-flow.
+ """
+ self.ensure_one()
+ vals = {}
+ carrier = self.x_fc_carrier_id
+ # carrier_type — Selection on fusion.shipment ('canada_post',
+ # 'ups_rest', 'fedex_rest', etc.). Map from delivery_type by
+ # stripping the 'fusion_' prefix (e.g. 'fusion_fedex_rest' →
+ # 'fedex_rest'). Selection on the model may not include every
+ # value our delivery_type uses; defensive against missing keys.
+ if carrier and carrier.delivery_type:
+ dt = carrier.delivery_type
+ ct = dt[len('fusion_'):] if dt.startswith('fusion_') else dt
+ Ship = self.env.get('fusion.shipment')
+ if Ship is not None:
+ valid_types = dict(
+ Ship._fields['carrier_type'].selection
+ )
+ if ct in valid_types:
+ vals['carrier_type'] = ct
+ # service_type — carrier-specific. FedEx REST stores it on
+ # carrier.fedex_rest_service_type; UPS REST has its own field.
+ # Read whichever attribute exists.
+ if carrier:
+ for attr in ('fedex_rest_service_type', 'ups_rest_service_type',
+ 'dhl_rest_service_type'):
+ if attr in carrier._fields and carrier[attr]:
+ vals['service_type'] = carrier[attr]
+ break
+ # Sender from company partner; recipient from SO shipping address.
+ company_partner = self.env.company.partner_id
+ vals['sender_name'] = company_partner.name or ''
+ vals['sender_address'] = self._fp_format_address(company_partner)
+ so = self.sale_order_id
+ if so:
+ recipient = so.partner_shipping_id or so.partner_id
+ vals['recipient_name'] = recipient.name or ''
+ vals['recipient_address'] = self._fp_format_address(recipient)
+ return vals
+
+ def _fp_format_address(self, partner):
+ """Single-line address string for the shipment record.
+ fusion.shipment.sender_address / recipient_address are plain
+ Char; we just need a readable rendering."""
+ if not partner:
+ return ''
+ parts = [partner.street, partner.street2, partner.city,
+ partner.state_id.code if partner.state_id else False,
+ partner.zip,
+ partner.country_id.name if partner.country_id else False]
+ return ', '.join(p for p in parts if p)
+
+ def action_view_outbound_shipment(self):
+ self.ensure_one()
+ if not self.x_fc_outbound_shipment_id:
+ return False
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_outbound_shipment_id.name,
+ 'res_model': 'fusion.shipment',
+ 'res_id': self.x_fc_outbound_shipment_id.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
+
+ # ---- Phase C — Generate Outbound Label -------------------------------
+ def action_generate_outbound_label(self):
+ """One-button label generation.
+
+ Branches on carrier.delivery_type:
+ - 'fixed' (no API integration): opens manual entry wizard.
+ - 'fusion_*' (API integration): synthesizes a stock.picking,
+ calls the existing carrier._send_shipping method,
+ copies the result back to the linked fusion.shipment.
+ - On API exception: falls back to the manual wizard with the
+ error message in the note field.
+ """
+ self.ensure_one()
+ self._fp_validate_label_inputs()
+ carrier = self.x_fc_carrier_id
+ if carrier.delivery_type == 'fixed':
+ return self._fp_open_manual_label_wizard(note=_(
+ 'Carrier "%s" has no API integration configured. Enter '
+ 'the label PDF and tracking number below to record the '
+ 'shipment manually.'
+ ) % carrier.name)
+ # Ensure the shipment exists before we attempt the API call.
+ if not self.x_fc_outbound_shipment_id:
+ self.action_create_outbound_shipment()
+ # Push the packaging info onto the shipment so it's the source
+ # of truth post-generation.
+ self._fp_sync_packaging_to_shipment()
+ try:
+ picking = self._fp_build_shipping_picking()
+ shipping_data = carrier.send_shipping(picking)
+ self._fp_apply_shipping_result(picking, shipping_data)
+ except UserError:
+ raise
+ except Exception as e:
+ _logger.warning(
+ 'Receiving %s: outbound label API call failed: %s',
+ self.name, e,
+ )
+ return self._fp_open_manual_label_wizard(note=_(
+ 'Carrier API call failed:\n %s\n\nEnter the label '
+ 'PDF and tracking number below to record the shipment '
+ 'manually.'
+ ) % str(e))
+ return self.action_view_outbound_shipment()
+
+ def _fp_validate_label_inputs(self):
+ """Gate: required inputs before label generation."""
+ self.ensure_one()
+ if not self.x_fc_carrier_id:
+ raise UserError(_(
+ 'Pick an Outbound Carrier before generating a label.'
+ ))
+ if not self.x_fc_weight or self.x_fc_weight <= 0:
+ raise UserError(_(
+ 'Enter the Weight before generating a label.'
+ ))
+ if not self.sale_order_id:
+ raise UserError(_(
+ 'Receiving "%s" is not linked to a sale order — '
+ 'cannot generate a shipping label.'
+ ) % self.name)
+ if not self.sale_order_id.partner_shipping_id \
+ and not self.sale_order_id.partner_id:
+ raise UserError(_(
+ 'Sale order has no shipping address. Set one on '
+ '%s before generating a label.'
+ ) % self.sale_order_id.name)
+
+ def _fp_open_manual_label_wizard(self, note=''):
+ """Open the small manual-entry wizard for label PDF + tracking."""
+ self.ensure_one()
+ # Ensure the shipment exists so the wizard has a target to write to.
+ if not self.x_fc_outbound_shipment_id:
+ self.action_create_outbound_shipment()
+ Wizard = self.env.get('fp.label.manual.wizard')
+ if Wizard is None:
+ raise UserError(_(
+ 'Manual label wizard is not installed. Upgrade '
+ 'fusion_plating_receiving.'
+ ))
+ wiz = Wizard.create({
+ 'receiving_id': self.id,
+ 'note': note or '',
+ })
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Enter Label Manually — %s') % self.name,
+ 'res_model': Wizard._name,
+ 'res_id': wiz.id,
+ 'view_mode': 'form',
+ 'target': 'new',
+ }
+
+ def _fp_sync_packaging_to_shipment(self):
+ """Copy weight + dimensions from the receiving to the linked
+ fusion.shipment so the shipment record carries the values used
+ for label generation."""
+ self.ensure_one()
+ ship = self.x_fc_outbound_shipment_id
+ if not ship:
+ return
+ vals = {}
+ if self.x_fc_weight:
+ vals['weight'] = self.x_fc_weight
+ if 'x_fc_length' in ship._fields:
+ if self.x_fc_length:
+ vals['x_fc_length'] = self.x_fc_length
+ if self.x_fc_width:
+ vals['x_fc_width'] = self.x_fc_width
+ if self.x_fc_height:
+ vals['x_fc_height'] = self.x_fc_height
+ if self.x_fc_dim_uom:
+ vals['x_fc_dim_uom'] = self.x_fc_dim_uom
+ if self.x_fc_weight_uom:
+ vals['x_fc_weight_uom'] = self.x_fc_weight_uom
+ if vals:
+ ship.sudo().write(vals)
+
+ def _fp_build_shipping_picking(self):
+ """Synthesize a stock.picking just to carry the data needed by
+ carrier.send_shipping. The picking is auto-validated to 'done'
+ state so it doesn't sit as draft in operator views.
+ """
+ self.ensure_one()
+ Picking = self.env['stock.picking'].sudo()
+ warehouse = self.env['stock.warehouse'].sudo().search(
+ [('company_id', '=', self.env.company.id)], limit=1,
+ )
+ if not warehouse:
+ raise UserError(_(
+ 'No warehouse configured for the company. Configure '
+ 'one in Settings > Warehouses before generating labels.'
+ ))
+ picking_type = warehouse.out_type_id
+ if not picking_type:
+ raise UserError(_(
+ 'Warehouse "%s" has no outgoing picking type.'
+ ) % warehouse.name)
+ so = self.sale_order_id
+ partner = so.partner_shipping_id or so.partner_id
+ # Use the first SO line's product as the synthetic move's product
+ # (carrier APIs read product info for dimensions / customs forms).
+ product = (so.order_line and so.order_line[0].product_id) or self.env.ref(
+ 'product.product_product_4', raise_if_not_found=False,
+ )
+ if not product:
+ raise UserError(_(
+ 'No product available to synthesize the shipping picking.'
+ ))
+ picking = Picking.create({
+ 'partner_id': partner.id,
+ 'picking_type_id': picking_type.id,
+ 'origin': so.name,
+ 'sale_id': so.id,
+ 'carrier_id': self.x_fc_carrier_id.id,
+ 'move_ids': [(0, 0, {
+ # Odoo 19 dropped stock.move.name; description_picking
+ # replaces it (see CLAUDE.md "stock.move.name removed").
+ 'description_picking': 'Outbound %s' % (self.name or ''),
+ 'product_id': product.id,
+ 'product_uom_qty': 1,
+ 'product_uom': product.uom_id.id,
+ 'location_id': picking_type.default_location_src_id.id,
+ 'location_dest_id': picking_type.default_location_dest_id.id,
+ })],
+ })
+ # Force the picking's weight so the API helper reads our value
+ # instead of the computed (zero) weight from the synthetic move.
+ if 'weight' in picking._fields:
+ picking.write({'weight': self.x_fc_weight})
+ # Confirm + assign so move_lines exist; we then pre-pack them
+ # into one stock.package carrying the operator-entered weight +
+ # the carrier's default package type. Without an explicit
+ # package, _get_packages_from_picking falls back to weight_bulk
+ # which reads from product.weight (always 0 for our synthetic
+ # move) → FedEx rejects with "weight 0.0 lb". Setting
+ # package_type_id makes DeliveryPackage.packaging_type resolve
+ # to the carrier-specific shipper_package_code (e.g.
+ # 'YOUR_PACKAGING' for FedEx).
+ picking.action_confirm()
+ try:
+ picking.action_assign()
+ except Exception:
+ pass
+ Package = self.env.get('stock.package')
+ if Package is not None and picking.move_line_ids:
+ default_pkg_type = self._fp_resolve_carrier_default_package_type()
+ # Build the list of (weight, dimensions) tuples — one per
+ # outbound package. Multi-piece shipments use the per-row
+ # data from x_fc_outbound_package_ids; single-piece falls
+ # back to the receiving's top-level weight/dim fields.
+ rows = self.x_fc_outbound_package_ids.filtered(
+ lambda r: (r.weight or 0) > 0
+ )
+ if not rows:
+ # Synthesize one virtual row from the top-level fields.
+ rows = [type('Row', (), {
+ 'weight': self.x_fc_weight,
+ 'length': self.x_fc_length,
+ 'width': self.x_fc_width,
+ 'height': self.x_fc_height,
+ 'id': False,
+ })()]
+ ml = picking.move_line_ids[0]
+ packages = Package
+ for row in rows:
+ pkg_vals = {'shipping_weight': row.weight or 0}
+ if default_pkg_type:
+ pkg_vals['package_type_id'] = default_pkg_type.id
+ pkg = Package.sudo().create(pkg_vals)
+ packages |= pkg
+ # Spread move_line qty across packages via result_package_id.
+ # Stock's pack flow allows multiple move lines, but our move
+ # has a single line with qty=1. For multi-box, we split the
+ # move_line by creating extra lines (one per package).
+ if len(packages) == 1:
+ ml.result_package_id = packages[0].id
+ else:
+ # First package keeps the existing move_line.
+ ml.result_package_id = packages[0].id
+ Move = picking.move_ids[0] if picking.move_ids else False
+ if Move:
+ MoveLine = self.env['stock.move.line'].sudo()
+ for pkg in packages[1:]:
+ MoveLine.create({
+ 'move_id': Move.id,
+ 'picking_id': picking.id,
+ 'product_id': Move.product_id.id,
+ 'product_uom_id': Move.product_uom.id,
+ 'quantity': 1,
+ 'location_id': Move.location_id.id,
+ 'location_dest_id': Move.location_dest_id.id,
+ 'result_package_id': pkg.id,
+ })
+ # Stash packages on the picking via a transient attr so
+ # _fp_apply_shipping_result can walk them in the same order
+ # the API processes them (FedEx returns labels in the
+ # order packages were submitted).
+ picking._fp_outbound_packages = packages
+ self.x_fc_shipping_picking_id = picking.id
+ return picking
+
+ def _fp_resolve_carrier_default_package_type(self):
+ """Return the stock.package.type to use for the synthetic
+ outbound package. Reads the carrier's per-provider default
+ (e.g. fedex_rest_default_package_type_id). Returns False when
+ no default is configured — the API call will then fail with a
+ clear PACKAGINGTYPE error pointing the admin at the setup.
+ """
+ self.ensure_one()
+ carrier = self.x_fc_carrier_id
+ if not carrier:
+ return False
+ # Field name pattern is _default_package_type_id
+ # for the FedEx REST / UPS REST / etc. integrations.
+ field_name = '%s_default_package_type_id' % (
+ carrier.delivery_type or ''
+ )
+ # Strip the 'fusion_' prefix used by fusion_shipping.
+ if field_name.startswith('fusion_'):
+ field_name = field_name[len('fusion_'):]
+ if field_name in carrier._fields:
+ return carrier[field_name]
+ return False
+
+ def _fp_apply_shipping_result(self, picking, shipping_data):
+ """Copy tracking + label(s) from the picking back to the linked
+ fusion.shipment AND to the per-package rows for multi-piece
+ shipments. shipping_data is the list returned by
+ carrier.send_shipping — `[{exact_price, tracking_number}, ...]`,
+ one dict per package, in submission order.
+
+ Multi-piece (MPS): walks shipping_data alongside the picking's
+ packages and writes per-package tracking + label_attachment back
+ onto the matching fp.outbound.package row. The shipment-level
+ tracking_number stores the first package's tracking (so the
+ chatter / portal / notification still has a single primary ref).
+ """
+ self.ensure_one()
+ ship = self.x_fc_outbound_shipment_id
+ if not ship:
+ return
+ # All label attachments uploaded to the picking by the upstream
+ # send_shipping. PDF for PDF mode, application/zpl-ish for ZPLII.
+ # We accept any attachment created on this picking by the API
+ # call (the upstream code uses message_post which creates them).
+ label_atts = self.env['ir.attachment'].sudo().search([
+ ('res_model', '=', 'stock.picking'),
+ ('res_id', '=', picking.id),
+ ], order='id asc')
+ # Per-package shipping_data list — one entry per package.
+ sd_list = shipping_data if isinstance(shipping_data, list) else [
+ shipping_data
+ ]
+ # Pair rows with their results. If user didn't enter per-row
+ # data, fall back to a single virtual row scenario (no rows to
+ # write back to).
+ rows = self.x_fc_outbound_package_ids.filtered(
+ lambda r: (r.weight or 0) > 0
+ )
+ # Walk both lists in parallel; carrier returns one tracking +
+ # label per package in submission order. Some carriers return
+ # one combined tracking_ref split by '+' — handle both.
+ primary_tracking = ''
+ per_pkg_trackings = []
+ for sd in sd_list:
+ tn = sd.get('tracking_number') or ''
+ for part in tn.split('+'):
+ if part:
+ per_pkg_trackings.append(part)
+ if not per_pkg_trackings and 'carrier_tracking_ref' in picking._fields:
+ for part in (picking.carrier_tracking_ref or '').split('+'):
+ if part:
+ per_pkg_trackings.append(part)
+ primary_tracking = per_pkg_trackings[0] if per_pkg_trackings else ''
+ # Write per-row labels + tracking. Attachments are paired by
+ # index — N labels and N rows. Excess on either side is ignored.
+ for idx, row in enumerate(rows):
+ row_vals = {}
+ if idx < len(per_pkg_trackings):
+ row_vals['tracking_number'] = per_pkg_trackings[idx]
+ if idx < len(label_atts):
+ row_vals['label_attachment_id'] = label_atts[idx].id
+ if row_vals:
+ row.sudo().write(row_vals)
+ # Shipment-level fields. Primary label = first attachment; mirror
+ # all labels onto x_fc_label_attachment_ids for the multi-print UX.
+ vals = {'status': 'confirmed'}
+ if primary_tracking:
+ vals['tracking_number'] = primary_tracking
+ if label_atts:
+ vals['label_attachment_id'] = label_atts[0].id
+ if 'x_fc_label_attachment_ids' in ship._fields:
+ vals['x_fc_label_attachment_ids'] = [(6, 0, label_atts.ids)]
+ # Link the synthetic stock.picking so the Transfer field shows
+ # it on the shipment form. Also refresh sender/recipient/carrier
+ # defaults in case the operator changed carrier between create
+ # and generate.
+ if 'picking_id' in ship._fields:
+ vals['picking_id'] = picking.id
+ for k, v in self._fp_resolve_shipment_defaults().items():
+ # Only fill if blank; never overwrite an operator edit.
+ if not ship[k]:
+ vals[k] = v
+ ship.sudo().write(vals)
+ self.message_post(body=Markup(_(
+ 'Outbound label generated. Tracking: %s'
+ )) % (tracking_number or '(see attached PDF)'))
+ # Validate the synthetic picking so it lands in 'done' state
+ # instead of sitting at 'ready'. The shipping label is the proof
+ # of dispatch — keeping the picking open misleads anyone looking
+ # at the warehouse view. Wrapped in try/except so any quirk in
+ # the validation flow (e.g. zero on-hand stock) doesn't block
+ # the label generation success path.
+ if picking and picking.state not in ('done', 'cancel'):
+ try:
+ # skip_sms = bypass the SMS-on-delivery confirm wizard
+ # (stock_sms intercepts button_validate otherwise).
+ # skip_backorder = no backorder dialog when qty doesn't
+ # reconcile (won't on a synthetic picking with no stock).
+ # skip_immediate = bypass the immediate-transfer prompt.
+ result = picking.with_context(
+ skip_immediate=True,
+ skip_backorder=True,
+ skip_sms=True,
+ ).button_validate()
+ # If button_validate still returned an action (a wizard
+ # popped up despite the context flags), log and move on
+ # — the label is already saved; manual validation later
+ # is fine.
+ if isinstance(result, dict) and result.get('res_model'):
+ _logger.info(
+ 'Receiving %s: button_validate returned a wizard '
+ '(%s); leaving picking %s in state %s.',
+ self.name,
+ result.get('res_model'),
+ picking.name,
+ picking.state,
+ )
+ except Exception as e:
+ _logger.warning(
+ 'Receiving %s: failed to auto-validate picking %s: %s',
+ self.name, picking.name, e,
+ )
+
+ def action_print_label(self):
+ """Open the label PDF for printing.
+
+ Returns the standard Odoo download action so the operator can
+ print from their browser. Phase F replaces this with auto-print
+ to a network printer.
+ """
+ self.ensure_one()
+ ship = self.x_fc_outbound_shipment_id
+ if not ship or not ship.label_attachment_id:
+ raise UserError(_(
+ 'No outbound shipping label on this receiving. '
+ 'Generate the label first.'
+ ))
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': '/web/content/%d?download=true' % ship.label_attachment_id.id,
+ 'target': 'new',
+ }
notes = fields.Html(string='Notes')
line_ids = fields.One2many('fp.receiving.line', 'receiving_id', string='Receiving Lines')
diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving_racking_link.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving_racking_link.py
deleted file mode 100644
index 1afa1487..00000000
--- a/fusion_plating/fusion_plating_receiving/models/fp_receiving_racking_link.py
+++ /dev/null
@@ -1,51 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2026 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-#
-# Sub 12 audit fix — discoverable handoff from fp.receiving (boxes
-# counted) to fp.racking.inspection (parts inspected by the racking
-# crew). The racking inspection is auto-created on fp.job.action_confirm
-# but until now there was no smart-button on the receiving form to find
-# it — racking crew had to navigate via a separate menu.
-
-from odoo import _, fields, models
-
-
-class FpReceivingRackingLink(models.Model):
- _inherit = 'fp.receiving'
-
- racking_inspection_count = fields.Integer(
- string='Racking Inspections', compute='_compute_racking_inspection_count',
- )
-
- def _compute_racking_inspection_count(self):
- Inspection = self.env['fp.racking.inspection'] \
- if 'fp.racking.inspection' in self.env else None
- for rec in self:
- if Inspection is None or not rec.sale_order_id:
- rec.racking_inspection_count = 0
- continue
- rec.racking_inspection_count = Inspection.search_count([
- ('sale_order_id', '=', rec.sale_order_id.id),
- ])
-
- def action_view_racking_inspections(self):
- """Open the racking inspection(s) for this receiving's SO. If
- none exists yet, default-create context lets the user spawn one
- with the SO context pre-filled.
- """
- self.ensure_one()
- Inspection = self.env['fp.racking.inspection']
- domain = [('sale_order_id', '=', self.sale_order_id.id)] \
- if self.sale_order_id else []
- return {
- 'type': 'ir.actions.act_window',
- 'name': _('Racking Inspections'),
- 'res_model': 'fp.racking.inspection',
- 'view_mode': 'list,form',
- 'domain': domain,
- 'context': {
- 'default_sale_order_id': self.sale_order_id.id
- if self.sale_order_id else False,
- },
- }
diff --git a/fusion_plating/fusion_plating_receiving/models/fusion_shipment.py b/fusion_plating/fusion_plating_receiving/models/fusion_shipment.py
new file mode 100644
index 00000000..9128e7a8
--- /dev/null
+++ b/fusion_plating/fusion_plating_receiving/models/fusion_shipment.py
@@ -0,0 +1,136 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+"""Phase C — extend fusion.shipment with dimension fields.
+
+fusion_shipping's native model has `weight` but no length/width/height.
+The plating workflow needs all four captured at receiving time so the
+shipment record carries everything the carrier API would want. Added
+here (not in fusion_shipping) to keep the upstream module untouched.
+"""
+import logging
+
+from odoo import api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class FusionShipment(models.Model):
+ _inherit = 'fusion.shipment'
+
+ x_fc_length = fields.Float(string='Length', digits=(10, 2))
+ x_fc_width = fields.Float(string='Width', digits=(10, 2))
+ x_fc_height = fields.Float(string='Height', digits=(10, 2))
+ x_fc_dim_uom = fields.Selection(
+ [('in', 'in'), ('cm', 'cm')],
+ string='Dim UoM', default='in',
+ )
+ x_fc_weight_uom = fields.Selection(
+ [('lb', 'lb'), ('kg', 'kg')],
+ string='Weight UoM', default='lb',
+ )
+
+ # Multi-piece label storage. label_attachment_id remains the
+ # primary (first box) for backward-compat; this M2M holds the full
+ # set so the operator can download any box's label individually.
+ x_fc_label_attachment_ids = fields.Many2many(
+ 'ir.attachment',
+ 'fusion_shipment_label_attachment_rel',
+ 'shipment_id', 'attachment_id',
+ string='All Labels',
+ copy=False,
+ )
+
+ # Phase C — resolved carrier tracking URL with the tracking number
+ # substituted into the carrier.tracking_url template. Used by the
+ # shipment_labeled email template and any other place that needs a
+ # working clickable tracking link. Single source of truth so both
+ # email + portal stay consistent.
+ x_fc_tracking_url = fields.Char(
+ string='Tracking URL (resolved)',
+ compute='_compute_x_fc_tracking_url',
+ help='carrier.tracking_url with replaced '
+ 'by tracking_number. Empty when the carrier has no URL '
+ 'template or there is no tracking number yet.',
+ )
+
+ @api.depends('carrier_id.tracking_url', 'tracking_number')
+ def _compute_x_fc_tracking_url(self):
+ for rec in self:
+ tpl = (rec.carrier_id.tracking_url or '') if rec.carrier_id else ''
+ tn = rec.tracking_number or ''
+ if not tpl or not tn:
+ rec.x_fc_tracking_url = ''
+ continue
+ placeholder = ''
+ if placeholder in tpl:
+ rec.x_fc_tracking_url = tpl.replace(placeholder, tn)
+ else:
+ rec.x_fc_tracking_url = tpl + tn
+
+ def write(self, vals):
+ """Sync the carrier tracking number + label to the customer
+ portal job whenever they land on the shipment. The portal_job
+ currently shows `delivery.name` as 'tracking' — wrong; the
+ customer wants the carrier's actual tracking number so the
+ clickable link goes to FedEx/UPS/etc."""
+ res = super().write(vals)
+ sync_keys = {'tracking_number', 'label_attachment_id', 'status'}
+ if not sync_keys & set(vals.keys()):
+ return res
+ for ship in self:
+ try:
+ ship._fp_sync_to_portal_job()
+ except Exception as e:
+ _logger.warning(
+ 'Shipment %s: portal-job sync failed: %s',
+ ship.name, e,
+ )
+ return res
+
+ def _fp_sync_to_portal_job(self):
+ """Walk shipment → SO → fp.job → fusion.plating.portal.job
+ and push the carrier tracking number + label + delivery's
+ packing slip onto the customer-facing record.
+ """
+ self.ensure_one()
+ if not self.sale_order_id:
+ return
+ Job = self.env.get('fp.job')
+ if Job is None:
+ return
+ jobs = Job.sudo().search(
+ [('sale_order_id', '=', self.sale_order_id.id)],
+ )
+ if not jobs:
+ return
+ for job in jobs:
+ portal = job.portal_job_id
+ if not portal:
+ continue
+ vals = {}
+ if self.tracking_number and portal.tracking_ref != self.tracking_number:
+ vals['tracking_ref'] = self.tracking_number
+ # Packing slip lives on the linked fp.delivery, not the
+ # shipment. Walk it lazily here so a packing-slip generated
+ # earlier on the delivery also lands on the portal job.
+ delivery = job.delivery_id
+ if (delivery
+ and 'packing_list_attachment_id' in delivery._fields
+ and delivery.packing_list_attachment_id
+ and portal.packing_list_attachment_id !=
+ delivery.packing_list_attachment_id):
+ vals['packing_list_attachment_id'] = (
+ delivery.packing_list_attachment_id.id
+ )
+ # Once a tracking number exists, the parts have been picked
+ # by the carrier (or are about to be) — advance the portal
+ # state to 'shipped' so the customer sees their order is
+ # on its way. The 'delivered' status flips when FedEx
+ # tracking reports the delivery.
+ if self.tracking_number and portal.state in (
+ 'received', 'in_progress', 'ready_to_ship',
+ ):
+ vals['state'] = 'shipped'
+ if vals:
+ portal.sudo().write(vals)
diff --git a/fusion_plating/fusion_plating_receiving/models/sale_order.py b/fusion_plating/fusion_plating_receiving/models/sale_order.py
index e9267a1b..a9fd2bd0 100644
--- a/fusion_plating/fusion_plating_receiving/models/sale_order.py
+++ b/fusion_plating/fusion_plating_receiving/models/sale_order.py
@@ -22,25 +22,41 @@ class SaleOrder(models.Model):
rec.x_fc_receiving_count = len(rec.x_fc_receiving_ids)
def action_confirm(self):
- """Override to auto-create receiving record on SO confirmation."""
+ """Override to auto-create receiving record on SO confirmation.
+
+ Per-line metadata (part catalog, part number) is sourced from
+ ``sale.order.line.x_fc_part_catalog_id`` — NOT from the SO header.
+ The header field exists too but is rarely populated; the line
+ carries the authoritative part link in the configurator flow.
+
+ Each receiving line prefills ``received_qty`` to ``expected_qty``
+ so the racking crew only types when the count is off (mirrors
+ the header behaviour in fp_receiving.py:create).
+ """
res = super().action_confirm()
for order in self:
- # Only create if no receiving record exists yet
- if not order.x_fc_receiving_ids:
- total_qty = sum(order.order_line.mapped('product_uom_qty'))
- receiving_vals = {
- 'sale_order_id': order.id,
- 'expected_qty': int(total_qty),
- 'line_ids': [],
- }
- # Auto-create lines from SO lines
- for line in order.order_line:
- receiving_vals['line_ids'].append((0, 0, {
- 'part_number': order.x_fc_part_catalog_id.part_number if order.x_fc_part_catalog_id else '',
- 'description': line.name or '',
- 'expected_qty': int(line.product_uom_qty),
- }))
- self.env['fp.receiving'].create(receiving_vals)
+ if order.x_fc_receiving_ids:
+ continue
+ total_qty = sum(order.order_line.mapped('product_uom_qty'))
+ line_vals = []
+ for line in order.order_line:
+ part = (
+ line.x_fc_part_catalog_id
+ if 'x_fc_part_catalog_id' in line._fields else False
+ )
+ expected = int(line.product_uom_qty or 0)
+ line_vals.append((0, 0, {
+ 'part_catalog_id': part.id if part else False,
+ 'part_number': (part.part_number if part else '') or '',
+ 'description': line.name or '',
+ 'expected_qty': expected,
+ 'received_qty': expected,
+ }))
+ self.env['fp.receiving'].create({
+ 'sale_order_id': order.id,
+ 'expected_qty': int(total_qty),
+ 'line_ids': line_vals,
+ })
return res
def action_view_receiving(self):
diff --git a/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv b/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv
index 4e1c7d82..c13b5f51 100644
--- a/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv
+++ b/fusion_plating/fusion_plating_receiving/security/ir.model.access.csv
@@ -14,3 +14,9 @@ access_fp_racking_inspection_manager,fp.racking.inspection.manager,model_fp_rack
access_fp_racking_inspection_line_operator,fp.racking.inspection.line.operator,model_fp_racking_inspection_line,fusion_plating.group_fusion_plating_operator,1,1,1,1
access_fp_racking_inspection_line_supervisor,fp.racking.inspection.line.supervisor,model_fp_racking_inspection_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_racking_inspection_line_manager,fp.racking.inspection.line.manager,model_fp_racking_inspection_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
+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_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
diff --git a/fusion_plating/fusion_plating_receiving/tests/__init__.py b/fusion_plating/fusion_plating_receiving/tests/__init__.py
new file mode 100644
index 00000000..ffad1d47
--- /dev/null
+++ b/fusion_plating/fusion_plating_receiving/tests/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import test_carrier_fields
diff --git a/fusion_plating/fusion_plating_receiving/tests/test_carrier_fields.py b/fusion_plating/fusion_plating_receiving/tests/test_carrier_fields.py
new file mode 100644
index 00000000..c1262802
--- /dev/null
+++ b/fusion_plating/fusion_plating_receiving/tests/test_carrier_fields.py
@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+"""Phase A — carrier field + outbound shipment link tests on fp.receiving.
+
+See docs/superpowers/specs/2026-05-18-phase-a-shipping-carrier-foundation-design.md.
+"""
+from odoo.tests.common import TransactionCase
+
+
+class TestCarrierFields(TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.partner = cls.env['res.partner'].create({'name': 'CarrierCust'})
+ cls.product = cls.env['product.product'].create({'name': 'Widget'})
+ cls.so = cls.env['sale.order'].create({
+ 'partner_id': cls.partner.id,
+ 'order_line': [(0, 0, {
+ 'product_id': cls.product.id,
+ 'product_uom_qty': 1,
+ })],
+ })
+ # Carrier records seeded by data/delivery_carrier_seed_data.xml.
+ cls.carrier_ups = cls.env.ref(
+ 'fusion_plating_receiving.delivery_carrier_ups',
+ )
+ cls.carrier_fedex = cls.env.ref(
+ 'fusion_plating_receiving.delivery_carrier_fedex',
+ )
+
+ def _make_receiving(self, **kw):
+ vals = {'sale_order_id': self.so.id}
+ vals.update(kw)
+ return self.env['fp.receiving'].create(vals)
+
+ # ---- Field existence ------------------------------------------------
+
+ def test_carrier_id_field_exists_on_receiving(self):
+ recv = self._make_receiving()
+ self.assertIn('x_fc_carrier_id', recv._fields)
+
+ def test_outbound_shipment_id_field_exists_on_receiving(self):
+ recv = self._make_receiving()
+ self.assertIn('x_fc_outbound_shipment_id', recv._fields)
+
+ # ---- action_create_outbound_shipment --------------------------------
+
+ def test_action_create_outbound_shipment_creates_draft(self):
+ recv = self._make_receiving(x_fc_carrier_id=self.carrier_ups.id)
+ self.assertFalse(recv.x_fc_outbound_shipment_id)
+ recv.action_create_outbound_shipment()
+ self.assertTrue(recv.x_fc_outbound_shipment_id)
+ ship = recv.x_fc_outbound_shipment_id
+ self.assertEqual(ship.status, 'draft')
+ self.assertEqual(ship.carrier_id, self.carrier_ups)
+ self.assertEqual(ship.sale_order_id, self.so)
+
+ def test_action_create_outbound_shipment_idempotent(self):
+ recv = self._make_receiving(x_fc_carrier_id=self.carrier_ups.id)
+ recv.action_create_outbound_shipment()
+ first_ship = recv.x_fc_outbound_shipment_id
+ recv.action_create_outbound_shipment()
+ # Second call must not create a new shipment.
+ self.assertEqual(recv.x_fc_outbound_shipment_id, first_ship)
+ count = self.env['fusion.shipment'].search_count([
+ ('sale_order_id', '=', self.so.id),
+ ])
+ self.assertEqual(count, 1)
+
+ # ---- onchange propagation -------------------------------------------
+
+ def test_carrier_id_change_propagates_to_draft_shipment(self):
+ recv = self._make_receiving(x_fc_carrier_id=self.carrier_ups.id)
+ recv.action_create_outbound_shipment()
+ ship = recv.x_fc_outbound_shipment_id
+ self.assertEqual(ship.carrier_id, self.carrier_ups)
+ # Onchange triggers via the Form helper — we simulate by calling
+ # the handler directly after writing.
+ recv.x_fc_carrier_id = self.carrier_fedex.id
+ recv._onchange_x_fc_carrier_id()
+ self.assertEqual(ship.carrier_id, self.carrier_fedex)
+
+ def test_carrier_id_change_does_not_propagate_to_confirmed_shipment(self):
+ recv = self._make_receiving(x_fc_carrier_id=self.carrier_ups.id)
+ recv.action_create_outbound_shipment()
+ ship = recv.x_fc_outbound_shipment_id
+ # Confirm the shipment — propagation must stop.
+ ship.status = 'confirmed'
+ recv.x_fc_carrier_id = self.carrier_fedex.id
+ recv._onchange_x_fc_carrier_id()
+ # Confirmed shipment retains the original carrier.
+ self.assertEqual(ship.carrier_id, self.carrier_ups)
diff --git a/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml b/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml
index 98acc7c2..312e78d3 100644
--- a/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml
+++ b/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml
@@ -72,18 +72,41 @@
type="object"
invisible="state != 'discrepancy'"
groups="fusion_plating.group_fusion_plating_manager"/>
+
+
-
+
@@ -114,8 +137,46 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ×
+
+ ×
+
+
+
+
+
+
+ Enter the weight and dimensions of the
+ packaging you'll use to ship the finished
+ parts back. The system reuses the same
+ boxes for the return shipment. Click
+ Generate Outbound Label
+ in the header once carrier + weight are
+ set.
+
@@ -124,6 +185,33 @@
+
+
+ For multi-box shipments, add one row per
+ box with its weight + dimensions. The
+ carrier API will return one tracking
+ number + one label per row.
+ Single-box flow: leave
+ this empty and the top-level weight/dim
+ fields above are used.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_receiving/views/fusion_shipment_inherit_views.xml b/fusion_plating/fusion_plating_receiving/views/fusion_shipment_inherit_views.xml
new file mode 100644
index 00000000..6d2d57c5
--- /dev/null
+++ b/fusion_plating/fusion_plating_receiving/views/fusion_shipment_inherit_views.xml
@@ -0,0 +1,32 @@
+
+
+
+
+ fusion.shipment.form.mps.inherit
+ fusion.shipment
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_receiving/wizards/__init__.py b/fusion_plating/fusion_plating_receiving/wizards/__init__.py
new file mode 100644
index 00000000..e1380a58
--- /dev/null
+++ b/fusion_plating/fusion_plating_receiving/wizards/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import fp_label_manual_wizard
diff --git a/fusion_plating/fusion_plating_receiving/wizards/fp_label_manual_wizard.py b/fusion_plating/fusion_plating_receiving/wizards/fp_label_manual_wizard.py
new file mode 100644
index 00000000..200222f1
--- /dev/null
+++ b/fusion_plating/fusion_plating_receiving/wizards/fp_label_manual_wizard.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+"""Manual outbound-label entry wizard.
+
+Opens automatically from fp.receiving.action_generate_outbound_label
+when:
+ - the chosen carrier has no API integration (delivery_type='fixed'), or
+ - the carrier API call fails (network, credential, malformed response).
+
+Operator pastes the label PDF from the carrier's web tool + types the
+tracking number. On confirm, both land on the linked fusion.shipment.
+"""
+import base64
+
+from markupsafe import Markup
+
+from odoo import _, fields, models
+from odoo.exceptions import UserError
+
+
+class FpLabelManualWizard(models.TransientModel):
+ _name = 'fp.label.manual.wizard'
+ _description = 'Fusion Plating — Manual Outbound Label Entry'
+
+ 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,
+ )
+ shipment_id = fields.Many2one(
+ related='receiving_id.x_fc_outbound_shipment_id', readonly=True,
+ )
+ note = fields.Text(
+ string='Why Manual?', readonly=True,
+ help='Explanatory message — set by the caller (no API, API '
+ 'failure, etc.).',
+ )
+ label_pdf = fields.Binary(string='Shipping Label PDF')
+ label_filename = fields.Char(string='Filename')
+ tracking_number = fields.Char(string='Tracking Number')
+
+ def action_confirm(self):
+ self.ensure_one()
+ if not self.label_pdf:
+ raise UserError(_(
+ 'Attach the shipping label PDF before confirming.'
+ ))
+ if not (self.tracking_number or '').strip():
+ raise UserError(_(
+ 'Enter the tracking number before confirming.'
+ ))
+ ship = self.shipment_id
+ if not ship:
+ raise UserError(_(
+ 'No outbound shipment linked to this receiving — '
+ 'cannot save manual label.'
+ ))
+ # Create the attachment, then write the shipment.
+ att = self.env['ir.attachment'].sudo().create({
+ 'name': self.label_filename or 'shipping-label.pdf',
+ 'type': 'binary',
+ 'datas': self.label_pdf,
+ 'mimetype': 'application/pdf',
+ 'res_model': 'fusion.shipment',
+ 'res_id': ship.id,
+ })
+ ship.sudo().write({
+ 'label_attachment_id': att.id,
+ 'tracking_number': self.tracking_number.strip(),
+ 'status': 'confirmed',
+ })
+ ship.message_post(body=Markup(_(
+ 'Manual label saved — tracking %s.'
+ )) % self.tracking_number)
+ self.receiving_id.message_post(body=Markup(_(
+ 'Outbound label entered manually. Tracking: %s'
+ )) % self.tracking_number)
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/fusion_plating/fusion_plating_receiving/wizards/fp_label_manual_wizard_views.xml b/fusion_plating/fusion_plating_receiving/wizards/fp_label_manual_wizard_views.xml
new file mode 100644
index 00000000..4d960c87
--- /dev/null
+++ b/fusion_plating/fusion_plating_receiving/wizards/fp_label_manual_wizard_views.xml
@@ -0,0 +1,49 @@
+
+
+
+
+ fp.label.manual.wizard.form
+ fp.label.manual.wizard
+
+
+
+
+
Enter Label Manually —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py
index 5f3b3e87..53eee7e5 100644
--- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py
+++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
- 'version': '19.0.26.1.0',
+ 'version': '19.0.26.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py
index 3d336228..fedfe8cc 100644
--- a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py
+++ b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py
@@ -244,7 +244,7 @@ class FpManagerDashboardController(http.Controller):
and 'x_fc_assigned_manager_id' in so_fields):
pending_accept_sos = SO.search_count([
('state', '=', 'sale'),
- ('x_fc_receiving_status', '=', 'inspected'),
+ ('x_fc_receiving_status', '=', 'received'),
('x_fc_assigned_manager_id', '=', False),
])
else:
diff --git a/fusion_shipping/__manifest__.py b/fusion_shipping/__manifest__.py
index 0c8cefc2..4319a921 100644
--- a/fusion_shipping/__manifest__.py
+++ b/fusion_shipping/__manifest__.py
@@ -1,6 +1,6 @@
{
"name": "Fusion Shipping",
- "version": "19.0.1.1.0",
+ "version": "19.0.1.5.0",
"category": "Inventory/Delivery",
"summary": "All-in-one shipping integration — Canada Post, UPS, FedEx, DHL Express. "
"Live pricing, label generation, shipment tracking, and multi-package support.",
diff --git a/fusion_shipping/api/fedex_rest/request.py b/fusion_shipping/api/fedex_rest/request.py
index 0c771039..6ba2dcee 100644
--- a/fusion_shipping/api/fedex_rest/request.py
+++ b/fusion_shipping/api/fedex_rest/request.py
@@ -342,6 +342,34 @@ class FedexRequest:
res.append({'number': partner.parent_id.vat, 'tinType': 'BUSINESS_NATIONAL'})
return res
+ def _strip_customs_for_domestic(self, request_data):
+ """Remove customsClearanceDetail when shipper + recipient are in
+ the same country and the service isn't FEDEX_REGIONAL_ECONOMY.
+
+ FedEx rejects domestic Ground/Express requests that carry a
+ customs block (TOTALCUSTOMSVALUE.REQUIRED). The upstream model
+ always builds the block; we strip it for clearly-domestic cases.
+ Caller invokes this immediately before _send_fedex_request.
+ """
+ rs = request_data.get('requestedShipment', {})
+ shipper = rs.get('shipper') or {}
+ ship_addr = shipper.get('address') or {}
+ # Recipient lives under 'recipients' (list) for /ship and
+ # 'recipient' (single) for /rate. Handle both shapes.
+ rec = rs.get('recipients') or []
+ if isinstance(rec, list) and rec:
+ rec_addr = rec[0].get('address') or {}
+ else:
+ rec_addr = (rs.get('recipient') or {}).get('address') or {}
+ ship_country = ship_addr.get('countryCode')
+ rec_country = rec_addr.get('countryCode')
+ if (ship_country and rec_country
+ and ship_country == rec_country
+ and self.service_type != 'FEDEX_REGIONAL_ECONOMY'
+ # India domestic still uses customs per upstream logic.
+ and not (ship_country == 'IN' and rec_country == 'IN')):
+ rs.pop('customsClearanceDetail', None)
+
def _get_shipping_price(self, ship_from, ship_to, packages, currency):
fedex_currency = _convert_curr_iso_fdx(currency)
request_data = {
@@ -364,6 +392,7 @@ class FedexRequest:
}
}
self._add_extra_data_to_request(request_data, 'rate')
+ self._strip_customs_for_domestic(request_data)
res = self._send_fedex_request("/rate/v1/rates/quotes", request_data)
try:
rate = next(filter(lambda d: d['currency'] == fedex_currency, res['rateReplyDetails'][0]['ratedShipmentDetails']), {})
@@ -474,6 +503,7 @@ class FedexRequest:
request_data['requestedShipment']['customsClearanceDetail']['customsOption'] = {'type': 'COURTESY_RETURN_LABEL'}
self._add_extra_data_to_request(request_data, 'ship')
+ self._strip_customs_for_domestic(request_data)
res = self._send_fedex_request("/ship/v1/shipments", request_data)
try:
@@ -561,6 +591,7 @@ class FedexRequest:
}
self._add_extra_data_to_request(request_data, 'return')
+ self._strip_customs_for_domestic(request_data)
res = self._send_fedex_request("/ship/v1/shipments", request_data)
try:
@@ -597,6 +628,62 @@ class FedexRequest:
return actual['totalNetChargeWithDutiesAndTaxes']
return actual['totalNetCharge']
+ def track_shipment(self, tracking_nr):
+ """Call FedEx /track/v1/trackingnumbers and return the parsed
+ scan-event list. Returns:
+ {
+ 'tracking_number': '',
+ 'status': '',
+ 'events': [
+ {
+ 'date_time': '',
+ 'description': '',
+ 'event_type': '',
+ 'city': '',
+ 'state_province': '',
+ 'country': '',
+ 'signed_by': '',
+ }, ...
+ ]
+ }
+ Empty events list when FedEx returns no scans yet (newly-printed
+ label that hasn't been picked up). Raises ValidationError on
+ HTTP error.
+ """
+ res = self._send_fedex_request("/track/v1/trackingnumbers", {
+ 'includeDetailedScans': True,
+ 'trackingInfo': [{
+ 'trackingNumberInfo': {
+ 'trackingNumber': tracking_nr,
+ },
+ }],
+ })
+ out = {'tracking_number': tracking_nr, 'status': '', 'events': []}
+ try:
+ results = (res.get('completeTrackResults') or [{}])[0]
+ track = (results.get('trackResults') or [{}])[0]
+ except (AttributeError, IndexError):
+ return out
+ latest = track.get('latestStatusDetail') or {}
+ out['status'] = (
+ latest.get('description')
+ or latest.get('statusByLocale')
+ or ''
+ )
+ for scan in (track.get('scanEvents') or []):
+ addr = scan.get('scanLocation') or {}
+ out['events'].append({
+ 'date_time': scan.get('date') or '',
+ 'description': scan.get('eventDescription') or '',
+ 'event_type': scan.get('eventType') or '',
+ 'city': addr.get('city') or '',
+ 'state_province': addr.get('stateOrProvinceCode') or '',
+ 'country': addr.get('countryCode') or '',
+ 'signed_by': track.get('deliveryDetails', {}).get(
+ 'receivedByName', '') or '',
+ })
+ return out
+
def cancel_shipment(self, tracking_nr):
res = self._send_fedex_request('/ship/v1/shipments/cancel', {
'accountNumber': {'value': self.account_number},
diff --git a/fusion_shipping/models/fusion_shipment.py b/fusion_shipping/models/fusion_shipment.py
index dc44d84c..c97046eb 100644
--- a/fusion_shipping/models/fusion_shipment.py
+++ b/fusion_shipping/models/fusion_shipment.py
@@ -292,7 +292,13 @@ class FusionShipment(models.Model):
# ── Tracking ──────────────────────────────────────────────
def action_refresh_tracking(self):
- """Fetch latest tracking events from Canada Post VIS API."""
+ """Fetch latest tracking events from the carrier's API.
+
+ Dispatch by carrier_type:
+ - canada_post → Canada Post VIS API (inline below)
+ - fedex_rest → FedEx /track/v1/trackingnumbers
+ - other carriers → not yet supported; raise with clear message
+ """
self.ensure_one()
if not self.tracking_number:
raise ValidationError(
@@ -301,6 +307,15 @@ class FusionShipment(models.Model):
if not carrier:
raise ValidationError(
_("No carrier linked to this shipment."))
+ if self.carrier_type == 'fedex_rest':
+ return self._refresh_tracking_fedex_rest()
+ if self.carrier_type != 'canada_post':
+ raise ValidationError(_(
+ "Refresh Tracking is only wired to Canada Post and "
+ "FedEx REST at this time. For %(carrier)s shipments, "
+ "use the Track Shipment button to view live tracking "
+ "on the carrier's website."
+ ) % {'carrier': carrier.name})
# VIS tracking uses /vis/ path, not /rs/
if carrier.prod_environment:
@@ -745,3 +760,70 @@ class FusionShipment(models.Model):
attachment_ids=(
self.return_label_attachment_id.ids
if self.return_label_attachment_id else []))
+
+ # ── FedEx REST tracking ──────────────────────────────────────────
+
+ def _refresh_tracking_fedex_rest(self):
+ """Call FedEx /track/v1/trackingnumbers and load scan events.
+
+ Parses the response via the FedexRestRequest.track_shipment
+ helper, replaces the shipment's tracking_event_ids with the
+ latest events, and updates status to 'delivered' if the latest
+ event indicates delivery. The 'delivered' transition cascades
+ to the portal_job via the existing write() hook.
+ """
+ self.ensure_one()
+ from odoo.addons.fusion_shipping.api.fedex_rest.request import (
+ FedexRequest as FedexRestRequest,
+ )
+ try:
+ fedex = FedexRestRequest(self.carrier_id)
+ result = fedex.track_shipment(self.tracking_number)
+ except Exception as e:
+ raise ValidationError(
+ _("FedEx tracking error: %s") % str(e))
+ # Replace events.
+ self.tracking_event_ids.unlink()
+ vals_list = []
+ delivered = False
+ for evt in result.get('events') or []:
+ evt_date_str = ''
+ evt_time_str = ''
+ evt_datetime = False
+ raw_dt = evt.get('date_time') or ''
+ if raw_dt:
+ # FedEx returns ISO 8601 like 2026-05-18T14:30:00-05:00.
+ try:
+ parsed = dt_mod.fromisoformat(raw_dt)
+ # Strip tzinfo so it stores in Odoo's naive UTC fields.
+ if parsed.tzinfo is not None:
+ import pytz as _pytz
+ parsed = parsed.astimezone(_pytz.UTC).replace(tzinfo=None)
+ evt_datetime = parsed
+ evt_date_str = parsed.strftime('%Y-%m-%d')
+ evt_time_str = parsed.strftime('%H:%M:%S')
+ except (ValueError, TypeError):
+ pass
+ if (evt.get('event_type') or '').upper() == 'DL' or (
+ 'delivered' in (evt.get('description') or '').lower()):
+ delivered = True
+ vals_list.append({
+ 'shipment_id': self.id,
+ 'event_date': evt_date_str or False,
+ 'event_time': evt_time_str or '',
+ 'event_datetime': evt_datetime,
+ 'event_description': evt.get('description') or '',
+ 'event_type': evt.get('event_type') or '',
+ 'event_site': evt.get('city') or '',
+ 'event_province': evt.get('state_province') or '',
+ 'signatory_name': evt.get('signed_by') or '',
+ })
+ if vals_list:
+ self.env['fusion.tracking.event'].create(vals_list)
+ self.last_tracking_update = fields.Datetime.now()
+ if delivered and self.status != 'delivered':
+ self.status = 'delivered'
+ self.delivery_date = fields.Datetime.now()
+ self.message_post(body=_(
+ "FedEx tracking refreshed: %(n)d event(s) loaded. Status: %(s)s"
+ ) % {'n': len(vals_list), 's': result.get('status') or '—'})
diff --git a/fusion_shipping/views/fusion_shipment_views.xml b/fusion_shipping/views/fusion_shipment_views.xml
index 5a75c69e..7843e1f5 100644
--- a/fusion_shipping/views/fusion_shipment_views.xml
+++ b/fusion_shipping/views/fusion_shipment_views.xml
@@ -78,12 +78,17 @@
string="Refresh Tracking"
class="btn-primary"
icon="fa-refresh"
- invisible="not tracking_number or status == 'cancelled'"/>
+ invisible="not tracking_number or status == 'cancelled' or carrier_type not in ('canada_post', 'fedex_rest')"/>
+