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