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

@@ -83,6 +83,24 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
- Local URL: http://localhost:8069
- Test before deploying. Edit existing files — don't create unnecessary new ones.
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab
When a Python action opens an attachment, route it through `fusion_pdf_preview` instead of returning `ir.actions.act_url` with `download=true` or `target=new`. The preview dialog gives operators preview + print + download in one place and writes an audit log; non-PDF attachments fall back to the legacy download path automatically.
The drop-in replacement is the new helper on `ir.attachment`:
```python
return att.action_fusion_preview(title='My Doc')
# vs. the old pattern:
# return {'type': 'ir.actions.act_url',
# 'url': '/web/content/%s?download=true' % att.id,
# 'target': 'new'}
```
The helper auto-detects mimetype: PDFs go to the dialog, everything else (ZPL, CSV, XML, images) stays on download. So a callsite that today serves CSV today and a PDF tomorrow doesn't need a code change — same call, different routing.
If you need to invoke the client action directly (rare — only when you don't have a recordset handy), the tag is `fusion_pdf_preview.open_attachment` and the params are `{attachment_id, title, model_name, record_ids, report_name}`. See `fusion_pdf_preview/static/src/js/open_attachment_action.js`.
Existing reports (`ir.actions.report` of type `qweb-pdf`) are intercepted automatically by `fusion_pdf_preview/static/src/js/pdf_preview.js`; the helper above is for the *other* pattern — attachments opened by custom buttons.
## Supabase Knowledge Base
Before starting unfamiliar work, check Supabase for context:
```bash

View File

@@ -252,10 +252,23 @@ class FusionCpShipment(models.Model):
}
def _action_open_attachment(self, attachment):
"""Open an attachment PDF in the browser viewer (new tab)."""
"""Open an attachment for the operator.
Delegates to ir.attachment.action_fusion_preview when
fusion_pdf_preview is installed — PDFs render in the preview
dialog, anything else downloads. Falls back to the legacy
new-tab URL when the helper isn't available. See CLAUDE.md
"PDF Preview" for the contract.
"""
self.ensure_one()
if not attachment:
return False
if hasattr(attachment, 'action_fusion_preview'):
return attachment.action_fusion_preview(
title=attachment.name or 'Shipping Label',
model_name=self._name,
record_ids=self.id,
)
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=false' % attachment.id,

3106
fusion_claims/CLAUDE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
{
"name": "Fusion PDF Preview",
"version": "19.0.2.0.0",
"version": "19.0.2.1.0",
"depends": ["web"],
"author": "Nexa Systems Inc",
"category": "web",
@@ -41,6 +41,7 @@ Key Features:
"assets": {
"web.assets_backend": [
"fusion_pdf_preview/static/src/js/pdf_preview.js",
"fusion_pdf_preview/static/src/js/open_attachment_action.js",
"fusion_pdf_preview/static/src/js/user_menu.js",
"fusion_pdf_preview/static/src/xml/pdf_viewer_dialog.xml",
],

View File

@@ -3,5 +3,6 @@
from . import res_users
from . import ir_http
from . import ir_actions_report
from . import ir_attachment
from . import res_config_settings
from . import preview_log

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
from odoo import models
class IrAttachment(models.Model):
_inherit = "ir.attachment"
def action_fusion_preview(self, title=None, model_name=None, record_ids=None):
"""Return the right action to "view" this attachment.
- PDF attachments → fusion_pdf_preview's client dialog (preview
+ print + download all in one place, with audit log).
- Anything else (ZPL, CSV, XML, images, etc.) → legacy
new-tab/download URL since the PDF viewer can't render them.
Drop-in replacement for the common pattern:
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % att.id,
'target': 'new',
}
Use as: return att.action_fusion_preview(title='My Doc')
See CLAUDE.md "PDF Preview" for the full contract.
"""
self.ensure_one()
is_pdf = (
(self.mimetype or '').lower() == 'application/pdf'
or (self.name or '').lower().endswith('.pdf')
)
if is_pdf:
return {
'type': 'ir.actions.client',
'tag': 'fusion_pdf_preview.open_attachment',
'params': {
'attachment_id': self.id,
'title': title or self.name or 'Document',
'model_name': model_name or '',
'record_ids': str(record_ids) if record_ids else '',
},
}
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % self.id,
'target': 'new',
}

View File

@@ -0,0 +1,42 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { openPDFViewer } from "./pdf_preview";
/**
* Client action: open an ir.attachment in the PDF preview dialog.
*
* Python callers return:
* {
* 'type': 'ir.actions.client',
* 'tag': 'fusion_pdf_preview.open_attachment',
* 'params': {
* 'attachment_id': <int>,
* 'title': <str>, // optional, defaults to "Document"
* 'model_name': <str>, // optional, for audit log
* 'record_ids': <str>, // optional, comma-sep for audit log
* 'report_name': <str>, // optional, for audit log
* },
* }
*
* Non-PDF attachments fall back to opening in a new browser tab — the
* preview dialog only renders PDF. ZPL labels (text/plain) should NOT
* use this action; route those through the direct download act_url.
*/
registry.category("actions").add(
"fusion_pdf_preview.open_attachment",
async (env, action) => {
const params = action.params || {};
const attachmentId = params.attachment_id;
if (!attachmentId) {
return;
}
const title = params.title || "Document";
const url = `/web/content/${attachmentId}`;
openPDFViewer(env, url, title, {
modelName: params.model_name || "",
recordIds: params.record_ids || "",
reportName: params.report_name || "",
});
}
);

View File

@@ -27,11 +27,22 @@ 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` |
| **Page-break-inside: avoid placement** | When a long QWeb report dumps content into multi-page PDFs via wkhtmltopdf, the company header (rendered as `--header-html`) can overlap body content if a page break lands mid-row in a table. **Apply `page-break-inside: avoid` to `<tr>` elements** (and to wrapper `<div>`s that wrap whole logical sections like signature blocks), not to `<table>`. On entech wkhtmltopdf, `<table>`-level `page-break-inside` is unreliable when the table is long enough to definitely break; per-row is honoured. Pattern: keep individual readings/rows together so the wkhtmltopdf header zone never overlaps mid-row content. Wrap the larger logical block (cert thickness section, signature + certification statement) in `<div style="page-break-inside: avoid;">` to keep it together when it fits and naturally wrap to a fresh page when it doesn't. | `fusion_plating_reports/report/report_coc.xml` |
| **`opacity` + `italic` muted text renders jagged on entech wkhtmltopdf** | The obvious pattern for a subtle footnote — `font-style: italic; opacity: 0.7;` (used by `.fp-coc .small-label`) — produces washed-out, jagged characters that look "broken" or "messed up" on the printed PDF. Visually it reads as garbled text even though the source is clean. **Use solid grey (`color: #555`) at normal weight instead** for muted secondary text. Same workaround applies to any `opacity`-driven greyed-out element bound for wkhtmltopdf. The existing `.small-label` class still exists for legacy callers but new code should prefer an explicit `color:` style. | `fusion_plating_reports` |
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix:** size `paperformat.margin_top` to the actual rendered header height, then drop body `padding-top` to a tiny visual gap (~5mm). Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12). Update the right one and don't bleed changes across reports. | `fusion_plating_reports`, `report.paperformat` |
| **CoC + thickness = ONE cert (page 2 merge OR inline body)** | 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**. 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. **Two rendering paths exist for the thickness data in the CoC PDF:** (1) **Page-2 PDF merge** via `_fp_merge_thickness_into_pdf` — used when there's a real PDF source (operator uploaded a Fischerscope PDF, or QC has `thickness_report_pdf_id`). (2) **Inline readings table in the CoC body** — used when `thickness_reading_ids` is populated but there's no PDF source (e.g. RTF upload parsed to readings, manually typed readings). Lives in `report_coc.xml` between the parts table and the signature block, gated on `doc.thickness_reading_ids`. Both can coexist on a cert — PDF merges as page 2, readings render inline; usually only one path has data per cert. | `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports` |
| **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 `<div class="o_stat_info">` without `<span class="o_stat_value">` renders awkwardly in Odoo 19 (numbers + label expected); `statinfo` handles the standard structure automatically. The action method itself should branch on whether the linked record exists (create-then-open or just open). | any module with smart buttons |
| **stock.move.name removed** | Odoo 19 dropped the `name` field on `stock.move`. Passing `name` in a create dict raises `ValueError: Invalid field 'name' on model 'stock.move'`. Use `description_picking` instead (the operator-facing line label on the picking). The DB column is gone too — `name` doesn't exist as a stored field. | any code that builds stock.move records |
| **`mail.template.body_html` is `Markup` + jsonb** | Two gotchas: (1) `tpl.body_html` returns a `markupsafe.Markup` object. `Markup.replace(old, new)` *escapes both args* — quotes in `old` become `&#39;` so the literal pre-escape string never matches. **Cast to `str(tpl.body_html)` before calling `.replace`**. (2) The DB column is `jsonb` (translatable). Direct `UPDATE ... SET body_html = '...'` SQL fails with `invalid input syntax for type json`; either use ORM `tpl.write({'body_html': ...})` or wrap raw SQL with `jsonb_build_object('en_US', ...)`. (3) Mail-template XML data files typically use `<odoo noupdate="1">` so `-u <module>` does NOT reload them — users can edit templates in the UI and the module won't overwrite. To sync XML edits to existing records, write a one-shot post-migration or update via `odoo shell`. | any code scripting `mail.template.body_html` |
| **Recordsets use `__slots__` — no transient attrs** | Odoo 19's `BaseModel` declares `__slots__ = ['env', '_ids', '_prefetch_ids']`, so `picking._my_stash = data` raises `AttributeError: 'stock.picking' object has no attribute '_my_stash'`. The error reads like a missing field but it's actually Python rejecting the assignment. Don't stash transient state on a recordset between method calls — pass it as a method arg, store on the caller's `self`, or use `env.context` for cross-frame plumbing. Caught here because `fp_receiving._fp_build_shipping_picking` tried to attach `_fp_outbound_packages` to the picking before handing off to `_fp_apply_shipping_result`; the catch-all `except Exception` swallowed it and surfaced the misleading "Carrier API call failed" wizard. | any code that wants to attach data to a recordset between calls |
| **labelary.com dependency for ZPL→PDF** | `fusion_plating_receiving` POSTs ZPL labels to `https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/` to get a PDF rasterization, so one FedEx ship call can populate both the PDF and ZPL smart buttons on the receiving form. **Privacy:** every outbound label's shipping address + tracking number leaves the network and hits labelary's servers (no payment data, but real customer info). **Operational:** anonymous tier is ~5 req/s; add an API key in the labelary helper if you ever ship more than that. PDF→ZPL is intentionally not attempted — that direction is impractical and FedEx's `/ship` endpoint only returns one format per shipment, so the carrier MUST be configured for ZPLII (not PDF) for the dual-format flow to work. Switching the carrier back to PDF will silently drop the ZPL button. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
| **FedEx ZPL ships with `^POI` — strip it** | FedEx's REST `/ship` endpoint returns ZPL with `^POI` (Print Orientation = Invert) baked in, which flips the label 180° on the printer. On a desktop direct-thermal like the Zebra ZD450 that prints upside-down for the operator, and labelary mirrors the inversion in the PDF preview. `_fp_apply_shipping_result` creates a `*-fixed.zpl` copy of the FedEx attachment with `^POI` removed and points the shipment + smart buttons at the cleaned copy; the original FedEx ZPL stays on the picking for audit. **Don't restore `^POI`** — both the PDF preview and the Zebra output need it stripped. If a future printer needs inverted orientation, configure the printer driver instead of putting `^POI` back. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
| **Per-shipment service override via `fp_service_type_override` context key** | Operator picks a FedEx service tier on `fp.receiving.x_fc_outbound_service_type` (Priority Overnight, 2Day, Ground, etc.). `action_generate_outbound_label` passes the chosen code through to `carrier.send_shipping` via `with_context(fp_service_type_override=…)`. `fusion_shipping.fusion_fedex_rest_send_shipping` reads the context key and overrides `srm.service_type` for that call only — carrier default is untouched. Empty/blank override falls back to `carrier.fedex_rest_service_type`. Only FedEx is wired up right now; mirroring this for Canada Post / UPS is a separate task. | `fusion_plating_receiving/models/fp_receiving.py``fusion_shipping/models/delivery_carrier.py` |
| **`mail.template.body_html` is `Markup` + jsonb** | Two gotchas: (1) `tpl.body_html` returns a `markupsafe.Markup` object. `Markup.replace(old, new)` *escapes both args* — quotes in `old` become `&#39;` so the literal pre-escape string never matches. **Cast to `str(tpl.body_html)` before calling `.replace`**. (2) The DB column is `jsonb` (translatable). Direct `UPDATE ... SET body_html = '...'` SQL fails with `invalid input syntax for type json`; either use ORM `tpl.write({'body_html': ...})` or wrap raw SQL with `jsonb_build_object('en_US', ...)`. (3) Mail-template XML data files typically use `<odoo noupdate="1">` so `-u <module>` does NOT reload them — users can edit templates in the UI and the module won't overwrite. To sync XML edits to existing records: temporarily flip the wrapper to `<odoo noupdate="0">`, redeploy and `-u`, then revert (and `UPDATE ir_model_data SET noupdate=true ...` to restore protection). Alternatively, post-migration script or odoo shell write. (4) **`mail.template.report_name` was removed in Odoo 19** — the dynamic PDF-filename field now lives on `ir.actions.report.print_report_name` instead. Old `<field name="report_name">` entries in mail-template data files silently survive while protected by noupdate=1, but the moment you force-reload they error with `Invalid field 'report_name' in 'mail.template'`. Strip them or move the expression to the report action. | any code scripting `mail.template.body_html` |
| **`message_post(body=...)` HTML-escapes by default** | A plain `str` body with `<b>` tags renders as literal `<b>foo</b>` text in chatter — operators see angle brackets, not bold. Wrap the template in `Markup(_('... <b>%s</b> ...'))` and use `%`/`format_map` for substitutions; markupsafe escapes the substituted values automatically so user input still can't inject HTML. Pattern: `self.message_post(body=Markup(_('Tracking: <b>%s</b>')) % tracking)`. | any model posting HTML-formatted chatter |
| **OWL `t-out` escapes plain JS strings — wrap with `markup()`** | The JS-side analogue of the `message_post` markup gotcha. `t-out="state.html"` only renders unescaped HTML when the value is a `markup()`-tagged string from `@odoo/owl`; a plain string (e.g. straight off an RPC response) gets HTML-escaped and the user sees literal `<p>foo</p>` text. Caught here because `fp_record_inputs_dialog.js` was assigning `this.state.instructionsHtml = data.instructions_html` raw — recipe author's `<p>...</p>` rendered as visible tags in the operator dialog. **Fix:** `import { markup } from "@odoo/owl"` and wrap RPC-returned HTML: `this.state.html = markup(data.html || "")`. Same rule for any OWL component that ingests HTML from the server and pushes it through `t-out`. | any OWL component rendering server-returned HTML via `t-out` |
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
### Pending — IN PROGRESS when this session ended

View File

@@ -95,10 +95,20 @@ class FpFair(models.Model):
}
def action_view_signed_document(self):
"""Open the signed PDF attachment in a new browser tab."""
"""Open the signed PDF in the fusion_pdf_preview dialog.
Falls back to a new-tab URL when the helper isn't installed.
See CLAUDE.md "PDF Preview" for the contract.
"""
self.ensure_one()
if not self.x_fc_signed_pdf_id:
return False
if hasattr(self.x_fc_signed_pdf_id, 'action_fusion_preview'):
return self.x_fc_signed_pdf_id.action_fusion_preview(
title=self.x_fc_signed_pdf_id.name or 'Signed FAIR',
model_name=self._name,
record_ids=self.id,
)
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % self.x_fc_signed_pdf_id.id,

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.7.0.0',
'version': '19.0.7.7.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -106,6 +106,81 @@ class FpCertificate(models.Model):
string='Fischerscope PDF filename',
)
# Non-PDF Fischerscope uploads (.doc / .docx / .xlsx / images) — the
# Issue Certs wizard stashes them here so the thickness-required gate
# can still pass. Unlike `x_fc_local_thickness_pdf`, this attachment
# is NOT merged into the CoC PDF as page 2 (we can't rasterize .doc
# server-side without LibreOffice). It rides along as a separate
# evidence attachment on the cert and on any email/portal delivery.
x_fc_local_thickness_evidence_id = fields.Many2one(
'ir.attachment',
string='Fischerscope Evidence (non-PDF)',
copy=False,
help='Original Fischerscope/XRF upload when not a PDF. Counts '
'as valid thickness evidence for the cert-issue gate but '
'is delivered as a separate attachment, not merged into '
'the CoC PDF.',
)
# Report-level Fischerscope metadata — populated by the Issue Certs
# wizard when parsing an RTF/.docx upload. Rendered on the CoC so
# the printed cert shows the same context an auditor would see on
# the original XDAL 600 export (equipment, operator, calibration,
# product/application, measuring time, date/time). Per-reading
# values (mils, Ni%, P%) live on fp.thickness.reading.
x_fc_thickness_equipment = fields.Char(
string='Thickness Equipment',
help='XRF/thickness gauge model (e.g. "Fischerscope XDAL 600").',
)
x_fc_thickness_operator = fields.Char(
string='Thickness Operator',
help='Operator initials/name as recorded by the gauge.',
)
x_fc_thickness_datetime = fields.Datetime(
string='Thickness Reading Date/Time',
help='When the readings were taken on the gauge.',
)
x_fc_thickness_product = fields.Char(
string='Thickness Product Profile',
help='XDAL 600 product line + part-family reference '
'(e.g. "2805031 / NiP/Al-alloys 2805030").',
)
x_fc_thickness_application = fields.Char(
string='Thickness Application',
help='XDAL 600 application profile '
'(e.g. "16 / NiP/Al-alloys").',
)
x_fc_thickness_directory = fields.Char(
string='Thickness Directory',
help='XDAL 600 directory the measurements were saved into.',
)
x_fc_thickness_measuring_time_sec = fields.Integer(
string='Thickness Measuring Time (sec)',
help='Per-reading measuring time configured on the gauge.',
)
x_fc_thickness_source_filename = fields.Char(
string='Thickness Source File',
help='Filename of the Fischerscope upload the readings were '
'parsed from.',
)
# Two paths populate this field, with operator upload winning:
# 1. RTF auto-extraction — Issue Certs wizard runs libwmf
# (wmf2svg) on the embedded WMF blocks and picks the
# largest raster (header banners filtered by area threshold).
# 2. Manual PNG/JPEG upload via the wizard's "Measurement
# Image" field — operator override path when the
# auto-extracted image is wrong, missing, or low-quality.
# See _apply_to_cert and _apply_image_to_cert in the wizard.
x_fc_thickness_image_id = fields.Many2one(
'ir.attachment',
string='Thickness Microscope Image',
copy=False,
help='Microscope photo of the measurement site. Auto-extracted '
'from the Fischerscope RTF export when libwmf can parse '
'the embedded WMF; operator can also upload a PNG/JPEG '
'directly via the Issue Certs wizard to override.',
)
# ---- Material traceability (T2.3) ----
batch_ids = fields.Many2many(
'fusion.plating.batch', compute='_compute_batch_ids',
@@ -456,7 +531,11 @@ class FpCertificate(models.Model):
if 'x_fc_thickness_pdf_id' in rec._fields else False
)
has_local_pdf = bool(rec.x_fc_local_thickness_pdf)
if not (has_readings or has_qc_fischer_pdf or has_local_pdf):
has_local_evidence = bool(
rec.x_fc_local_thickness_evidence_id
)
if not (has_readings or has_qc_fischer_pdf
or has_local_pdf or has_local_evidence):
type_label = (
_('Thickness Report')
if rec.certificate_type == 'thickness_report'
@@ -685,6 +764,32 @@ class FpCertificate(models.Model):
) % source)
return merged
def action_reset_to_draft(self):
"""Move an issued/voided cert back to draft so the manager can
correct typos in the thickness metadata, swap the microscope
image, re-pick the void reason, etc. — then re-Issue.
Wipes the existing `attachment_id` so the next render picks up
whatever was changed. The original PDF stays around as a
regular ir.attachment on the cert (for audit) since we only
clear the FK, not the attachment record itself. Re-issue
creates a fresh PDF.
"""
for rec in self:
if rec.state == 'draft':
raise UserError(_(
'Certificate %s is already a draft.'
) % rec.name)
rec.state = 'draft'
old_att = rec.attachment_id
if old_att:
rec.attachment_id = False
rec.message_post(body=_(
'Reset to draft for edits. The previously-issued PDF '
'%s remains attached for audit; a fresh PDF will be '
'generated on re-issue.'
) % (old_att.name if old_att else '(none)'))
def action_void(self):
for rec in self:
if rec.state != 'issued':

View File

@@ -42,12 +42,27 @@
<button name="action_issue" string="Issue"
type="object" class="btn-primary"
invisible="state != 'draft'"/>
<!-- Print = the same EN report action the gear-menu
Print > Certificate of Conformance (English)
calls. Routes through fusion_pdf_preview's
report interceptor automatically. For the
French variant or any other language report,
use the gear menu. -->
<button name="%(fusion_plating_reports.action_report_coc_en)d"
string="Print"
type="action" class="btn-secondary"
icon="fa-print"/>
<button name="action_open_void_wizard" string="Void"
type="object" class="btn-danger"
invisible="state != 'issued'"/>
<button name="action_send_to_customer" string="Send to Customer"
type="object"
invisible="state != 'issued'"/>
<button name="action_reset_to_draft" string="Reset to Draft"
type="object" class="btn-secondary"
icon="fa-undo"
confirm="Reset this certificate to draft? You'll be able to edit and re-issue. The previously-issued PDF stays attached for audit."
invisible="state == 'draft'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,issued"/>
</header>
@@ -67,48 +82,52 @@
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Main info — collapsed from 3 separate groups
into 1 to eliminate the dead rows that
appeared when one sub-group ran shorter than
the other. Left column is identity / signer /
dates; right column is part / process / qty /
derived stats. Reorganized 2026-05-21. -->
<group>
<group>
<field name="certificate_type"/>
<field name="partner_id"/>
<field name="sale_order_id"/>
<field name="portal_job_id"/>
<field name="issue_date"/>
</group>
<group>
<field name="part_number"/>
<field name="po_number"/>
<field name="entech_wo_number"/>
<field name="customer_job_no"/>
<field name="process_description"/>
<field name="spec_reference"/>
<field name="quantity_shipped"/>
<field name="nc_quantity"/>
<field name="contact_partner_id"
options="{'no_create': True}"
invisible="not partner_id"/>
</group>
</group>
<group>
<group>
<field name="sale_order_id"/>
<field name="entech_wo_number"/>
<field name="portal_job_id"/>
<field name="issue_date"/>
<field name="issued_by_id"/>
<field name="certified_by_id"/>
<field name="body_style"/>
</group>
<group>
<field name="part_number"/>
<field name="process_description"/>
<field name="spec_reference"/>
<field name="po_number"/>
<field name="customer_job_no"/>
<field name="quantity_shipped"/>
<field name="nc_quantity"/>
<field name="reading_count" readonly="1"/>
<field name="mean_nip_mils" readonly="1"/>
</group>
</group>
<!-- SPC rebalanced — spec/min/max on the left,
derived stats on the right; trend_explanation
spans both columns so the long message doesn't
get cropped. -->
<group string="SPC — Statistical Process Control">
<group>
<field name="spec_min_mils"/>
<field name="spec_max_mils"/>
<field name="min_reading_mils" readonly="1"/>
<field name="max_reading_mils" readonly="1"/>
<field name="std_dev_mils" readonly="1"/>
</group>
<group>
<field name="std_dev_mils" readonly="1"/>
<field name="cpk" readonly="1"/>
<field name="cpk_status" readonly="1" widget="badge"
decoration-success="cpk_status in ('capable','excellent')"
@@ -119,9 +138,9 @@
decoration-success="trend_alert == 'ok'"
decoration-warning="trend_alert == 'warning'"
decoration-danger="trend_alert == 'alert'"/>
<field name="trend_explanation" readonly="1"
invisible="trend_alert == 'ok'"/>
</group>
<field name="trend_explanation" readonly="1" colspan="2"
invisible="trend_alert == 'ok'"/>
</group>
<notebook>
<page string="Thickness Readings" name="readings">

View File

@@ -12,6 +12,19 @@
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- Header buttons: make draft Confirm the primary CTA, demote/rename
Send to "Send Email" (red), and reorder so Confirm sits first. -->
<xpath expr="//header/button[@name='action_confirm' and not(@id)]" position="attributes">
<attribute name="class">btn-primary</attribute>
</xpath>
<xpath expr="//header/button[@id='quotation_send_primary']" position="attributes">
<attribute name="string">Send Email</attribute>
<attribute name="class">btn-danger</attribute>
</xpath>
<xpath expr="//header/button[@id='quotation_send_primary']" position="before">
<xpath expr="//header/button[@name='action_confirm' and not(@id)]" position="move"/>
</xpath>
<!-- Hide standard Delivery button: our Transfers button (below) shows
all stock.picking records - inbound receipts AND outbound deliveries -
which matches the plating workflow better than outbound-only. -->
@@ -307,13 +320,13 @@
<field name="name">sale.order.list.fp</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<list string="Sale Orders" decoration-info="state == 'draft'"
<list string="Sale Orders" create="0" decoration-info="state == 'draft'"
decoration-muted="state == 'cancel'"
decoration-danger="x_fc_is_late_forecast">
<header>
<button name="%(action_fp_direct_order_wizard)d"
type="action"
string="+ New Direct Order"
string="New Order"
class="btn-primary"
display="always"/>
</header>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.10.16.2',
'version': '19.0.10.16.8',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -1540,6 +1540,23 @@ class FpJob(models.Model):
# qty tracking truly doesn't apply).
skip_qty_gate = self.env.context.get('fp_skip_qty_reconcile')
if not skip_qty_gate and job.qty:
# Smooth the typical "clean close" case so the operator
# doesn't have to manually type qty_done = ordered_qty
# every time. Conditions for safe auto-fill:
# - operator has NOT recorded any scrap or done qty
# (so we're not overriding their explicit entry)
# - the receiving closed with matching qty (parts
# physically came in as expected)
# - no visual-inspection rejects recorded
# When any of those fail, fall through to the gate so
# the operator reconciles by hand. Mirrors the receiving
# `_update_job_qty_received` pattern: server fills the
# obvious default, operator owns the edge cases.
if (not job.qty_done and not job.qty_scrapped
and not (job.qty_visual_inspection_rejects or 0)
and job.qty_received
and abs(job.qty_received - job.qty) < 0.0001):
job.qty_done = job.qty
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
if abs(accounted - job.qty) > 0.0001:
raise UserError(_(

View File

@@ -17,7 +17,7 @@
* onSave → /fp/record_inputs/commit → advance step (optional)
*/
import { Component, onWillStart, useState } from "@odoo/owl";
import { Component, markup, onWillStart, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
@@ -106,7 +106,10 @@ export class FpRecordInputsDialog extends Component {
this.state.jobName = data.job.name;
this.state.recipeRootId = data.recipe_root_id || false;
this.state.userInitials = data.user_initials || "";
this.state.instructionsHtml = data.instructions_html || "";
// `t-out` only renders unescaped HTML when the value is a
// `markup()`-tagged string — otherwise it shows literal tags
// (e.g. `<p>foo</p>`). See CLAUDE.md "OWL `t-out` escapes".
this.state.instructionsHtml = markup(data.instructions_html || "");
this.state.instructionImages = data.instruction_images || [];
const nowDt = this._fpNowForDatetimeLocal();
this.state.rows = data.prompts.map((p) => {

View File

@@ -76,12 +76,21 @@
</div>
</xpath>
<!-- 3. Add a Thickness Report tab right next to the -->
<!-- Certificate PDF tab so operator can preview the -->
<!-- Fischerscope file before merging into the cert. -->
<!-- 3. Thickness Report tab — single place to see/edit
every Fischerscope-related field on the cert.
Reorganized 2026-05-21:
* Status + linked QC at the top (read-only context)
* XDAL 600 metadata (operator/product/etc.) editable
so manager can correct OCR mistakes
* Microscope image preview (auto-extracted from RTF
or manually uploaded — either way editable here)
* Source files (PDF / non-PDF evidence / source name)
* Upload wizard button + help text -->
<xpath expr="//notebook/page[@name='pdf']" position="after">
<page string="Thickness Report (Fischerscope)"
name="thickness_pdf">
<!-- Status + QC link (read-only context) -->
<group>
<field name="x_fc_thickness_status" widget="badge"
readonly="1"
@@ -90,12 +99,33 @@
decoration-success="x_fc_thickness_status == 'merged'"/>
<field name="x_fc_thickness_qc_id" readonly="1"
invisible="not x_fc_thickness_qc_id"/>
<field name="x_fc_thickness_pdf_id" readonly="1"
widget="many2one_binary"
invisible="not x_fc_thickness_pdf_id"/>
</group>
<separator string="Upload Fischerscope Report"/>
<div class="oe_button_box">
<!-- Hints rotate by state -->
<div class="text-muted"
invisible="x_fc_thickness_status != 'none'">
<p>
No Fischerscope thickness data has been
uploaded yet. Click <strong>Upload Thickness
Report</strong> below to drop a `.doc` / `.docx`
/ `.rtf` / `.pdf` file straight from the
XDAL&#160;600. The wizard parses readings +
metadata and fills out the fields on this tab.
</p>
</div>
<div class="text-muted"
invisible="x_fc_thickness_status != 'pending'">
<p>
<i class="fa fa-arrow-up"/>
Click <strong>Issue</strong> in the header to
merge the Fischerscope PDF as page&#160;2 of
the CoC. Readings will render inline in the
body of the cert either way.
</p>
</div>
<!-- Upload wizard CTA -->
<div style="margin: 8px 0;">
<button name="%(fusion_plating_certificates.action_fp_thickness_upload_wizard)d"
type="action"
class="btn-primary"
@@ -103,44 +133,65 @@
context="{'default_certificate_id': id}"
invisible="state != 'draft'"/>
</div>
<div class="text-muted">
<p>
Drop the <code>.docx</code> or <code>.pdf</code>
file straight from the Fischerscope&#160;XDAL&#160;600.
The wizard reads the readings, calibration set,
and operator info, lets you review them, and
attaches the original file to this certificate.
</p>
</div>
<separator string="Attached File"
invisible="not x_fc_local_thickness_pdf"/>
<group invisible="not x_fc_local_thickness_pdf">
<field name="x_fc_local_thickness_pdf"
filename="x_fc_local_thickness_pdf_filename"
readonly="1"/>
<field name="x_fc_local_thickness_pdf_filename"
invisible="1"/>
<separator string="XDAL 600 Measurement Context"/>
<p class="text-muted small">
These values are pulled from the uploaded file
and printed on the CoC's thickness section. Edit
any field here to override what the parser saw.
</p>
<group>
<group>
<field name="x_fc_thickness_equipment"
placeholder="Fischerscope XDAL 600"/>
<field name="x_fc_thickness_operator"
placeholder="Operator initials / name"/>
<field name="x_fc_thickness_datetime"/>
<field name="x_fc_thickness_measuring_time_sec"/>
</group>
<group>
<field name="x_fc_thickness_product"
placeholder="e.g. 2805031 / NiP/Al-alloys 2805030"/>
<field name="x_fc_thickness_application"
placeholder="e.g. 16 / NiP/Al-alloys"/>
<field name="x_fc_thickness_directory"
placeholder="XDAL save directory"/>
<field name="x_fc_thickness_source_filename"
readonly="1"/>
</group>
</group>
<separator string="Microscope Image"/>
<p class="text-muted small">
Auto-extracted from RTF uploads (via libwmf) or
manually uploaded via the wizard. Drop a new
PNG/JPEG here to override.
</p>
<group>
<field name="x_fc_thickness_image_id"
options="{'no_create': True}"/>
</group>
<separator string="Source Files"/>
<group>
<group string="Fischerscope PDF"
invisible="not x_fc_local_thickness_pdf">
<field name="x_fc_local_thickness_pdf"
filename="x_fc_local_thickness_pdf_filename"/>
<field name="x_fc_local_thickness_pdf_filename"
invisible="1"/>
</group>
<group string="Non-PDF Evidence (RTF/DOCX)"
invisible="not x_fc_local_thickness_evidence_id">
<field name="x_fc_local_thickness_evidence_id"
options="{'no_create': True}"/>
</group>
<group string="QC-side Fischerscope PDF"
invisible="not x_fc_thickness_pdf_id">
<field name="x_fc_thickness_pdf_id" readonly="1"
widget="many2one_binary"/>
</group>
</group>
<div class="text-muted"
invisible="x_fc_thickness_status != 'none'">
<p>
No Fischerscope thickness PDF has been
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.
</p>
</div>
<div class="text-muted"
invisible="x_fc_thickness_status != 'pending'">
<p>
<i class="fa fa-arrow-up" title="Action"
aria-label="Action"/>
Click <strong>Issue</strong> in the header
and the Fischerscope PDF will be merged into
page&#160;2 of the CoC.
</p>
</div>
</page>
</xpath>

View File

@@ -21,13 +21,26 @@ Issue button on the cert form, which stays as the fallback path.
import base64
import io
import logging
import os
import re
import shutil
import subprocess
import tempfile
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Minimum pixel-area for an extracted RTF image to be treated as the
# "microscope photo" candidate. Filters out narrow header banners
# (~790x203 = 160k pixels) while keeping standard XDAL exports
# (~1024x768 = 786k). See CLAUDE.md "entech apt is broken" for the
# libwmf install path that makes this possible.
_FP_RTF_IMAGE_MIN_AREA = 200_000
# Fischerscope XDAL 600 reading line, e.g.
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
@@ -38,10 +51,206 @@ _FISCHER_READING_RE = re.compile(
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)
# Capture every {\pict ... \wmetafile8 ...hex...} group in an RTF, in
# document order. The hex blob can be interspersed with whitespace
# (RTF wraps to 80 cols) — the consumer strips it.
_RTF_PICT_WMF_RE = re.compile(
r'\{\\pict'
r'(?:\\[a-zA-Z]+-?\d*\s?)*?'
r'\\wmetafile8'
r'(?:\\[a-zA-Z]+-?\d*\s?)*'
r'\s*([0-9a-fA-F\s]+?)'
r'\}',
re.DOTALL,
)
def _fp_extract_rtf_images(raw_bytes):
"""Pull all WMF picture blocks out of an RTF, unpack to PNG via
libwmf, and return the list of PNG bytes in document order.
XDAL 600 RTF exports embed each picture as a WMF metafile wrapping
the actual raster. ImageMagick on Debian Bookworm doesn't carry a
WMF delegate, so we shell out to `wmf2svg` (from libwmf-bin) — it
writes a thin SVG and a side-file `*-N.png` per raster block. We
keep the PNGs, drop the SVG/WMF temp files.
Returns [] (not raise) on any tooling/parse failure; the cert
issue keeps working even when image extraction can't run.
"""
if not raw_bytes:
return []
try:
text = raw_bytes.decode('latin-1', errors='replace')
except Exception:
return []
blobs = []
for m in _RTF_PICT_WMF_RE.finditer(text):
hex_blob = re.sub(r'\s+', '', m.group(1))
try:
blobs.append(bytes.fromhex(hex_blob))
except ValueError:
continue
if not blobs:
return []
tmpdir = tempfile.mkdtemp(prefix='fp_rtf_wmf_')
pngs = []
try:
for i, wmf in enumerate(blobs):
wmf_path = os.path.join(tmpdir, 'pict%d.wmf' % i)
svg_path = os.path.join(tmpdir, 'pict%d.svg' % i)
with open(wmf_path, 'wb') as fh:
fh.write(wmf)
try:
subprocess.run(
['wmf2svg', '-o', svg_path, wmf_path],
capture_output=True, timeout=20, check=False,
)
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
_logger.warning(
'wmf2svg unavailable or timed out (%s) — skipping '
'RTF image extraction.', e,
)
return []
# wmf2svg writes <basename>-N.png next to the SVG.
for fn in sorted(os.listdir(tmpdir)):
if fn.startswith('pict%d-' % i) and fn.endswith('.png'):
full = os.path.join(tmpdir, fn)
with open(full, 'rb') as fh:
pngs.append(fh.read())
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
return pngs
def _fp_pick_microscope_image(png_bytes_list):
"""Pick the largest-area PNG (by pixel count, not file size) from
the list — that's almost always the microscope photo. Header
banners are wide-but-thin so their pixel area falls below the
threshold. Returns (png_bytes, width, height) or (None, 0, 0)
when no PNG meets the threshold.
"""
try:
from PIL import Image
except ImportError:
# Pillow ships with Odoo; this is defensive.
return (png_bytes_list[0] if png_bytes_list else None, 0, 0)
best = None
best_area = 0
for png in png_bytes_list:
try:
with Image.open(io.BytesIO(png)) as im:
area = im.width * im.height
if area > best_area and area >= _FP_RTF_IMAGE_MIN_AREA:
best = (png, im.width, im.height)
best_area = area
except Exception:
continue
return best or (None, 0, 0)
_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+?)(?:\s{2,}|$)', 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]*)')
# XDAL 600 header lines — only present on full RTF reports (not on
# the .docx body the upstream parser already handled).
_FISCHER_PRODUCT_RE = re.compile(r'Product:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
_FISCHER_DIRECTORY_RE = re.compile(r'Directory:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
_FISCHER_APPLICATION_RE = re.compile(r'Application:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
_FISCHER_MTIME_RE = re.compile(r'Measuring\s+time\s+(\d+)\s*sec', re.IGNORECASE)
_FISCHER_EQUIPMENT_RE = re.compile(r'(Fischerscope[^\r\n]*XDAL\s*\d+)', re.IGNORECASE)
def _fp_strip_rtf(raw_bytes):
"""Best-effort RTF → plain text. RTF is text-based with control
words prefixed by `\\` and groups wrapped in `{}`. We need to strip
all of those plus the hex-encoded image data so the Fischerscope
reading regex hits clean text.
Not a full parser — meant for the narrow case of XRF/XDAL reports
that have a simple body wrapped around an embedded WMF image.
"""
if not raw_bytes:
return ''
# RTF is ASCII-safe; latin-1 round-trips every byte.
text = raw_bytes.decode('latin-1', errors='replace')
# Drop destination groups entirely — these are the image data,
# font tables, color tables, etc. The pattern `{\* ...}` and other
# nested destinations carry binary-ish hex strings we never want.
text = re.sub(r'\{\\\*[^{}]*\}', ' ', text)
text = re.sub(r'\{\\fonttbl[^{}]*\}', ' ', text)
text = re.sub(r'\{\\colortbl[^{}]*\}', ' ', text)
# Pictures: {\pict ...} contains hex image data. The body is the
# part between `\pict...goal\d+` and the closing brace of the group.
# Easier: nuke anything matching the picture marker through the
# next closing brace at the same depth (single-level approximation
# — works for FedEx/XRF docs that have one image per pict block).
text = re.sub(r'\{\\pict[^{}]*\}', ' ', text)
# Remove control words like \rtf1, \ansicpg1252, \par, \tab,
# \tx2840, etc. (`\` + letters + optional digits + optional space)
text = re.sub(r'\\[A-Za-z]+-?\d*\s?', ' ', text)
# Hex escapes (e.g. \'ae for special chars)
text = re.sub(r"\\'[0-9a-fA-F]{2}", ' ', text)
# Other backslash escapes (`\\`, `\{`, `\}`)
text = re.sub(r'\\[^A-Za-z\s]', ' ', text)
# Strip remaining braces
text = text.replace('{', ' ').replace('}', ' ')
# Collapse runs of whitespace so the Fischerscope regex doesn't
# have to deal with weird spacing artefacts from the strip pass.
text = re.sub(r'[ \t]+', ' ', text)
return text
def _fp_parse_fischerscope_rtf(raw_bytes):
"""Fischerscope XDAL 600 RTF export → same dict shape as the
.docx parser. RTF detection is by magic bytes (`{\\rtf`) — the
XRF software names the file `.doc` for legacy reasons, but the
contents are RTF.
"""
empty = {
'readings': [], 'calibration': '', 'operator': '',
'date_str': '', 'time_str': '',
'product': '', 'directory': '', 'application': '',
'measuring_time_sec': 0, 'equipment': '',
'raw_text': '',
}
if not raw_bytes:
return empty
text = _fp_strip_rtf(raw_bytes)
readings = []
for m in _FISCHER_READING_RE.finditer(text):
try:
readings.append((
float(m.group(2)),
float(m.group(3)),
float(m.group(4)),
))
except ValueError:
continue
def _grab(rx):
m = rx.search(text)
return m.group(1).strip() if m else ''
mtime = 0
m = _FISCHER_MTIME_RE.search(text)
if m:
try:
mtime = int(m.group(1))
except ValueError:
mtime = 0
return {
'readings': readings,
'calibration': _grab(_FISCHER_CALIB_RE),
'operator': _grab(_FISCHER_OPERATOR_RE),
'date_str': _grab(_FISCHER_DATE_RE),
'time_str': _grab(_FISCHER_TIME_RE),
'product': _grab(_FISCHER_PRODUCT_RE),
'directory': _grab(_FISCHER_DIRECTORY_RE),
'application': _grab(_FISCHER_APPLICATION_RE),
'measuring_time_sec': mtime,
'equipment': _grab(_FISCHER_EQUIPMENT_RE),
'raw_text': text,
}
def _fp_parse_fischerscope_docx(raw_bytes):
@@ -227,6 +436,14 @@ class FpCertIssueWizardLine(models.TransientModel):
)
fischer_file = fields.Binary(string='Fischerscope File (PDF or .docx)')
fischer_filename = fields.Char(string='Filename')
# Optional: microscope/coupon image exported separately from the
# XDAL 600. The RTF carries an embedded WMF that the entech host
# can't rasterize (no imagemagick/libwmf — see CLAUDE.md "entech
# apt is in a broken-deps state"), so the operator exports a PNG
# from the XDAL software and uploads it here. Rendered inline on
# the CoC's thickness section when present.
fischer_image_file = fields.Binary(string='Measurement Image (PNG/JPEG)')
fischer_image_filename = fields.Char(string='Image Filename')
parsed_summary = fields.Text(
string='Parsed Summary', readonly=True,
help='Output of the .docx parser. Populated when you attach a '
@@ -274,22 +491,29 @@ class FpCertIssueWizardLine(models.TransientModel):
@api.onchange('fischer_file', 'fischer_filename')
def _onchange_fischer_file(self):
"""Try to parse .docx on upload; prefill the readings + summary."""
"""Parse .docx OR RTF on upload (XDAL 600 names RTF files
`.doc` — detected by magic bytes; see CLAUDE.md "Fischerscope
XDAL 600 `.doc` files are actually RTF"). Prefill the readings
+ summary so the operator can verify before issuing."""
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)
name = (self.fischer_filename or '').lower()
is_rtf = raw[:5] == b'{\\rtf' or name.endswith('.rtf')
if is_rtf:
parsed = _fp_parse_fischerscope_rtf(raw)
elif name.endswith('.docx'):
parsed = _fp_parse_fischerscope_docx(raw)
else:
self.parsed_summary = _(
'Non-parseable upload (%s) — file will be attached as '
'evidence. Type readings manually below if needed.'
) % (self.fischer_filename or 'unnamed')
return
readings = parsed.get('readings') or []
if readings:
self.reading_line_ids = [(5, 0, 0)] + [
@@ -312,15 +536,70 @@ class FpCertIssueWizardLine(models.TransientModel):
't': parsed.get('time_str') or '',
}
def _write_thickness_metadata_to_cert(self, cert, parsed):
"""Persist the Fischerscope header block (operator, product,
application, equipment, measuring time, date/time, source
filename) onto the cert so the CoC report can render a full
report block instead of a bare readings table.
"""
vals = {}
field_map = (
('x_fc_thickness_operator', parsed.get('operator')),
('x_fc_thickness_product', parsed.get('product')),
('x_fc_thickness_directory', parsed.get('directory')),
('x_fc_thickness_application', parsed.get('application')),
('x_fc_thickness_measuring_time_sec',
parsed.get('measuring_time_sec') or 0),
('x_fc_thickness_equipment',
parsed.get('equipment') or 'Fischerscope XDAL 600'),
('x_fc_thickness_source_filename',
self.fischer_filename or ''),
)
for fname, fval in field_map:
if fname in cert._fields and fval:
vals[fname] = fval
# Combine the gauge's date+time and parse to Datetime — try a
# few formats since XDAL exports vary (12h vs 24h, with/without
# seconds). Best-effort: leave the field blank if no format
# matches rather than crashing the cert issue.
date_str = (parsed.get('date_str') or '').strip()
time_str = (parsed.get('time_str') or '').strip()
if date_str and 'x_fc_thickness_datetime' in cert._fields:
from datetime import datetime
combined = ('%s %s' % (date_str, time_str)).strip()
for fmt in (
'%m/%d/%Y %I:%M:%S %p', '%m/%d/%Y %I:%M %p',
'%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M',
'%m/%d/%Y',
):
try:
vals['x_fc_thickness_datetime'] = datetime.strptime(
combined, fmt,
)
break
except ValueError:
continue
if vals:
cert.write(vals)
def _apply_to_cert(self):
"""Write this line's data into the cert."""
"""Write this line's data into the cert.
Order matters: operator-uploaded PNG must run LAST so it wins
over any image the RTF auto-extraction picked. Reverse order
(PNG first, then RTF) lets the WMF blow away the explicit
operator choice — exactly the bug we just hit.
"""
self.ensure_one()
cert = self.cert_id.sudo()
if not self.fischer_file:
# Just push manual readings, if any.
self._push_readings_to_cert()
# PNG-only path: still attach the operator's image upload.
self._apply_image_to_cert(cert)
return
name = (self.fischer_filename or 'fischerscope').lower()
calibration = '' # backfilled below if the parser hits
if name.endswith('.pdf'):
# Drop the PDF into the cert-local field — merges into page 2.
cert.write({
@@ -328,23 +607,107 @@ class FpCertIssueWizardLine(models.TransientModel):
'x_fc_local_thickness_pdf_filename': self.fischer_filename,
})
else:
# .doc / .docx / anything else — attach as evidence.
self.env['ir.attachment'].sudo().create({
# .doc / .docx / anything else — attach as evidence AND
# link the attachment to the cert's evidence slot so the
# thickness-required gate recognises it. Without the link,
# the gate would still raise (it checks specific fields,
# not stray attachments) and rolling back the transaction
# would orphan the upload.
att = 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=_(
if 'x_fc_local_thickness_evidence_id' in cert._fields:
cert.write({'x_fc_local_thickness_evidence_id': att.id})
# Re-parse the file at apply time so the report-header
# metadata (operator, product, application, etc.) makes it
# onto the cert. Onchange populates reading_line_ids but
# not the cert-level fields. Best-effort: any parse hiccup
# is logged and we still complete the attachment + readings.
try:
raw = base64.b64decode(self.fischer_file)
is_rtf = raw[:5] == b'{\\rtf'
if is_rtf:
parsed = _fp_parse_fischerscope_rtf(raw)
elif name.endswith('.docx'):
parsed = _fp_parse_fischerscope_docx(raw)
else:
parsed = None
if parsed:
self._write_thickness_metadata_to_cert(cert, parsed)
calibration = parsed.get('calibration') or ''
# WMF image extraction is RTF-only (the .docx path
# uses python-docx which already gives PIL-readable
# bitmaps; that flow can be added later if needed).
if is_rtf and 'x_fc_thickness_image_id' in cert._fields:
pngs = _fp_extract_rtf_images(raw)
img_bytes, img_w, img_h = _fp_pick_microscope_image(pngs)
if img_bytes:
img_att = self.env['ir.attachment'].sudo().create({
'name': '%s-microscope.png' % (
(self.fischer_filename or 'fischerscope')
.rsplit('.', 1)[0]
),
'type': 'binary',
'datas': base64.b64encode(img_bytes),
'mimetype': 'image/png',
'res_model': 'fp.certificate',
'res_id': cert.id,
})
cert.write({
'x_fc_thickness_image_id': img_att.id,
})
_logger.info(
'Cert %s: attached microscope image '
'(%dx%d, %d bytes)',
cert.name, img_w, img_h, len(img_bytes),
)
except Exception as exc:
_logger.warning(
'Cert %s: Fischerscope metadata extraction failed: %s',
cert.name, exc,
)
cert.message_post(body=Markup(_(
'Fischerscope file <b>%s</b> attached via Issue wizard.'
) % (self.fischer_filename or 'unnamed'))
self._push_readings_to_cert()
)) % (self.fischer_filename or 'unnamed'))
self._push_readings_to_cert(calibration=calibration)
# Operator's PNG upload wins over auto-extracted WMF — runs
# last so it overwrites x_fc_thickness_image_id if both paths
# supplied an image.
self._apply_image_to_cert(cert)
def _push_readings_to_cert(self):
def _apply_image_to_cert(self, cert):
"""Attach the operator-uploaded PNG/JPEG and link it to the
cert's image slot so the CoC report can render it inline.
No-op when nothing was uploaded. Mirrors the evidence-file
pattern: file is attached as a regular ir.attachment AND
linked to the dedicated field so the report template can
find it predictably.
"""
self.ensure_one()
if not self.fischer_image_file or \
'x_fc_thickness_image_id' not in cert._fields:
return
att = self.env['ir.attachment'].sudo().create({
'name': self.fischer_image_filename or 'thickness-image.png',
'type': 'binary',
'datas': self.fischer_image_file,
'res_model': 'fp.certificate',
'res_id': cert.id,
})
cert.write({'x_fc_thickness_image_id': att.id})
def _push_readings_to_cert(self, calibration=''):
"""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."""
readings — the manager has just told us this is the new data.
Per-reading calibration_std_ref is stamped from the optional
`calibration` arg so the printed CoC's calibration line stays
accurate even when readings are re-pushed from a fresh upload.
"""
self.ensure_one()
Reading = self.env.get('fp.thickness.reading')
if Reading is None or not self.reading_line_ids:
@@ -358,6 +721,8 @@ class FpCertIssueWizardLine(models.TransientModel):
}
if 'reading_number' in Reading._fields:
vals['reading_number'] = r.sequence
if calibration and 'calibration_std_ref' in Reading._fields:
vals['calibration_std_ref'] = calibration
Reading.sudo().create(vals)

View File

@@ -93,6 +93,23 @@
<field name="fischer_filename"
invisible="1"/>
</group>
<group string="Measurement Image (Optional)"
invisible="not needs_thickness">
<field name="fischer_image_file"
filename="fischer_image_filename"
widget="image"
options="{'size': [200, 200]}"/>
<field name="fischer_image_filename"
invisible="1"/>
<div colspan="2" class="text-muted small">
Drop a PNG/JPEG of the coupon
under the XRF probe (export
from the XDAL 600 software's
Image menu). Rendered inline on
the printed CoC so the customer
sees the actual measurement.
</div>
</group>
<div class="alert alert-info"
role="alert"
invisible="not needs_thickness or not parsed_summary">

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
@@ -186,9 +188,9 @@ class FpDelivery(models.Model):
}
shipment = self.env['fusion.shipment'].sudo().create(vals)
self.x_fc_outbound_shipment_id = shipment.id
self.message_post(body=_(
self.message_post(body=Markup(_(
'Outbound shipment <b>%s</b> created (draft).'
) % shipment.name)
)) % shipment.name)
return self.action_view_outbound_shipment()
def action_view_outbound_shipment(self):

View File

@@ -16,7 +16,7 @@
<record id="fp_mail_template_quote_sent" model="mail.template">
<field name="name">FP: Quotation Sent</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">Quotation {{ object.name }} — EN Technologies</field>
<field name="subject">Quotation {{ object.name }} — Electroless Nickel Technologies Inc. (ENTECH)</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="auto_delete" eval="True"/>
@@ -24,7 +24,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Quotation Ready</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -54,16 +54,15 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>
<field name="report_template_ids"
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
<field name="report_name">Quotation_{{ (object.name or '').replace('/','_') }}</field>
</record>
<!-- ============================================================= -->
@@ -80,7 +79,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Order Confirmed</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -114,16 +113,15 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>
<field name="report_template_ids"
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
<field name="report_name">SalesOrder_{{ (object.name or '').replace('/','_') }}</field>
</record>
<!-- ============================================================= -->
@@ -140,7 +138,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Parts Received</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -170,10 +168,10 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>
@@ -201,7 +199,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Order Is Being Prepared for Shipment</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -239,10 +237,10 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>
@@ -262,7 +260,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Parts Have Shipped</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -296,10 +294,10 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>
@@ -311,7 +309,7 @@
<record id="fp_mail_template_invoice_posted" model="mail.template">
<field name="name">FP: Invoice Notification</field>
<field name="model_id" ref="account.model_account_move"/>
<field name="subject">Invoice {{ object.name }} — EN Technologies</field>
<field name="subject">Invoice {{ object.name }} — Electroless Nickel Technologies Inc. (ENTECH)</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="auto_delete" eval="True"/>
@@ -319,7 +317,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Invoice Ready</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -357,16 +355,15 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>
<field name="report_template_ids"
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_invoice_portrait')])]"/>
<field name="report_name">Invoice_{{ (object.name or '').replace('/','_') }}</field>
</record>
<!-- ============================================================= -->
@@ -383,7 +380,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Payment Received — Thank You</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -417,10 +414,10 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
'version': '19.0.3.20.0',
'version': '19.0.3.25.0',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """
@@ -46,7 +46,20 @@ Provides:
'views/fp_receiving_menu.xml',
'views/fusion_shipment_inherit_views.xml',
'wizards/fp_label_manual_wizard_views.xml',
'wizards/fp_label_generate_wizard_views.xml',
],
'assets': {
# Theme-aware shipping-quote callout. Registered in BOTH
# bundles so the dark-mode compile picks up the @if branch
# (see CLAUDE.md "Dark Mode" — no runtime DOM toggle in
# Odoo 19).
'web.assets_backend': [
'fusion_plating_receiving/static/src/scss/fp_shipping_quote.scss',
],
'web.assets_web_dark': [
'fusion_plating_receiving/static/src/scss/fp_shipping_quote.scss',
],
},
'installable': True,
'application': False,
'auto_install': False,

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

View File

@@ -17,6 +17,9 @@ access_fp_racking_inspection_line_manager,fp.racking.inspection.line.manager,mod
access_fp_label_manual_wizard_receiver,fp.label.manual.wizard.receiver,model_fp_label_manual_wizard,group_fp_receiving,1,1,1,1
access_fp_label_manual_wizard_supervisor,fp.label.manual.wizard.supervisor,model_fp_label_manual_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_label_manual_wizard_manager,fp.label.manual.wizard.manager,model_fp_label_manual_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_label_generate_wizard_receiver,fp.label.generate.wizard.receiver,model_fp_label_generate_wizard,group_fp_receiving,1,1,1,1
access_fp_label_generate_wizard_supervisor,fp.label.generate.wizard.supervisor,model_fp_label_generate_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_label_generate_wizard_manager,fp.label.generate.wizard.manager,model_fp_label_generate_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_outbound_package_receiver,fp.outbound.package.receiver,model_fp_outbound_package,group_fp_receiving,1,1,1,1
access_fp_outbound_package_supervisor,fp.outbound.package.supervisor,model_fp_outbound_package,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_outbound_package_manager,fp.outbound.package.manager,model_fp_outbound_package,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
17 access_fp_label_manual_wizard_receiver fp.label.manual.wizard.receiver model_fp_label_manual_wizard group_fp_receiving 1 1 1 1
18 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
19 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
20 access_fp_label_generate_wizard_receiver fp.label.generate.wizard.receiver model_fp_label_generate_wizard group_fp_receiving 1 1 1 1
21 access_fp_label_generate_wizard_supervisor fp.label.generate.wizard.supervisor model_fp_label_generate_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 1
22 access_fp_label_generate_wizard_manager fp.label.generate.wizard.manager model_fp_label_generate_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
23 access_fp_outbound_package_receiver fp.outbound.package.receiver model_fp_outbound_package group_fp_receiving 1 1 1 1
24 access_fp_outbound_package_supervisor fp.outbound.package.supervisor model_fp_outbound_package fusion_plating.group_fusion_plating_supervisor 1 1 1 1
25 access_fp_outbound_package_manager fp.outbound.package.manager model_fp_outbound_package fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,78 @@
// =============================================================================
// Fusion Plating — Shipping-Quote Callout Panel
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Yellow-tinted info panel rendered on the right side of the receiving
// form. Branches on Odoo's compile-time $o-webclient-color-scheme so it
// produces readable contrast in BOTH light (warm cornsilk) and dark
// (deep amber) bundles. See CLAUDE.md "Dark Mode" for why we branch at
// compile time instead of using a runtime class selector — Odoo 19
// serves two pre-compiled bundles, no .o_dark_mode toggle fires.
// =============================================================================
$o-webclient-color-scheme: bright !default;
// Light (cornsilk on white page)
$_fp-quote-bg-hex : #fff8dc;
$_fp-quote-border-hex : #e6d28f;
$_fp-quote-label-hex : #7a5b00;
$_fp-quote-body-hex : #1f2937;
$_fp-quote-muted-hex : #6b6452;
// Dark (warm-amber tint that still reads against $fp-page)
@if $o-webclient-color-scheme == dark {
$_fp-quote-bg-hex : #3a2f10 !global;
$_fp-quote-border-hex : #5a4820 !global;
$_fp-quote-label-hex : #ffd866 !global;
$_fp-quote-body-hex : #e5e7eb !global;
$_fp-quote-muted-hex : #b8a877 !global;
}
// CSS custom-property fallback chain so a deployment can override
// without touching SCSS.
$fp-quote-bg : var(--fp-quote-bg, $_fp-quote-bg-hex);
$fp-quote-border : var(--fp-quote-border, $_fp-quote-border-hex);
$fp-quote-label : var(--fp-quote-label, $_fp-quote-label-hex);
$fp-quote-body : var(--fp-quote-body, $_fp-quote-body-hex);
$fp-quote-muted : var(--fp-quote-muted, $_fp-quote-muted-hex);
.fp_shipping_quote_callout {
background-color: $fp-quote-bg;
border: 1px solid $fp-quote-border;
border-radius: 6px;
padding: 14px 16px;
color: $fp-quote-body;
.fp_shipping_quote_header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
.fp_shipping_quote_label {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 700;
color: $fp-quote-label;
i.fa {
margin-right: 8px;
}
}
}
// Quote body — the HTML field rendered by _fp_format_shipping_quote.
// Inherits the body colour from the wrapper; the .text-muted-style
// small-print uses our muted token.
.o_field_html,
.o_field_html * {
color: inherit;
}
.fp_shipping_quote_placeholder {
color: $fp-quote-muted;
font-size: 13px;
}
}

View File

@@ -51,10 +51,16 @@
class="btn-primary"
invisible="state not in ('draft', 'inspecting')"/>
<button name="action_close"
string="Close — Racking Confirmed"
string="Close Receiving"
type="object"
class="btn-primary"
invisible="state not in ('counted', 'staged', 'accepted', 'resolved')"/>
<button name="action_reset_to_counted"
string="Reset to Counted"
type="object"
class="btn-secondary"
confirm="Reset this receiving back to Counted? Use only if no work has started on the order."
invisible="state != 'closed'"/>
<!-- Legacy actions (hidden by default; surfaces for old records) -->
<button name="action_accept"
string="Accept (legacy)"
@@ -77,7 +83,7 @@
string="Generate Outbound Label"
class="btn-primary"
icon="fa-print"
invisible="not x_fc_carrier_id or not x_fc_weight"/>
invisible="not x_fc_carrier_id or not x_fc_weight or x_fc_has_label"/>
<button name="action_print_label"
type="object"
string="Print Label"
@@ -108,6 +114,17 @@
</div>
<field name="x_fc_has_label" invisible="1"/>
</button>
<button name="action_print_label_zpl"
type="object"
class="oe_stat_button"
icon="fa-barcode"
invisible="not x_fc_has_label_zpl">
<div class="o_stat_info">
<span class="o_stat_value">ZPL</span>
<span class="o_stat_text">Print Label</span>
</div>
<field name="x_fc_has_label_zpl" invisible="1"/>
</button>
</div>
<div class="alert alert-info" role="alert">
<i class="fa fa-info-circle me-2"/>
@@ -139,6 +156,9 @@
<field name="received_date"/>
<field name="x_fc_carrier_id"
options="{'no_create': True}"/>
<field name="x_fc_outbound_service_type"
invisible="not x_fc_carrier_id"
placeholder="Carrier default"/>
<field name="carrier_tracking"/>
<!--
Legacy carrier_name (Char) is retained
@@ -154,6 +174,35 @@
-->
<field name="carrier_name" invisible="1"/>
</group>
<!-- Shipping-quote preview. The .fp_shipping_quote_callout
class in fp_shipping_quote.scss handles
colour for both light + dark bundles —
yellow tint that flips to deep amber on
dark theme. Structure-only styling stays
inline; semantic colour lives in SCSS. -->
<div invisible="not x_fc_carrier_id"
class="fp_shipping_quote_callout">
<div class="fp_shipping_quote_header">
<strong class="fp_shipping_quote_label">
<i class="fa fa-truck"/>Shipping Quote
</strong>
<button name="action_refresh_shipping_quote"
type="object"
string="Refresh Quote"
class="btn btn-sm btn-warning"
icon="fa-refresh"/>
</div>
<field name="x_fc_shipping_quote_html"
nolabel="1" readonly="1"
widget="html"/>
<div invisible="x_fc_shipping_quote_html"
class="fp_shipping_quote_placeholder">
Click <strong>Refresh Quote</strong> to
fetch the price and estimated delivery
date for the current carrier + service
+ weight.
</div>
</div>
</group>
<group string="Outbound Packaging"
invisible="not x_fc_carrier_id">

View File

@@ -27,6 +27,22 @@
</list>
</field>
</xpath>
<!-- Mirror the receiving form's two-button layout: the
existing "Print Label PDF" smart button (rendered by
fusion_shipping) handles the primary PDF; this adds a
sibling ZPL button only when a ZPL attachment exists. -->
<xpath expr="//div[@name='button_box']/button[@name='action_view_label']" position="after">
<button name="action_view_label_zpl" type="object"
class="oe_stat_button" icon="fa-barcode"
invisible="not x_fc_label_zpl_attachment_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">ZPL</span>
<span class="o_stat_text">Print Label</span>
</div>
<field name="x_fc_label_zpl_attachment_id" invisible="1"/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import fp_label_manual_wizard
from . import fp_label_generate_wizard

View File

@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Service-type confirmation wizard.
Opens when fp.receiving.action_generate_outbound_label fires (assuming
a label hasn't already been generated). Forces the operator to look at
carrier + service tier + weight before the API call is made — cheaper
shipping decisions, no surprise charges, and a hard gate against the
accidental-double-click bug where every Generate click leaked a fresh
FedEx shipment + tracking number.
Picked service rides through to the FedEx API via the
`fp_service_type_override` context key (see CLAUDE.md).
"""
from odoo import _, fields, models
from odoo.exceptions import UserError
class FpLabelGenerateWizard(models.TransientModel):
_name = 'fp.label.generate.wizard'
_description = 'Fusion Plating — Confirm Label Generation'
receiving_id = fields.Many2one(
'fp.receiving', required=True, readonly=True, ondelete='cascade',
)
receiving_name = fields.Char(related='receiving_id.name', readonly=True)
carrier_id = fields.Many2one(
related='receiving_id.x_fc_carrier_id', readonly=True,
)
carrier_default_service = fields.Char(
compute='_compute_carrier_default_service',
string='Carrier Default',
help='What the carrier would use if no override is set.',
)
service_type = fields.Selection(
selection='_fp_get_service_type_selection',
string='Service Type',
help='Leave blank to use the carrier default shown above. Pick '
'a faster tier (Priority Overnight, 2Day, etc.) when the '
'customer is paying for expedited delivery.',
)
weight = fields.Float(string='Weight', digits=(10, 3), required=True)
weight_uom = fields.Selection(
related='receiving_id.x_fc_weight_uom', readonly=True,
)
length = fields.Float(string='Length', digits=(10, 2))
width = fields.Float(string='Width', digits=(10, 2))
height = fields.Float(string='Height', digits=(10, 2))
dim_uom = fields.Selection(
related='receiving_id.x_fc_dim_uom', readonly=True,
)
def _fp_get_service_type_selection(self):
# Single source of truth — pulls the curated list from
# fp.receiving so both the form dropdown and the wizard stay
# in sync. See fp.receiving._FP_USABLE_FEDEX_SERVICES.
Receiving = self.env.get('fp.receiving')
if Receiving is None:
return []
return Receiving._fp_get_service_type_selection()
def _compute_carrier_default_service(self):
for wiz in self:
carrier = wiz.carrier_id
if not carrier or 'fedex_rest_service_type' not in carrier._fields:
wiz.carrier_default_service = ''
continue
code = carrier.fedex_rest_service_type or ''
label = dict(
carrier._fields['fedex_rest_service_type'].selection
).get(code, code)
wiz.carrier_default_service = label or _('(none set)')
@classmethod
def _fp_default_from_receiving(cls, env, rec):
"""Build the wizard create-vals from a receiving record."""
return {
'receiving_id': rec.id,
'service_type': rec.x_fc_outbound_service_type or False,
'weight': rec.x_fc_weight or 0.0,
'length': rec.x_fc_length or 0.0,
'width': rec.x_fc_width or 0.0,
'height': rec.x_fc_height or 0.0,
}
def action_confirm(self):
"""Apply the operator's choices back to the receiving, then
delegate to fp.receiving._fp_actually_generate_outbound_label
which makes the API call. Wizard closes; result action is the
outbound shipment view (or the manual-fallback wizard on error).
"""
self.ensure_one()
rec = self.receiving_id
if not rec:
raise UserError(_('Wizard is detached from the receiving.'))
if not self.weight or self.weight <= 0:
raise UserError(_('Enter a non-zero weight before generating.'))
rec.write({
'x_fc_outbound_service_type': self.service_type or False,
'x_fc_weight': self.weight,
'x_fc_length': self.length,
'x_fc_width': self.width,
'x_fc_height': self.height,
})
return rec._fp_actually_generate_outbound_label()

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Service-type confirmation wizard. Pops up before every
"Generate Outbound Label" so the operator can pick the FedEx
service tier (or accept the carrier default) and double-check
weight/dimensions before any API call is made.
-->
<odoo>
<record id="view_fp_label_generate_wizard_form" model="ir.ui.view">
<field name="name">fp.label.generate.wizard.form</field>
<field name="model">fp.label.generate.wizard</field>
<field name="arch" type="xml">
<form string="Generate Outbound Label">
<sheet>
<div class="oe_title">
<h2>Generate Label —
<field name="receiving_name"
readonly="1" nolabel="1" class="oe_inline"/>
</h2>
</div>
<div class="alert alert-info" role="alert">
<i class="fa fa-info-circle me-2"/>
Confirm the service tier and package details before
the carrier API is called. Each generation creates
a billable shipment.
</div>
<group>
<group string="Carrier">
<field name="carrier_id" readonly="1"/>
<field name="carrier_default_service"
readonly="1"/>
<field name="service_type"
placeholder="Use carrier default"/>
</group>
<group string="Package">
<label for="weight"/>
<div class="o_row">
<field name="weight" class="oe_inline"/>
<field name="weight_uom" nolabel="1"
readonly="1" class="oe_inline"/>
</div>
<label for="length" string="Dimensions (L×W×H)"/>
<div class="o_row">
<field name="length" class="oe_inline"/>
<span>×</span>
<field name="width" class="oe_inline"/>
<span>×</span>
<field name="height" class="oe_inline"/>
<field name="dim_uom" nolabel="1"
readonly="1" class="oe_inline"/>
</div>
</group>
</group>
</sheet>
<footer>
<button name="action_confirm" type="object"
string="Generate Label" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.11.15.0',
'version': '19.0.11.24.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -38,8 +38,18 @@
<t t-set="signer_name" t-value="(signer_user and signer_user.name) or ''"/>
<style>
/* padding-top history: original 50mm wasted too much
page-1 space; dropped to 5mm caused the title to
overlap the ENTECH header (the rendered header is
taller than paperformat margin_top reserves). 20mm
is the middle ground — title sits cleanly below the
header, still saves ~30mm vs the original 50mm so the
signature block fits on page 1. If the header logo /
address changes height, bump this in step with
paperformat.margin_top. See CLAUDE.md "wkhtmltopdf
header overlap". */
.fp-coc { font-family: Arial, sans-serif; font-size: 9pt; color: #000;
padding-top: 50mm; }
padding-top: 20mm; }
.fp-coc h1 { text-align: center; font-size: 20pt; margin: 0 0 10px 0; font-weight: bold; }
.fp-coc hr.heavy { border: 0; border-top: 2px solid #000; margin: 6px 0; }
.fp-coc table { width: 100%; border-collapse: collapse; margin-bottom: 6px; }
@@ -72,6 +82,36 @@
.fp-coc .small-label { font-size: 7.5pt; opacity: 0.7; }
.fp-coc .brand-note { font-size: 7.5pt; color: #888; text-align: center;
margin-top: 10px; font-style: italic; }
/* Thickness block — single outer border, internal-only
cell dividers so the title / metadata / image+readings
look like one connected section. No nested .bordered
class on inner tables; each cell explicitly draws the
internal divider it needs. */
.fp-coc .fp-thickness-block { border: 1px solid #000; margin-top: 14px; }
.fp-coc .fp-thickness-block table { width: 100%; border-collapse: collapse;
margin: 0; }
.fp-coc .fp-thickness-block td,
.fp-coc .fp-thickness-block th { padding: 5px 8px; vertical-align: top;
font-size: 8.5pt; }
.fp-coc .fp-thickness-block .ftk-title {
background-color: #ededed; text-align: center; font-weight: bold;
font-size: 11pt; padding: 6px; border-bottom: 1px solid #000; }
.fp-coc .fp-thickness-block .ftk-label { background-color: #f7f7f7;
font-weight: bold; }
.fp-coc .fp-thickness-block .ftk-row-divider { border-bottom: 1px solid #000; }
.fp-coc .fp-thickness-block .ftk-cell-divider { border-right: 1px solid #000; }
.fp-coc .fp-thickness-block .ftk-img-cell {
text-align: center; vertical-align: middle; padding: 6px;
border-right: 1px solid #000; }
.fp-coc .fp-thickness-block .ftk-img-cell img { max-width: 100%; max-height: 10cm; }
.fp-coc .fp-thickness-block .ftk-readings-cell { padding: 0; vertical-align: top; }
.fp-coc .fp-thickness-block .ftk-readings th {
background-color: #ededed; text-align: center; font-weight: bold;
border-bottom: 1px solid #000; }
.fp-coc .fp-thickness-block .ftk-readings td { text-align: center; }
.fp-coc .fp-thickness-block .ftk-readings .ftk-stat-mean {
background-color: #ededed; font-weight: bold; }
.fp-coc .fp-thickness-block .ftk-readings .ftk-stat { background-color: #f7f7f7; }
</style>
<div class="fp-coc">
@@ -193,12 +233,13 @@
</tbody>
</table>
<!-- Quantities / line item table -->
<div class="text-end small-label" style="margin-top: 8px;">
<strong t-if="not is_fr">Quantities</strong>
<strong t-if="is_fr">Quantités</strong>
</div>
<table class="bordered">
<!-- Line-item table — the column headers already speak
for themselves (Shipped / NC Qty / etc.), so the
hovering "Quantities" caption above was just visual
noise. Removed 2026-05-21. Header row + body row
stay together (page-break-inside on tr per CLAUDE.md). -->
<table class="bordered" style="margin-top: 8px;
page-break-inside: avoid;">
<thead>
<tr>
<th style="width: 20%;">
@@ -244,9 +285,225 @@
</tbody>
</table>
<!-- Signature + certification statement -->
<!-- Thickness readings (Fischerscope XRF) — full report
block mirroring the original XDAL 600 export so the
customer/auditor sees the same context the gauge
produced: equipment, operator, calibration, product,
application, measuring time, all readings, plus
derived stats (mean, std dev, CoV, range, n). When
the source upload was an RTF/.docx, this replaces
the page-2 PDF merge. -->
<t t-if="doc.thickness_reading_ids">
<t t-set="readings" t-value="doc.thickness_reading_ids.sorted('reading_number')"/>
<t t-set="calib" t-value="(doc.x_fc_thickness_calibration if 'x_fc_thickness_calibration' in doc._fields else '') or (readings and readings[0].calibration_std_ref or '')"/>
<t t-set="n" t-value="len(readings)"/>
<t t-set="nip_vals" t-value="[r.nip_mils for r in readings if r.nip_mils]"/>
<t t-set="ni_vals" t-value="[r.ni_percent for r in readings if r.ni_percent]"/>
<t t-set="p_vals" t-value="[r.p_percent for r in readings if r.p_percent]"/>
<!-- Stats computed in the template so the wizard
parser doesn't have to handle two number formats
(XDAL exports `0.5857` and `92.727` etc; we
recompute from the source readings to guarantee
the printed report agrees with the source data). -->
<t t-set="nip_mean" t-value="(sum(nip_vals) / len(nip_vals)) if nip_vals else 0"/>
<t t-set="ni_mean" t-value="(sum(ni_vals) / len(ni_vals)) if ni_vals else 0"/>
<t t-set="p_mean" t-value="(sum(p_vals) / len(p_vals)) if p_vals else 0"/>
<t t-set="nip_std" t-value="((sum((v - nip_mean) ** 2 for v in nip_vals) / (len(nip_vals) - 1)) ** 0.5) if len(nip_vals) > 1 else 0"/>
<t t-set="ni_std" t-value="((sum((v - ni_mean) ** 2 for v in ni_vals) / (len(ni_vals) - 1)) ** 0.5) if len(ni_vals) > 1 else 0"/>
<t t-set="p_std" t-value="((sum((v - p_mean) ** 2 for v in p_vals) / (len(p_vals) - 1)) ** 0.5) if len(p_vals) > 1 else 0"/>
<t t-set="nip_cov" t-value="(nip_std / nip_mean * 100) if nip_mean else 0"/>
<t t-set="ni_cov" t-value="(ni_std / ni_mean * 100) if ni_mean else 0"/>
<t t-set="p_cov" t-value="(p_std / p_mean * 100) if p_mean else 0"/>
<t t-set="nip_range" t-value="(max(nip_vals) - min(nip_vals)) if nip_vals else 0"/>
<t t-set="ni_range" t-value="(max(ni_vals) - min(ni_vals)) if ni_vals else 0"/>
<t t-set="p_range" t-value="(max(p_vals) - min(p_vals)) if p_vals else 0"/>
<!-- Whole block stays together when it fits; wraps
to a fresh page if it doesn't. Prevents the
wkhtmltopdf company header from overlapping the
readings table mid-row on page 2. -->
<div class="fp-thickness-block">
<!-- Section header — full-width bar, drawn by the
div's bottom-border, no internal table needed. -->
<div class="ftk-title">
<t t-if="not is_fr">Fischerscope XRF Thickness Report</t>
<t t-if="is_fr">Rapport d'épaisseur Fischerscope XRF</t>
</div>
<!-- Equipment metadata — 4-column key/value grid.
Per-cell border-right + per-row border-bottom
draw the internal grid; the outer perimeter
comes from .fp-thickness-block's border. Last
row + last column omit their dividers so we
don't double up against the parent border. -->
<table>
<tr class="ftk-row-divider">
<td class="ftk-label ftk-cell-divider" style="width: 18%;">
<t t-if="not is_fr">Equipment</t>
<t t-if="is_fr">Équipement</t>
</td>
<td class="ftk-cell-divider" style="width: 32%;">
<t t-esc="doc.x_fc_thickness_equipment or '—'"/>
</td>
<td class="ftk-label ftk-cell-divider" style="width: 18%;">
<t t-if="not is_fr">Calibration Std.</t>
<t t-if="is_fr">Étalon</t>
</td>
<td style="width: 32%;"><t t-esc="calib or '—'"/></td>
</tr>
<tr class="ftk-row-divider">
<td class="ftk-label ftk-cell-divider">
<t t-if="not is_fr">Product</t>
<t t-if="is_fr">Produit</t>
</td>
<td class="ftk-cell-divider"><t t-esc="doc.x_fc_thickness_product or '—'"/></td>
<td class="ftk-label ftk-cell-divider">
<t t-if="not is_fr">Operator</t>
<t t-if="is_fr">Opérateur</t>
</td>
<td><t t-esc="doc.x_fc_thickness_operator or '—'"/></td>
</tr>
<tr class="ftk-row-divider">
<td class="ftk-label ftk-cell-divider">
<t t-if="not is_fr">Application</t>
<t t-if="is_fr">Application</t>
</td>
<td class="ftk-cell-divider"><t t-esc="doc.x_fc_thickness_application or '—'"/></td>
<td class="ftk-label ftk-cell-divider">
<t t-if="not is_fr">Measured</t>
<t t-if="is_fr">Mesuré le</t>
</td>
<td>
<t t-if="doc.x_fc_thickness_datetime"
t-esc="doc.x_fc_thickness_datetime.strftime('%Y-%m-%d %H:%M')"/>
<t t-if="not doc.x_fc_thickness_datetime"></t>
</td>
</tr>
<tr class="ftk-row-divider">
<td class="ftk-label ftk-cell-divider">
<t t-if="not is_fr">Directory</t>
<t t-if="is_fr">Répertoire</t>
</td>
<td class="ftk-cell-divider"><t t-esc="doc.x_fc_thickness_directory or '—'"/></td>
<td class="ftk-label ftk-cell-divider">
<t t-if="not is_fr">Measuring Time</t>
<t t-if="is_fr">Durée de mesure</t>
</td>
<td>
<t t-if="doc.x_fc_thickness_measuring_time_sec"
t-esc="'%d sec' % doc.x_fc_thickness_measuring_time_sec"/>
<t t-if="not doc.x_fc_thickness_measuring_time_sec"></t>
</td>
</tr>
</table>
<!-- Image (left) + readings (right). The image
cell's border-right is the only divider; the
readings inner table is borderless on its
perimeter (the parent cell's edges + the
block's outer border do all the bounding).
Inner-cell dividers are drawn per th/td. -->
<table>
<tr>
<td t-if="doc.x_fc_thickness_image_id"
class="ftk-img-cell" style="width: 45%;">
<img t-att-src="'/web/image/%s' % doc.x_fc_thickness_image_id.id"/>
</td>
<td class="ftk-readings-cell"
t-att-style="'width: 55%;' if doc.x_fc_thickness_image_id else 'width: 100%;'">
<table class="ftk-readings">
<thead>
<tr>
<th class="ftk-cell-divider" style="width: 28%;">#</th>
<th class="ftk-cell-divider">NiP (mils)</th>
<th class="ftk-cell-divider">Ni %</th>
<th>P %</th>
</tr>
</thead>
<tbody>
<tr t-foreach="readings" t-as="r"
class="ftk-row-divider"
style="page-break-inside: avoid;">
<td class="ftk-cell-divider"><t t-esc="r.reading_number or r_index + 1"/></td>
<td class="ftk-cell-divider"><t t-esc="'%.4f' % (r.nip_mils or 0)"/></td>
<td class="ftk-cell-divider"><t t-esc="'%.3f' % (r.ni_percent or 0)"/></td>
<td><t t-esc="'%.3f' % (r.p_percent or 0)"/></td>
</tr>
<!-- Stats: Mean / Std Dev / CoV / Range / N -->
<tr t-if="nip_vals"
class="ftk-stat-mean ftk-row-divider"
style="page-break-inside: avoid;">
<td class="ftk-cell-divider">
<t t-if="not is_fr">Mean</t>
<t t-if="is_fr">Moyenne</t>
</td>
<td class="ftk-cell-divider"><t t-esc="'%.4f' % nip_mean"/></td>
<td class="ftk-cell-divider"><t t-esc="'%.3f' % ni_mean"/></td>
<td><t t-esc="'%.3f' % p_mean"/></td>
</tr>
<tr t-if="nip_vals and n > 1"
class="ftk-stat ftk-row-divider"
style="page-break-inside: avoid;">
<td class="ftk-cell-divider">
<t t-if="not is_fr">Std Dev</t>
<t t-if="is_fr">Écart-type</t>
</td>
<td class="ftk-cell-divider"><t t-esc="'%.4f' % nip_std"/></td>
<td class="ftk-cell-divider"><t t-esc="'%.3f' % ni_std"/></td>
<td><t t-esc="'%.3f' % p_std"/></td>
</tr>
<tr t-if="nip_vals and n > 1"
class="ftk-stat ftk-row-divider"
style="page-break-inside: avoid;">
<td class="ftk-cell-divider">CoV (%)</td>
<td class="ftk-cell-divider"><t t-esc="'%.2f' % nip_cov"/></td>
<td class="ftk-cell-divider"><t t-esc="'%.2f' % ni_cov"/></td>
<td><t t-esc="'%.2f' % p_cov"/></td>
</tr>
<tr t-if="nip_vals and n > 1"
class="ftk-stat ftk-row-divider"
style="page-break-inside: avoid;">
<td class="ftk-cell-divider">
<t t-if="not is_fr">Range</t>
<t t-if="is_fr">Étendue</t>
</td>
<td class="ftk-cell-divider"><t t-esc="'%.4f' % nip_range"/></td>
<td class="ftk-cell-divider"><t t-esc="'%.3f' % ni_range"/></td>
<td><t t-esc="'%.3f' % p_range"/></td>
</tr>
<tr t-if="nip_vals" class="ftk-stat"
style="page-break-inside: avoid;">
<td class="ftk-cell-divider">N</td>
<td class="ftk-cell-divider"><t t-esc="n"/></td>
<td class="ftk-cell-divider"><t t-esc="n"/></td>
<td><t t-esc="n"/></td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
<!-- Source-file footnote — italic + opacity:0.7
(from .small-label) renders jagged/washed-out
on entech wkhtmltopdf. Solid #555 grey at
normal weight prints cleanly. -->
<div t-if="doc.x_fc_thickness_source_filename"
style="margin-top: 4px; font-size: 8pt; color: #555;">
<strong t-if="not is_fr">Source file:</strong>
<strong t-if="is_fr">Fichier source :</strong>
<t t-esc="' ' + (doc.x_fc_thickness_source_filename or '')"/>
<t t-if="doc.x_fc_local_thickness_evidence_id"> (attached to cert as evidence)</t>
</div>
</div>
</t>
<!-- Signature + certification statement — never split
across pages (page-break-inside on the row works on
entech wkhtmltopdf; see CLAUDE.md). -->
<table style="margin-top: 18px;">
<tr>
<tr style="page-break-inside: avoid;">
<td style="width: 50%; vertical-align: top;">
<div>
<strong t-if="not is_fr">Certified By:</strong>

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
"""One-off rate-quote sweep across every FedEx service code.
Loops the full carrier selection (~38 services) against two routes —
CA domestic (matching SO-30045) and CA → US — to figure out which
services are valid for the shipping lanes EN Technologies actually
uses. Prints a CSV-ish matrix to stdout so the report can be pasted
straight into chat.
Run with:
odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < this_file
"""
from types import SimpleNamespace
from odoo.addons.fusion_shipping.api.fedex_rest.request import (
FedexRequest as FedexRestRequest,
)
CARRIER_ID = 17 # FedEx REST carrier on entech
WEIGHT_LB = 5.0
DIMS = {'length': 12, 'width': 10, 'height': 6}
carrier = env['delivery.carrier'].browse(CARRIER_ID)
assert carrier.exists(), 'FedEx carrier id=17 not found on this DB.'
service_codes = [code for code, _label in carrier._fields[
'fedex_rest_service_type'
].selection]
# CA Toronto sender (from company address)
sender = SimpleNamespace(
street='36 Taber Road', street2=False,
city='Toronto', zip='M9W3A8',
state_id=env['res.country.state'].search(
[('code', '=', 'ON'), ('country_id.code', '=', 'CA')], limit=1,
),
country_id=env['res.country'].search([('code', '=', 'CA')], limit=1),
name='ENTECH', phone='4167492400', email='ship@entech.test',
commercial_partner_id=None, parent_id=None, vat=False,
is_company=True,
)
# Route A — CA domestic (Niagara Falls, ON)
ca_recipient = env['res.partner'].search([
('city', 'ilike', 'Niagara Falls'), ('country_id.code', '=', 'CA'),
], limit=1)
assert ca_recipient.exists(), 'No CA partner found for the domestic route.'
# Route B — CA → US (a real US partner with a complete address)
us_recipient = env['res.partner'].search([
('country_id.code', '=', 'US'), ('city', '!=', False),
('zip', '!=', False), ('state_id', '!=', False),
], limit=1)
if not us_recipient:
# Fabricate one in memory (we won't write to DB).
us_recipient = env['res.partner'].new({
'name': 'Test US Customer',
'street': '1 World Trade Center',
'city': 'New York',
'zip': '10007',
'state_id': env['res.country.state'].search(
[('code', '=', 'NY'), ('country_id.code', '=', 'US')], limit=1,
).id,
'country_id': env['res.country'].search(
[('code', '=', 'US')], limit=1,
).id,
'phone': '2125551212',
'email': 'us@test.com',
})
# Sender partner — use the company partner for proper address resolution.
sender_partner = env.company.partner_id
def quote(service_code, recipient):
srm = FedexRestRequest(carrier)
srm.service_type = service_code
pkg = SimpleNamespace(
weight=WEIGHT_LB,
dimension=DIMS,
packaging_type='YOUR_PACKAGING',
total_cost=0, commodities=[], currency_id=env.ref('base.CAD'),
)
try:
res = srm._get_shipping_price(
ship_from=sender_partner,
ship_to=recipient,
packages=[pkg],
currency='CAD',
)
return {
'ok': True,
'price': res.get('price'),
'currency': res.get('currency'),
'service_name': res.get('service_name', '').strip(),
'delivery': (res.get('delivery_timestamp') or '')[:16].replace(
'T', ' ',
),
'transit': res.get('transit_time', ''),
'error': '',
}
except Exception as exc:
msg = str(exc).replace('\n', ' ').strip()
# Trim Odoo's "Error from FedEx: " prefix if present.
return {
'ok': False, 'price': 0, 'currency': '',
'service_name': '', 'delivery': '', 'transit': '',
'error': msg[:140],
}
def emit_row(route, code, label, result):
print('|{route}|{code}|{label}|{ok}|{price}|{cur}|{eta}|{transit}|{err}|'.format(
route=route,
code=code,
label=label[:50],
ok='OK' if result['ok'] else 'FAIL',
price=('%.2f' % result['price']) if result['ok'] else '',
cur=result['currency'],
eta=result['delivery'],
transit=result['transit'],
err=result['error'],
))
print('|Route|ServiceCode|Label|Status|Price|Cur|DeliveryETA|Transit|Error|')
print('|---|---|---|---|---|---|---|---|---|')
label_map = dict(carrier._fields['fedex_rest_service_type'].selection)
for code in service_codes:
label = label_map.get(code, code)
emit_row('CA->CA', code, label, quote(code, ca_recipient))
emit_row('CA->US', code, label, quote(code, us_recipient))
print('DONE')

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
"""One-off: reset CoC-30045 to draft for re-issue testing.
Clears all thickness data + attachments so the operator can re-run
the Issue Certs wizard from a clean slate (upload RTF + PNG image,
verify the full inline-render flow).
Run with:
odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < this_file
"""
cert = env['fp.certificate'].browse(501)
print('before: state=%s, readings=%d, attachment_id=%s, evidence=%s, image=%s' % (
cert.state,
len(cert.thickness_reading_ids),
cert.attachment_id.id if cert.attachment_id else None,
cert.x_fc_local_thickness_evidence_id.id if cert.x_fc_local_thickness_evidence_id else None,
cert.x_fc_thickness_image_id.id if cert.x_fc_thickness_image_id else None,
))
# Drop all readings
n_readings = len(cert.thickness_reading_ids)
cert.thickness_reading_ids.unlink()
# Delete any attachments on the cert (RTF, regenerated PDF, image)
atts = env['ir.attachment'].search([
('res_model', '=', 'fp.certificate'),
('res_id', '=', cert.id),
])
n_atts = len(atts)
atts.unlink()
# Wipe cert-level thickness metadata + state-affecting fields
cert.write({
'state': 'draft',
'attachment_id': False,
'x_fc_local_thickness_pdf': False,
'x_fc_local_thickness_pdf_filename': False,
'x_fc_local_thickness_evidence_id': False,
'x_fc_thickness_image_id': False,
'x_fc_thickness_operator': False,
'x_fc_thickness_product': False,
'x_fc_thickness_application': False,
'x_fc_thickness_directory': False,
'x_fc_thickness_equipment': False,
'x_fc_thickness_datetime': False,
'x_fc_thickness_measuring_time_sec': 0,
'x_fc_thickness_source_filename': False,
})
env.cr.commit()
cert.invalidate_recordset()
print('after: state=%s, readings=%d, attachments_removed=%d, prior_readings=%d' % (
cert.state, len(cert.thickness_reading_ids), n_atts, n_readings,
))
print('CoC-30045 ready for re-issue. Open the Issue Certs wizard '
'from WO-30045, upload the RTF + PNG image, click Confirm & Issue.')

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""Retro-extract WMF image from CoC-30045's attached RTF, attach as
PNG, link to x_fc_thickness_image_id, regenerate cert PDF.
Run with:
odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < this_file
"""
import base64
from odoo.addons.fusion_plating_jobs.wizards.fp_cert_issue_wizard import (
_fp_extract_rtf_images,
_fp_pick_microscope_image,
)
cert = env['fp.certificate'].browse(501)
att = env['ir.attachment'].search([
('res_model', '=', 'fp.certificate'),
('res_id', '=', 501),
('name', 'ilike', 'XRF'),
], limit=1)
raw = base64.b64decode(att.datas)
pngs = _fp_extract_rtf_images(raw)
print('extracted PNG blocks:', len(pngs),
'sizes:', [len(p) for p in pngs])
img_bytes, w, h = _fp_pick_microscope_image(pngs)
if not img_bytes:
print('no microscope image found (all blocks below area threshold)')
else:
print('picked microscope image: %dx%d, %d bytes' % (w, h, len(img_bytes)))
img_att = env['ir.attachment'].sudo().create({
'name': 'CoC-30045-microscope.png',
'type': 'binary',
'datas': base64.b64encode(img_bytes),
'mimetype': 'image/png',
'res_model': 'fp.certificate',
'res_id': cert.id,
})
cert.write({'x_fc_thickness_image_id': img_att.id})
print('attached as ir.attachment id=%d' % img_att.id)
# Regenerate the cert PDF so the layout includes the image.
if cert.attachment_id:
cert.attachment_id.unlink()
cert.invalidate_recordset()
new_att = cert._fp_render_and_attach_pdf()
env.cr.commit()
print('regen done · cert PDF=%s · size=%d bytes' % (
new_att.name if new_att else 'NONE',
new_att.file_size if new_att else 0,
))

View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""One-off: re-parse RTF on CoC-30045 + populate new metadata fields
+ regenerate the cert PDF. Run on entech after deploying the parser
extensions.
Run with:
odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < this_file
"""
import base64
from datetime import datetime
from odoo.addons.fusion_plating_jobs.wizards.fp_cert_issue_wizard import (
_fp_parse_fischerscope_rtf,
)
cert = env['fp.certificate'].browse(501)
att = env['ir.attachment'].search([
('res_model', '=', 'fp.certificate'),
('res_id', '=', 501),
('name', 'ilike', 'XRF'),
], limit=1)
raw = base64.b64decode(att.datas)
parsed = _fp_parse_fischerscope_rtf(raw)
print('parsed metadata:', {
k: parsed[k] for k in (
'operator', 'product', 'application', 'directory',
'equipment', 'measuring_time_sec', 'date_str', 'time_str',
'calibration',
)
})
vals = {
'x_fc_thickness_operator': parsed['operator'],
'x_fc_thickness_product': parsed['product'],
'x_fc_thickness_application': parsed['application'],
'x_fc_thickness_directory': parsed['directory'],
'x_fc_thickness_equipment': parsed['equipment'] or 'Fischerscope XDAL 600',
'x_fc_thickness_measuring_time_sec': parsed['measuring_time_sec'] or 0,
'x_fc_thickness_source_filename': att.name,
}
date_str = (parsed.get('date_str') or '').strip()
time_str = (parsed.get('time_str') or '').strip()
if date_str:
combined = ('%s %s' % (date_str, time_str)).strip()
for fmt in (
'%m/%d/%Y %I:%M:%S %p', '%m/%d/%Y %I:%M %p',
'%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M',
'%m/%d/%Y',
):
try:
vals['x_fc_thickness_datetime'] = datetime.strptime(combined, fmt)
break
except ValueError:
continue
cert.write(vals)
print('wrote vals:', list(vals.keys()))
# Backfill calibration on existing readings (created earlier).
calib = parsed.get('calibration') or ''
if calib:
cert.thickness_reading_ids.write({'calibration_std_ref': calib})
print('backfilled calibration on %d readings' % len(cert.thickness_reading_ids))
# Regenerate the cert PDF so the new layout takes effect.
if cert.attachment_id:
cert.attachment_id.unlink()
cert.invalidate_recordset()
new_att = cert._fp_render_and_attach_pdf()
env.cr.commit()
print('done · readings=%d · new PDF=%s · size=%d bytes' % (
len(cert.thickness_reading_ids),
new_att.name if new_att else 'NONE',
new_att.file_size if new_att else 0,
))

View File

@@ -165,7 +165,21 @@ class FedexRequest:
def _process_errors(self, res_body):
err_msgs = []
for err in res_body.get('errors', []):
err_msgs.append(f"{err['message']} ({err['code']})")
msg = f"{err.get('message', '')} ({err.get('code', '')})"
# FedEx hides the specific field-validation failures in
# parameterList (e.g. INVALID.INPUT.EXCEPTION's top-level
# message is just "Validation failed for object='X'. Error
# count: 1" — the actual field name lives in parameterList).
# Surface them so operators see "city cannot be null" instead
# of a useless generic exception.
params = err.get('parameterList') or []
details = '; '.join(
f"{p.get('key', '')}={p.get('value', '')}"
for p in params if p.get('key') or p.get('value')
)
if details:
msg += f"\n {details}"
err_msgs.append(msg)
return ','.join(err_msgs)
def _process_alerts(self, response):
@@ -395,7 +409,8 @@ class FedexRequest:
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']), {})
reply = res['rateReplyDetails'][0]
rate = next(filter(lambda d: d['currency'] == fedex_currency, reply['ratedShipmentDetails']), {})
if rate.get('totalNetChargeWithDutiesAndTaxes', 0):
price = rate['totalNetChargeWithDutiesAndTaxes']
else:
@@ -403,8 +418,22 @@ class FedexRequest:
except KeyError:
raise ValidationError(_('Could not decode response')) from None
# Commit info — service display name + estimated delivery date
# for the receiving form's shipping-quote preview panel.
# FedEx returns several shapes depending on service; fall
# through gracefully so callers that only need `price` still
# work.
commit = reply.get('commit') or {}
date_detail = commit.get('dateDetail') or {}
return {
'price': price,
'currency': fedex_currency,
'service_type': reply.get('serviceType') or self.service_type,
'service_name': reply.get('serviceName') or '',
'delivery_timestamp': commit.get('deliveryTimestamp')
or date_detail.get('dayCxsFormat') or '',
'day_of_week': commit.get('dayOfWeek') or '',
'transit_time': commit.get('transitTime') or '',
'alert_message': self._process_alerts(res),
}

View File

@@ -2459,13 +2459,29 @@ class DeliveryCarrier(models.Model):
def fusion_fedex_rest_send_shipping(self, pickings):
res = []
srm = FedexRestRequest(self)
# Per-shipment service override — fp.receiving sets this on the
# carrier via with_context() before calling send_shipping. Empty
# falls back to the carrier-level default already on srm.
# See CLAUDE.md "Per-shipment service override".
override = self.env.context.get('fp_service_type_override')
if override:
srm.service_type = override
for picking in pickings:
packages = self._get_packages_from_picking(picking, self.fedex_rest_default_package_type_id)
# SoldTo defaults to the SO's invoice partner, but many setups
# leave the parent contact (used as invoice fallback) with a
# name-only record and no address — FedEx rejects on `soldTo.
# address.city cannot be null`. If the invoice partner has no
# city, treat ship-to as sold-to so _ship_package skips the
# soldTo block entirely (line guard: `if sold_to != ship_to`).
invoice_partner = picking.sale_id.partner_invoice_id
if not (invoice_partner and invoice_partner.city):
invoice_partner = picking.partner_id
response = srm._ship_package(
ship_from_wh=picking.picking_type_id.warehouse_id.partner_id,
ship_from_company=picking.company_id.partner_id,
ship_to=picking.partner_id,
sold_to=picking.sale_id.partner_invoice_id,
sold_to=invoice_partner,
packages=packages,
currency=picking.sale_id.currency_id.name or picking.company_id.currency_id.name,
order_no=picking.sale_id.name,

View File

@@ -267,10 +267,22 @@ class FusionShipment(models.Model):
}
def _action_open_attachment(self, attachment):
"""Open an attachment PDF in the browser viewer (new tab)."""
"""Open an attachment for the operator.
Delegates to ir.attachment.action_fusion_preview — PDFs render
in the preview dialog, anything else (ZPL, etc.) downloads.
Helper falls back gracefully when fusion_pdf_preview isn't
installed. See CLAUDE.md "PDF Preview" for the contract.
"""
self.ensure_one()
if not attachment:
return False
if hasattr(attachment, 'action_fusion_preview'):
return attachment.action_fusion_preview(
title=attachment.name or 'Shipping Label',
model_name=self._name,
record_ids=self.id,
)
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=false' % attachment.id,

View File

@@ -0,0 +1,47 @@
\pset border 2
\pset format aligned
\echo '== A. Calendar event coverage on active tasks (last 30 days + future) =='
SELECT
COALESCE(NULLIF(x_fc_sync_source,''), '<local>') AS source,
status,
COUNT(*) AS task_count,
COUNT(calendar_event_id) AS with_calendar_event,
COUNT(*) - COUNT(calendar_event_id) AS missing
FROM fusion_technician_task
WHERE active = TRUE
AND scheduled_date >= CURRENT_DATE - 30
AND technician_id IS NOT NULL
GROUP BY 1, 2
ORDER BY 1, 2;
\echo ''
\echo '== B. Spot-check: recent tasks WITHOUT calendar_event_id =='
SELECT id, name, technician_id, x_fc_sync_source, status, scheduled_date, datetime_start, datetime_end
FROM fusion_technician_task
WHERE active = TRUE
AND scheduled_date >= CURRENT_DATE - 7
AND technician_id IS NOT NULL
AND calendar_event_id IS NULL
AND status NOT IN ('cancelled', 'completed')
ORDER BY scheduled_date DESC, id DESC
LIMIT 20;
\echo ''
\echo '== C. Sample of linked calendar.event records (most recent 5) =='
SELECT t.id AS task_id, t.name AS task_name,
ce.id AS event_id, ce.name AS event_name,
ce.start AS ev_start, ce.stop AS ev_stop,
t.x_fc_sync_source AS source
FROM fusion_technician_task t
JOIN calendar_event ce ON ce.id = t.calendar_event_id
WHERE t.active = TRUE
ORDER BY t.write_date DESC
LIMIT 5;
\echo ''
\echo '== D. Are external calendar sync modules installed? =='
SELECT name, state, latest_version FROM ir_module_module
WHERE name IN ('google_calendar', 'microsoft_calendar', 'calendar', 'mail')
OR name LIKE '%calendar%'
ORDER BY name;

View File

@@ -0,0 +1,42 @@
\pset border 2
\pset format aligned
\echo '== E. Are calendar events linked to the tech as organizer + attendee? =='
SELECT t.id AS task_id, t.name AS task_name,
ce.user_id AS event_organizer_uid,
u_org.login AS organizer_login,
u_tech.login AS task_tech_login,
(SELECT COUNT(*) FROM calendar_event_res_partner_rel
WHERE calendar_event_id = ce.id) AS attendee_count,
(SELECT COUNT(*) FROM calendar_event_res_partner_rel cer
JOIN res_users u2 ON u2.partner_id = cer.res_partner_id
WHERE cer.calendar_event_id = ce.id AND u2.id = t.technician_id) AS tech_is_attendee
FROM fusion_technician_task t
JOIN calendar_event ce ON ce.id = t.calendar_event_id
JOIN res_users u_tech ON u_tech.id = t.technician_id
LEFT JOIN res_users u_org ON u_org.id = ce.user_id
WHERE t.active = TRUE
AND t.scheduled_date >= CURRENT_DATE - 3
AND t.scheduled_date <= CURRENT_DATE + 7
ORDER BY t.scheduled_date, t.id
LIMIT 12;
\echo ''
\echo '== F. Microsoft Calendar OAuth: how many users have it connected? =='
SELECT
COUNT(*) FILTER (WHERE microsoft_calendar_token IS NOT NULL AND microsoft_calendar_token <> '') AS users_with_ms_token,
COUNT(*) FILTER (WHERE x_fc_is_field_staff = TRUE
AND microsoft_calendar_token IS NOT NULL
AND microsoft_calendar_token <> '') AS field_staff_with_ms_token,
COUNT(*) FILTER (WHERE x_fc_is_field_staff = TRUE AND active = TRUE) AS active_field_staff
FROM res_users;
\echo ''
\echo '== G. Per-tech: connected to MS calendar? =='
SELECT u.login, u.x_fc_tech_sync_id,
(microsoft_calendar_token IS NOT NULL AND microsoft_calendar_token <> '') AS ms_connected,
(microsoft_calendar_sync_token IS NOT NULL AND microsoft_calendar_sync_token <> '') AS ms_sync_token,
microsoft_calendar_account_id
FROM res_users u
WHERE u.x_fc_is_field_staff = TRUE AND u.active = TRUE
ORDER BY u.login;

View File

@@ -0,0 +1,25 @@
print('=== BEFORE ===')
for uid in (32, 27):
u = env['res.users'].browse(uid)
print(f" uid={uid} login={u.login} active={u.active} "
f"field_staff={u.x_fc_is_field_staff} sync_id={u.x_fc_tech_sync_id!r}")
try:
env['res.users'].browse(32).x_fc_tech_sync_id = 'simranjeet'
env['res.users'].browse(27).write({
'x_fc_is_field_staff': True,
'x_fc_tech_sync_id': 'hk',
})
env.cr.commit()
print('Commit OK')
except Exception as e:
env.cr.rollback()
print(f'FAILED: {type(e).__name__}: {e}')
raise
print('=== AFTER ===')
for uid in (32, 27):
u = env['res.users'].browse(uid)
print(f" uid={uid} login={u.login} active={u.active} "
f"field_staff={u.x_fc_is_field_staff} sync_id={u.x_fc_tech_sync_id!r}")
print('DONE')

View File

@@ -0,0 +1,62 @@
\pset border 2
\pset format aligned
\echo '== 1. Local instance ID =='
SELECT key, value
FROM ir_config_parameter
WHERE key = 'fusion_claims.sync_instance_id';
\echo ''
\echo '== 2. Remote sync configs (other instances we sync with) =='
SELECT id, name, instance_id, url, database, username, active,
last_sync, LEFT(COALESCE(last_sync_error,''), 200) AS last_sync_error
FROM fusion_task_sync_config;
\echo ''
\echo '== 3. Field technicians and sync IDs =='
SELECT u.id, u.login, p.name AS partner_name,
u.x_fc_is_field_staff, u.x_fc_tech_sync_id, u.active
FROM res_users u
JOIN res_partner p ON p.id = u.partner_id
WHERE u.x_fc_is_field_staff = TRUE
OR (u.x_fc_tech_sync_id IS NOT NULL AND u.x_fc_tech_sync_id <> '')
ORDER BY u.active DESC, u.login;
\echo ''
\echo '== 4. Recent task flow (last 7 days) =='
SELECT
COALESCE(NULLIF(x_fc_sync_source,''), '<local>') AS source,
status,
COUNT(*) AS cnt,
MIN(scheduled_date) AS min_date,
MAX(scheduled_date) AS max_date
FROM fusion_technician_task
WHERE create_date > NOW() - INTERVAL '7 days'
GROUP BY 1, 2
ORDER BY 1, 2;
\echo ''
\echo '== 5. Cron jobs for Fusion Tasks =='
SELECT
c.id,
REPLACE(REPLACE(c.cron_name, 'Fusion Tasks:', ''), ' ', ' ') AS job,
c.active,
c.interval_number || ' ' || c.interval_type AS every,
c.lastcall, c.nextcall
FROM ir_cron c
WHERE c.cron_name LIKE 'Fusion Tasks%'
ORDER BY c.cron_name;
\echo ''
\echo '== 6. Tasks scheduled today/tomorrow by tech =='
SELECT
u.login AS tech_login,
u.x_fc_tech_sync_id AS sync_id,
COALESCE(NULLIF(t.x_fc_sync_source,''), '<local>') AS source,
COUNT(*) AS cnt
FROM fusion_technician_task t
JOIN res_users u ON u.id = t.technician_id
WHERE t.scheduled_date BETWEEN CURRENT_DATE - 1 AND CURRENT_DATE + 7
AND t.active = TRUE
GROUP BY 1,2,3
ORDER BY 1,3;

View File

@@ -0,0 +1,19 @@
\pset border 2
\pset format aligned
\echo '== Garry vs Gurpreet on westin =='
SELECT u.id, u.login, u.active, u.share, u.create_date, u.write_date,
u.x_fc_tech_sync_id,
(SELECT COUNT(*) FROM fusion_technician_task t WHERE t.technician_id = u.id) AS total_tasks,
(SELECT MAX(create_date) FROM fusion_technician_task t WHERE t.technician_id = u.id) AS last_task_create,
(SELECT COUNT(*) FROM mail_message m WHERE m.author_id = u.partner_id) AS messages
FROM res_users u
WHERE u.id IN (2, 85);
\echo ''
\echo '== HK detail on westin =='
SELECT u.id, u.login, p.name AS partner_name, p.email, p.phone, p.mobile,
u.x_fc_is_field_staff, u.x_fc_tech_sync_id, u.active
FROM res_users u
JOIN res_partner p ON p.id = u.partner_id
WHERE u.id = 39;

View File

@@ -0,0 +1,31 @@
\pset border 2
\pset format aligned
\echo '== A. All field staff and sync IDs (live) =='
SELECT u.id, u.login, p.name, u.x_fc_is_field_staff, u.x_fc_tech_sync_id, u.active
FROM res_users u JOIN res_partner p ON p.id = u.partner_id
WHERE u.x_fc_is_field_staff = TRUE
OR (u.x_fc_tech_sync_id IS NOT NULL AND u.x_fc_tech_sync_id <> '')
ORDER BY u.active DESC, u.login;
\echo ''
\echo '== B. Last pull cron run + sync config status =='
SELECT
(SELECT to_char(lastcall, 'YYYY-MM-DD HH24:MI:SS') FROM ir_cron WHERE cron_name LIKE 'Fusion Tasks: Sync Remote Tasks (Pull)') AS last_pull_cron,
(SELECT to_char(last_sync, 'YYYY-MM-DD HH24:MI:SS') FROM fusion_task_sync_config LIMIT 1) AS last_sync,
(SELECT LEFT(COALESCE(last_sync_error,'(none)'),120) FROM fusion_task_sync_config LIMIT 1) AS last_sync_error,
to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS') AS now;
\echo ''
\echo '== C. Tasks by tech in next 7 days (target: simranjeet + hk shadows now appear) =='
SELECT
u.login AS tech_login,
u.x_fc_tech_sync_id AS sync_id,
COALESCE(NULLIF(t.x_fc_sync_source,''), '<local>') AS source,
COUNT(*) AS cnt
FROM fusion_technician_task t
JOIN res_users u ON u.id = t.technician_id
WHERE t.scheduled_date BETWEEN CURRENT_DATE - 1 AND CURRENT_DATE + 7
AND t.active = TRUE
GROUP BY 1,2,3
ORDER BY 1,3;

View File

@@ -0,0 +1,16 @@
print('=== BEFORE ===')
for uid in (85, 100, 39):
u = env['res.users'].browse(uid)
print(f" uid={uid} login={u.login} active={u.active} sync_id={u.x_fc_tech_sync_id!r}")
env['res.users'].browse(85).active = False
env['res.users'].browse(100).x_fc_tech_sync_id = 'simranjeet'
env['res.users'].browse(39).x_fc_tech_sync_id = 'hk'
env.cr.commit()
print('=== AFTER ===')
for uid in (85, 100, 39):
u = env['res.users'].with_context(active_test=False).browse(uid)
print(f" uid={uid} login={u.login} active={u.active} sync_id={u.x_fc_tech_sync_id!r}")
print('DONE')