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
|
- Local URL: http://localhost:8069
|
||||||
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
- 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
|
## Supabase Knowledge Base
|
||||||
Before starting unfamiliar work, check Supabase for context:
|
Before starting unfamiliar work, check Supabase for context:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -252,10 +252,23 @@ class FusionCpShipment(models.Model):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _action_open_attachment(self, attachment):
|
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()
|
self.ensure_one()
|
||||||
if not attachment:
|
if not attachment:
|
||||||
return False
|
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 {
|
return {
|
||||||
'type': 'ir.actions.act_url',
|
'type': 'ir.actions.act_url',
|
||||||
'url': '/web/content/%s?download=false' % attachment.id,
|
'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",
|
"name": "Fusion PDF Preview",
|
||||||
"version": "19.0.2.0.0",
|
"version": "19.0.2.1.0",
|
||||||
"depends": ["web"],
|
"depends": ["web"],
|
||||||
"author": "Nexa Systems Inc",
|
"author": "Nexa Systems Inc",
|
||||||
"category": "web",
|
"category": "web",
|
||||||
@@ -41,6 +41,7 @@ Key Features:
|
|||||||
"assets": {
|
"assets": {
|
||||||
"web.assets_backend": [
|
"web.assets_backend": [
|
||||||
"fusion_pdf_preview/static/src/js/pdf_preview.js",
|
"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/js/user_menu.js",
|
||||||
"fusion_pdf_preview/static/src/xml/pdf_viewer_dialog.xml",
|
"fusion_pdf_preview/static/src/xml/pdf_viewer_dialog.xml",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
from . import res_users
|
from . import res_users
|
||||||
from . import ir_http
|
from . import ir_http
|
||||||
from . import ir_actions_report
|
from . import ir_actions_report
|
||||||
|
from . import ir_attachment
|
||||||
from . import res_config_settings
|
from . import res_config_settings
|
||||||
from . import preview_log
|
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` |
|
| **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 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` |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **`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
|
### Pending — IN PROGRESS when this session ended
|
||||||
|
|
||||||
|
|||||||
@@ -95,10 +95,20 @@ class FpFair(models.Model):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def action_view_signed_document(self):
|
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()
|
self.ensure_one()
|
||||||
if not self.x_fc_signed_pdf_id:
|
if not self.x_fc_signed_pdf_id:
|
||||||
return False
|
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 {
|
return {
|
||||||
'type': 'ir.actions.act_url',
|
'type': 'ir.actions.act_url',
|
||||||
'url': '/web/content/%s?download=true' % self.x_fc_signed_pdf_id.id,
|
'url': '/web/content/%s?download=true' % self.x_fc_signed_pdf_id.id,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Certificates',
|
'name': 'Fusion Plating — Certificates',
|
||||||
'version': '19.0.7.0.0',
|
'version': '19.0.7.7.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -106,6 +106,81 @@ class FpCertificate(models.Model):
|
|||||||
string='Fischerscope PDF filename',
|
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) ----
|
# ---- Material traceability (T2.3) ----
|
||||||
batch_ids = fields.Many2many(
|
batch_ids = fields.Many2many(
|
||||||
'fusion.plating.batch', compute='_compute_batch_ids',
|
'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
|
if 'x_fc_thickness_pdf_id' in rec._fields else False
|
||||||
)
|
)
|
||||||
has_local_pdf = bool(rec.x_fc_local_thickness_pdf)
|
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 = (
|
type_label = (
|
||||||
_('Thickness Report')
|
_('Thickness Report')
|
||||||
if rec.certificate_type == 'thickness_report'
|
if rec.certificate_type == 'thickness_report'
|
||||||
@@ -685,6 +764,32 @@ class FpCertificate(models.Model):
|
|||||||
) % source)
|
) % source)
|
||||||
return merged
|
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):
|
def action_void(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if rec.state != 'issued':
|
if rec.state != 'issued':
|
||||||
|
|||||||
@@ -42,12 +42,27 @@
|
|||||||
<button name="action_issue" string="Issue"
|
<button name="action_issue" string="Issue"
|
||||||
type="object" class="btn-primary"
|
type="object" class="btn-primary"
|
||||||
invisible="state != 'draft'"/>
|
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"
|
<button name="action_open_void_wizard" string="Void"
|
||||||
type="object" class="btn-danger"
|
type="object" class="btn-danger"
|
||||||
invisible="state != 'issued'"/>
|
invisible="state != 'issued'"/>
|
||||||
<button name="action_send_to_customer" string="Send to Customer"
|
<button name="action_send_to_customer" string="Send to Customer"
|
||||||
type="object"
|
type="object"
|
||||||
invisible="state != 'issued'"/>
|
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"
|
<field name="state" widget="statusbar"
|
||||||
statusbar_visible="draft,issued"/>
|
statusbar_visible="draft,issued"/>
|
||||||
</header>
|
</header>
|
||||||
@@ -67,48 +82,52 @@
|
|||||||
<field name="name" readonly="1"/>
|
<field name="name" readonly="1"/>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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>
|
||||||
<group>
|
<group>
|
||||||
<field name="certificate_type"/>
|
<field name="certificate_type"/>
|
||||||
<field name="partner_id"/>
|
<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"
|
<field name="contact_partner_id"
|
||||||
options="{'no_create': True}"
|
options="{'no_create': True}"
|
||||||
invisible="not partner_id"/>
|
invisible="not partner_id"/>
|
||||||
</group>
|
<field name="sale_order_id"/>
|
||||||
</group>
|
<field name="entech_wo_number"/>
|
||||||
<group>
|
<field name="portal_job_id"/>
|
||||||
<group>
|
<field name="issue_date"/>
|
||||||
<field name="issued_by_id"/>
|
<field name="issued_by_id"/>
|
||||||
<field name="certified_by_id"/>
|
<field name="certified_by_id"/>
|
||||||
<field name="body_style"/>
|
<field name="body_style"/>
|
||||||
</group>
|
</group>
|
||||||
<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="reading_count" readonly="1"/>
|
||||||
<field name="mean_nip_mils" readonly="1"/>
|
<field name="mean_nip_mils" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
</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 string="SPC — Statistical Process Control">
|
||||||
<group>
|
<group>
|
||||||
<field name="spec_min_mils"/>
|
<field name="spec_min_mils"/>
|
||||||
<field name="spec_max_mils"/>
|
<field name="spec_max_mils"/>
|
||||||
<field name="min_reading_mils" readonly="1"/>
|
<field name="min_reading_mils" readonly="1"/>
|
||||||
<field name="max_reading_mils" readonly="1"/>
|
<field name="max_reading_mils" readonly="1"/>
|
||||||
<field name="std_dev_mils" readonly="1"/>
|
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
|
<field name="std_dev_mils" readonly="1"/>
|
||||||
<field name="cpk" readonly="1"/>
|
<field name="cpk" readonly="1"/>
|
||||||
<field name="cpk_status" readonly="1" widget="badge"
|
<field name="cpk_status" readonly="1" widget="badge"
|
||||||
decoration-success="cpk_status in ('capable','excellent')"
|
decoration-success="cpk_status in ('capable','excellent')"
|
||||||
@@ -119,9 +138,9 @@
|
|||||||
decoration-success="trend_alert == 'ok'"
|
decoration-success="trend_alert == 'ok'"
|
||||||
decoration-warning="trend_alert == 'warning'"
|
decoration-warning="trend_alert == 'warning'"
|
||||||
decoration-danger="trend_alert == 'alert'"/>
|
decoration-danger="trend_alert == 'alert'"/>
|
||||||
<field name="trend_explanation" readonly="1"
|
|
||||||
invisible="trend_alert == 'ok'"/>
|
|
||||||
</group>
|
</group>
|
||||||
|
<field name="trend_explanation" readonly="1" colspan="2"
|
||||||
|
invisible="trend_alert == 'ok'"/>
|
||||||
</group>
|
</group>
|
||||||
<notebook>
|
<notebook>
|
||||||
<page string="Thickness Readings" name="readings">
|
<page string="Thickness Readings" name="readings">
|
||||||
|
|||||||
@@ -12,6 +12,19 @@
|
|||||||
<field name="model">sale.order</field>
|
<field name="model">sale.order</field>
|
||||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||||
<field name="arch" type="xml">
|
<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
|
<!-- Hide standard Delivery button: our Transfers button (below) shows
|
||||||
all stock.picking records - inbound receipts AND outbound deliveries -
|
all stock.picking records - inbound receipts AND outbound deliveries -
|
||||||
which matches the plating workflow better than outbound-only. -->
|
which matches the plating workflow better than outbound-only. -->
|
||||||
@@ -307,13 +320,13 @@
|
|||||||
<field name="name">sale.order.list.fp</field>
|
<field name="name">sale.order.list.fp</field>
|
||||||
<field name="model">sale.order</field>
|
<field name="model">sale.order</field>
|
||||||
<field name="arch" type="xml">
|
<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-muted="state == 'cancel'"
|
||||||
decoration-danger="x_fc_is_late_forecast">
|
decoration-danger="x_fc_is_late_forecast">
|
||||||
<header>
|
<header>
|
||||||
<button name="%(action_fp_direct_order_wizard)d"
|
<button name="%(action_fp_direct_order_wizard)d"
|
||||||
type="action"
|
type="action"
|
||||||
string="+ New Direct Order"
|
string="New Order"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
display="always"/>
|
display="always"/>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.10.16.2',
|
'version': '19.0.10.16.8',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -1540,6 +1540,23 @@ class FpJob(models.Model):
|
|||||||
# qty tracking truly doesn't apply).
|
# qty tracking truly doesn't apply).
|
||||||
skip_qty_gate = self.env.context.get('fp_skip_qty_reconcile')
|
skip_qty_gate = self.env.context.get('fp_skip_qty_reconcile')
|
||||||
if not skip_qty_gate and job.qty:
|
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)
|
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
|
||||||
if abs(accounted - job.qty) > 0.0001:
|
if abs(accounted - job.qty) > 0.0001:
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* onSave → /fp/record_inputs/commit → advance step (optional)
|
* 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 { Dialog } from "@web/core/dialog/dialog";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
@@ -106,7 +106,10 @@ export class FpRecordInputsDialog extends Component {
|
|||||||
this.state.jobName = data.job.name;
|
this.state.jobName = data.job.name;
|
||||||
this.state.recipeRootId = data.recipe_root_id || false;
|
this.state.recipeRootId = data.recipe_root_id || false;
|
||||||
this.state.userInitials = data.user_initials || "";
|
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 || [];
|
this.state.instructionImages = data.instruction_images || [];
|
||||||
const nowDt = this._fpNowForDatetimeLocal();
|
const nowDt = this._fpNowForDatetimeLocal();
|
||||||
this.state.rows = data.prompts.map((p) => {
|
this.state.rows = data.prompts.map((p) => {
|
||||||
|
|||||||
@@ -76,12 +76,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
<!-- 3. Add a Thickness Report tab right next to the -->
|
<!-- 3. Thickness Report tab — single place to see/edit
|
||||||
<!-- Certificate PDF tab so operator can preview the -->
|
every Fischerscope-related field on the cert.
|
||||||
<!-- Fischerscope file before merging into 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">
|
<xpath expr="//notebook/page[@name='pdf']" position="after">
|
||||||
<page string="Thickness Report (Fischerscope)"
|
<page string="Thickness Report (Fischerscope)"
|
||||||
name="thickness_pdf">
|
name="thickness_pdf">
|
||||||
|
|
||||||
|
<!-- Status + QC link (read-only context) -->
|
||||||
<group>
|
<group>
|
||||||
<field name="x_fc_thickness_status" widget="badge"
|
<field name="x_fc_thickness_status" widget="badge"
|
||||||
readonly="1"
|
readonly="1"
|
||||||
@@ -90,12 +99,33 @@
|
|||||||
decoration-success="x_fc_thickness_status == 'merged'"/>
|
decoration-success="x_fc_thickness_status == 'merged'"/>
|
||||||
<field name="x_fc_thickness_qc_id" readonly="1"
|
<field name="x_fc_thickness_qc_id" readonly="1"
|
||||||
invisible="not x_fc_thickness_qc_id"/>
|
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>
|
</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"
|
<button name="%(fusion_plating_certificates.action_fp_thickness_upload_wizard)d"
|
||||||
type="action"
|
type="action"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
@@ -103,44 +133,65 @@
|
|||||||
context="{'default_certificate_id': id}"
|
context="{'default_certificate_id': id}"
|
||||||
invisible="state != 'draft'"/>
|
invisible="state != 'draft'"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted">
|
|
||||||
<p>
|
<separator string="XDAL 600 Measurement Context"/>
|
||||||
Drop the <code>.docx</code> or <code>.pdf</code>
|
<p class="text-muted small">
|
||||||
file straight from the Fischerscope XDAL 600.
|
These values are pulled from the uploaded file
|
||||||
The wizard reads the readings, calibration set,
|
and printed on the CoC's thickness section. Edit
|
||||||
and operator info, lets you review them, and
|
any field here to override what the parser saw.
|
||||||
attaches the original file to this certificate.
|
</p>
|
||||||
</p>
|
<group>
|
||||||
</div>
|
<group>
|
||||||
<separator string="Attached File"
|
<field name="x_fc_thickness_equipment"
|
||||||
invisible="not x_fc_local_thickness_pdf"/>
|
placeholder="Fischerscope XDAL 600"/>
|
||||||
<group invisible="not x_fc_local_thickness_pdf">
|
<field name="x_fc_thickness_operator"
|
||||||
<field name="x_fc_local_thickness_pdf"
|
placeholder="Operator initials / name"/>
|
||||||
filename="x_fc_local_thickness_pdf_filename"
|
<field name="x_fc_thickness_datetime"/>
|
||||||
readonly="1"/>
|
<field name="x_fc_thickness_measuring_time_sec"/>
|
||||||
<field name="x_fc_local_thickness_pdf_filename"
|
</group>
|
||||||
invisible="1"/>
|
<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>
|
</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>
|
</page>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,26 @@ Issue button on the cert form, which stays as the fallback path.
|
|||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
from odoo import _, api, fields, models
|
from odoo import _, api, fields, models
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_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.
|
# Fischerscope XDAL 600 reading line, e.g.
|
||||||
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
|
# 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*%',
|
r'\s+P\s+\d+\s*=\s*([\d.]+)\s*%',
|
||||||
re.IGNORECASE,
|
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_OPERATOR_RE = re.compile(r'Operator:\s*(\S+)', re.IGNORECASE)
|
||||||
_FISCHER_DATE_RE = re.compile(r'Date:\s*([\d/]+)', re.IGNORECASE)
|
_FISCHER_DATE_RE = re.compile(r'Date:\s*([\d/]+)', re.IGNORECASE)
|
||||||
_FISCHER_TIME_RE = re.compile(r'Time:\s*([\d:]+\s*[APMapm]*)')
|
_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):
|
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_file = fields.Binary(string='Fischerscope File (PDF or .docx)')
|
||||||
fischer_filename = fields.Char(string='Filename')
|
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(
|
parsed_summary = fields.Text(
|
||||||
string='Parsed Summary', readonly=True,
|
string='Parsed Summary', readonly=True,
|
||||||
help='Output of the .docx parser. Populated when you attach a '
|
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')
|
@api.onchange('fischer_file', 'fischer_filename')
|
||||||
def _onchange_fischer_file(self):
|
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:
|
if not self.fischer_file:
|
||||||
return
|
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:
|
try:
|
||||||
raw = base64.b64decode(self.fischer_file)
|
raw = base64.b64decode(self.fischer_file)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.parsed_summary = _('Could not decode the uploaded file.')
|
self.parsed_summary = _('Could not decode the uploaded file.')
|
||||||
return
|
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 []
|
readings = parsed.get('readings') or []
|
||||||
if readings:
|
if readings:
|
||||||
self.reading_line_ids = [(5, 0, 0)] + [
|
self.reading_line_ids = [(5, 0, 0)] + [
|
||||||
@@ -312,15 +536,70 @@ class FpCertIssueWizardLine(models.TransientModel):
|
|||||||
't': parsed.get('time_str') or '',
|
'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):
|
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()
|
self.ensure_one()
|
||||||
cert = self.cert_id.sudo()
|
cert = self.cert_id.sudo()
|
||||||
if not self.fischer_file:
|
if not self.fischer_file:
|
||||||
# Just push manual readings, if any.
|
# Just push manual readings, if any.
|
||||||
self._push_readings_to_cert()
|
self._push_readings_to_cert()
|
||||||
|
# PNG-only path: still attach the operator's image upload.
|
||||||
|
self._apply_image_to_cert(cert)
|
||||||
return
|
return
|
||||||
name = (self.fischer_filename or 'fischerscope').lower()
|
name = (self.fischer_filename or 'fischerscope').lower()
|
||||||
|
calibration = '' # backfilled below if the parser hits
|
||||||
if name.endswith('.pdf'):
|
if name.endswith('.pdf'):
|
||||||
# Drop the PDF into the cert-local field — merges into page 2.
|
# Drop the PDF into the cert-local field — merges into page 2.
|
||||||
cert.write({
|
cert.write({
|
||||||
@@ -328,23 +607,107 @@ class FpCertIssueWizardLine(models.TransientModel):
|
|||||||
'x_fc_local_thickness_pdf_filename': self.fischer_filename,
|
'x_fc_local_thickness_pdf_filename': self.fischer_filename,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# .doc / .docx / anything else — attach as evidence.
|
# .doc / .docx / anything else — attach as evidence AND
|
||||||
self.env['ir.attachment'].sudo().create({
|
# 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',
|
'name': self.fischer_filename or 'fischerscope-report',
|
||||||
'type': 'binary',
|
'type': 'binary',
|
||||||
'datas': self.fischer_file,
|
'datas': self.fischer_file,
|
||||||
'res_model': 'fp.certificate',
|
'res_model': 'fp.certificate',
|
||||||
'res_id': cert.id,
|
'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.'
|
'Fischerscope file <b>%s</b> attached via Issue wizard.'
|
||||||
) % (self.fischer_filename or 'unnamed'))
|
)) % (self.fischer_filename or 'unnamed'))
|
||||||
self._push_readings_to_cert()
|
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.
|
"""Create fp.thickness.reading rows on the cert from wizard rows.
|
||||||
Skips when no rows. Does not deduplicate against existing
|
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()
|
self.ensure_one()
|
||||||
Reading = self.env.get('fp.thickness.reading')
|
Reading = self.env.get('fp.thickness.reading')
|
||||||
if Reading is None or not self.reading_line_ids:
|
if Reading is None or not self.reading_line_ids:
|
||||||
@@ -358,6 +721,8 @@ class FpCertIssueWizardLine(models.TransientModel):
|
|||||||
}
|
}
|
||||||
if 'reading_number' in Reading._fields:
|
if 'reading_number' in Reading._fields:
|
||||||
vals['reading_number'] = r.sequence
|
vals['reading_number'] = r.sequence
|
||||||
|
if calibration and 'calibration_std_ref' in Reading._fields:
|
||||||
|
vals['calibration_std_ref'] = calibration
|
||||||
Reading.sudo().create(vals)
|
Reading.sudo().create(vals)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,23 @@
|
|||||||
<field name="fischer_filename"
|
<field name="fischer_filename"
|
||||||
invisible="1"/>
|
invisible="1"/>
|
||||||
</group>
|
</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"
|
<div class="alert alert-info"
|
||||||
role="alert"
|
role="alert"
|
||||||
invisible="not needs_thickness or not parsed_summary">
|
invisible="not needs_thickness or not parsed_summary">
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
from odoo import _, api, fields, models
|
from odoo import _, api, fields, models
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
@@ -186,9 +188,9 @@ class FpDelivery(models.Model):
|
|||||||
}
|
}
|
||||||
shipment = self.env['fusion.shipment'].sudo().create(vals)
|
shipment = self.env['fusion.shipment'].sudo().create(vals)
|
||||||
self.x_fc_outbound_shipment_id = shipment.id
|
self.x_fc_outbound_shipment_id = shipment.id
|
||||||
self.message_post(body=_(
|
self.message_post(body=Markup(_(
|
||||||
'Outbound shipment <b>%s</b> created (draft).'
|
'Outbound shipment <b>%s</b> created (draft).'
|
||||||
) % shipment.name)
|
)) % shipment.name)
|
||||||
return self.action_view_outbound_shipment()
|
return self.action_view_outbound_shipment()
|
||||||
|
|
||||||
def action_view_outbound_shipment(self):
|
def action_view_outbound_shipment(self):
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<record id="fp_mail_template_quote_sent" model="mail.template">
|
<record id="fp_mail_template_quote_sent" model="mail.template">
|
||||||
<field name="name">FP: Quotation Sent</field>
|
<field name="name">FP: Quotation Sent</field>
|
||||||
<field name="model_id" ref="sale.model_sale_order"/>
|
<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_from">{{ (object.company_id.email or user.email) }}</field>
|
||||||
<field name="email_to">{{ object.partner_id.email }}</field>
|
<field name="email_to">{{ object.partner_id.email }}</field>
|
||||||
<field name="auto_delete" eval="True"/>
|
<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="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="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;">
|
<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>
|
</div>
|
||||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Quotation Ready</h2>
|
<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;">
|
<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;">
|
<div style="margin-top: 32px; font-size: 14px;">
|
||||||
Best regards,<br/>
|
Best regards,<br/>
|
||||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||||
EN Technologies Inc.
|
Electroless Nickel Technologies Inc. (ENTECH)
|
||||||
</div>
|
</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;">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</field>
|
</field>
|
||||||
<field name="report_template_ids"
|
<field name="report_template_ids"
|
||||||
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
|
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
|
||||||
<field name="report_name">Quotation_{{ (object.name or '').replace('/','_') }}</field>
|
|
||||||
</record>
|
</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="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="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;">
|
<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>
|
</div>
|
||||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Order Confirmed</h2>
|
<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;">
|
<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;">
|
<div style="margin-top: 32px; font-size: 14px;">
|
||||||
Best regards,<br/>
|
Best regards,<br/>
|
||||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||||
EN Technologies Inc.
|
Electroless Nickel Technologies Inc. (ENTECH)
|
||||||
</div>
|
</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;">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</field>
|
</field>
|
||||||
<field name="report_template_ids"
|
<field name="report_template_ids"
|
||||||
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
|
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
|
||||||
<field name="report_name">SalesOrder_{{ (object.name or '').replace('/','_') }}</field>
|
|
||||||
</record>
|
</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="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="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;">
|
<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>
|
</div>
|
||||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Parts Received</h2>
|
<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;">
|
<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;">
|
<div style="margin-top: 32px; font-size: 14px;">
|
||||||
Best regards,<br/>
|
Best regards,<br/>
|
||||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||||
EN Technologies Inc.
|
Electroless Nickel Technologies Inc. (ENTECH)
|
||||||
</div>
|
</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;">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</field>
|
</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="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="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;">
|
<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>
|
</div>
|
||||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Order Is Being Prepared for Shipment</h2>
|
<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;">
|
<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;">
|
<div style="margin-top: 32px; font-size: 14px;">
|
||||||
Best regards,<br/>
|
Best regards,<br/>
|
||||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||||
EN Technologies Inc.
|
Electroless Nickel Technologies Inc. (ENTECH)
|
||||||
</div>
|
</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;">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</field>
|
</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="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="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;">
|
<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>
|
</div>
|
||||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Parts Have Shipped</h2>
|
<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;">
|
<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;">
|
<div style="margin-top: 32px; font-size: 14px;">
|
||||||
Best regards,<br/>
|
Best regards,<br/>
|
||||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||||
EN Technologies Inc.
|
Electroless Nickel Technologies Inc. (ENTECH)
|
||||||
</div>
|
</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;">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</field>
|
</field>
|
||||||
@@ -311,7 +309,7 @@
|
|||||||
<record id="fp_mail_template_invoice_posted" model="mail.template">
|
<record id="fp_mail_template_invoice_posted" model="mail.template">
|
||||||
<field name="name">FP: Invoice Notification</field>
|
<field name="name">FP: Invoice Notification</field>
|
||||||
<field name="model_id" ref="account.model_account_move"/>
|
<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_from">{{ (object.company_id.email or user.email) }}</field>
|
||||||
<field name="email_to">{{ object.partner_id.email }}</field>
|
<field name="email_to">{{ object.partner_id.email }}</field>
|
||||||
<field name="auto_delete" eval="True"/>
|
<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="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="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;">
|
<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>
|
</div>
|
||||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Invoice Ready</h2>
|
<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;">
|
<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;">
|
<div style="margin-top: 32px; font-size: 14px;">
|
||||||
Best regards,<br/>
|
Best regards,<br/>
|
||||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||||
EN Technologies Inc.
|
Electroless Nickel Technologies Inc. (ENTECH)
|
||||||
</div>
|
</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;">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</field>
|
</field>
|
||||||
<field name="report_template_ids"
|
<field name="report_template_ids"
|
||||||
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_invoice_portrait')])]"/>
|
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_invoice_portrait')])]"/>
|
||||||
<field name="report_name">Invoice_{{ (object.name or '').replace('/','_') }}</field>
|
|
||||||
</record>
|
</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="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="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;">
|
<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>
|
</div>
|
||||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Payment Received — Thank You</h2>
|
<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;">
|
<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;">
|
<div style="margin-top: 32px; font-size: 14px;">
|
||||||
Best regards,<br/>
|
Best regards,<br/>
|
||||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||||
EN Technologies Inc.
|
Electroless Nickel Technologies Inc. (ENTECH)
|
||||||
</div>
|
</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;">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Receiving & Inspection',
|
'name': 'Fusion Plating — Receiving & Inspection',
|
||||||
'version': '19.0.3.20.0',
|
'version': '19.0.3.25.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -46,7 +46,20 @@ Provides:
|
|||||||
'views/fp_receiving_menu.xml',
|
'views/fp_receiving_menu.xml',
|
||||||
'views/fusion_shipment_inherit_views.xml',
|
'views/fusion_shipment_inherit_views.xml',
|
||||||
'wizards/fp_label_manual_wizard_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,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import requests
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
@@ -12,6 +15,13 @@ from odoo.exceptions import UserError
|
|||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_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):
|
class FpReceiving(models.Model):
|
||||||
"""Parts receiving record.
|
"""Parts receiving record.
|
||||||
@@ -101,6 +111,65 @@ class FpReceiving(models.Model):
|
|||||||
help='Who picks up the parts when work is done. Used to generate '
|
help='Who picks up the parts when work is done. Used to generate '
|
||||||
'the return shipping label on the linked Outbound Shipment.',
|
'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(
|
x_fc_outbound_shipment_id = fields.Many2one(
|
||||||
'fusion.shipment', string='Outbound Shipment', tracking=True,
|
'fusion.shipment', string='Outbound Shipment', tracking=True,
|
||||||
ondelete='set null',
|
ondelete='set null',
|
||||||
@@ -117,13 +186,29 @@ class FpReceiving(models.Model):
|
|||||||
help='True when the linked outbound shipment has a label PDF '
|
help='True when the linked outbound shipment has a label PDF '
|
||||||
'attached. Drives the Print Label smart-button visibility.',
|
'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):
|
def _compute_x_fc_has_label(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
rec.x_fc_has_label = bool(
|
ship = rec.x_fc_outbound_shipment_id
|
||||||
rec.x_fc_outbound_shipment_id
|
rec.x_fc_has_label = bool(ship and ship.label_attachment_id)
|
||||||
and rec.x_fc_outbound_shipment_id.label_attachment_id
|
rec.x_fc_has_label_zpl = bool(
|
||||||
|
ship and ship.x_fc_label_zpl_attachment_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- Phase C — Outbound packaging fields -----------------------------
|
# ---- Phase C — Outbound packaging fields -----------------------------
|
||||||
@@ -293,15 +378,54 @@ class FpReceiving(models.Model):
|
|||||||
|
|
||||||
# ---- Phase C — Generate Outbound Label -------------------------------
|
# ---- Phase C — Generate Outbound Label -------------------------------
|
||||||
def action_generate_outbound_label(self):
|
def action_generate_outbound_label(self):
|
||||||
"""One-button label generation.
|
"""Open the confirmation wizard before the actual API call.
|
||||||
|
|
||||||
Branches on carrier.delivery_type:
|
Two guards live here so the user can't accidentally bill
|
||||||
- 'fixed' (no API integration): opens manual entry wizard.
|
themselves for duplicate shipments:
|
||||||
- 'fusion_*' (API integration): synthesizes a stock.picking,
|
1. If a label is already attached to the linked shipment,
|
||||||
calls the existing carrier.<provider>_send_shipping method,
|
refuse to regenerate — operator must void the shipment
|
||||||
copies the result back to the linked fusion.shipment.
|
first.
|
||||||
- On API exception: falls back to the manual wizard with the
|
2. Otherwise pop fp.label.generate.wizard so the operator
|
||||||
error message in the note field.
|
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.ensure_one()
|
||||||
self._fp_validate_label_inputs()
|
self._fp_validate_label_inputs()
|
||||||
@@ -320,7 +444,17 @@ class FpReceiving(models.Model):
|
|||||||
self._fp_sync_packaging_to_shipment()
|
self._fp_sync_packaging_to_shipment()
|
||||||
try:
|
try:
|
||||||
picking = self._fp_build_shipping_picking()
|
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)
|
self._fp_apply_shipping_result(picking, shipping_data)
|
||||||
except UserError:
|
except UserError:
|
||||||
raise
|
raise
|
||||||
@@ -413,8 +547,24 @@ class FpReceiving(models.Model):
|
|||||||
"""Synthesize a stock.picking just to carry the data needed by
|
"""Synthesize a stock.picking just to carry the data needed by
|
||||||
carrier.send_shipping. The picking is auto-validated to 'done'
|
carrier.send_shipping. The picking is auto-validated to 'done'
|
||||||
state so it doesn't sit as draft in operator views.
|
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()
|
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()
|
Picking = self.env['stock.picking'].sudo()
|
||||||
warehouse = self.env['stock.warehouse'].sudo().search(
|
warehouse = self.env['stock.warehouse'].sudo().search(
|
||||||
[('company_id', '=', self.env.company.id)], limit=1,
|
[('company_id', '=', self.env.company.id)], limit=1,
|
||||||
@@ -525,11 +675,6 @@ class FpReceiving(models.Model):
|
|||||||
'location_dest_id': Move.location_dest_id.id,
|
'location_dest_id': Move.location_dest_id.id,
|
||||||
'result_package_id': pkg.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
|
self.x_fc_shipping_picking_id = picking.id
|
||||||
return picking
|
return picking
|
||||||
|
|
||||||
@@ -581,6 +726,72 @@ class FpReceiving(models.Model):
|
|||||||
('res_model', '=', 'stock.picking'),
|
('res_model', '=', 'stock.picking'),
|
||||||
('res_id', '=', picking.id),
|
('res_id', '=', picking.id),
|
||||||
], order='id asc')
|
], 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.
|
# Per-package shipping_data list — one entry per package.
|
||||||
sd_list = shipping_data if isinstance(shipping_data, list) else [
|
sd_list = shipping_data if isinstance(shipping_data, list) else [
|
||||||
shipping_data
|
shipping_data
|
||||||
@@ -608,23 +819,28 @@ class FpReceiving(models.Model):
|
|||||||
primary_tracking = per_pkg_trackings[0] if per_pkg_trackings else ''
|
primary_tracking = per_pkg_trackings[0] if per_pkg_trackings else ''
|
||||||
# Write per-row labels + tracking. Attachments are paired by
|
# Write per-row labels + tracking. Attachments are paired by
|
||||||
# index — N labels and N rows. Excess on either side is ignored.
|
# 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):
|
for idx, row in enumerate(rows):
|
||||||
row_vals = {}
|
row_vals = {}
|
||||||
if idx < len(per_pkg_trackings):
|
if idx < len(per_pkg_trackings):
|
||||||
row_vals['tracking_number'] = per_pkg_trackings[idx]
|
row_vals['tracking_number'] = per_pkg_trackings[idx]
|
||||||
if idx < len(label_atts):
|
if idx < len(primary_atts):
|
||||||
row_vals['label_attachment_id'] = label_atts[idx].id
|
row_vals['label_attachment_id'] = primary_atts[idx].id
|
||||||
if row_vals:
|
if row_vals:
|
||||||
row.sudo().write(row_vals)
|
row.sudo().write(row_vals)
|
||||||
# Shipment-level fields. Primary label = first attachment; mirror
|
# Shipment-level fields. Primary label = PDF (or first attachment
|
||||||
# all labels onto x_fc_label_attachment_ids for the multi-print UX.
|
# if carrier didn't return PDF); ZPL goes into its own slot so
|
||||||
|
# the Print ZPL button can find it.
|
||||||
vals = {'status': 'confirmed'}
|
vals = {'status': 'confirmed'}
|
||||||
if primary_tracking:
|
if primary_tracking:
|
||||||
vals['tracking_number'] = primary_tracking
|
vals['tracking_number'] = primary_tracking
|
||||||
if label_atts:
|
if primary_atts:
|
||||||
vals['label_attachment_id'] = label_atts[0].id
|
vals['label_attachment_id'] = primary_atts[0].id
|
||||||
if 'x_fc_label_attachment_ids' in ship._fields:
|
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
|
# Link the synthetic stock.picking so the Transfer field shows
|
||||||
# it on the shipment form. Also refresh sender/recipient/carrier
|
# it on the shipment form. Also refresh sender/recipient/carrier
|
||||||
# defaults in case the operator changed carrier between create
|
# defaults in case the operator changed carrier between create
|
||||||
@@ -638,7 +854,7 @@ class FpReceiving(models.Model):
|
|||||||
ship.sudo().write(vals)
|
ship.sudo().write(vals)
|
||||||
self.message_post(body=Markup(_(
|
self.message_post(body=Markup(_(
|
||||||
'Outbound label generated. Tracking: <b>%s</b>'
|
'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
|
# Validate the synthetic picking so it lands in 'done' state
|
||||||
# instead of sitting at 'ready'. The shipping label is the proof
|
# instead of sitting at 'ready'. The shipping label is the proof
|
||||||
# of dispatch — keeping the picking open misleads anyone looking
|
# of dispatch — keeping the picking open misleads anyone looking
|
||||||
@@ -676,12 +892,169 @@ class FpReceiving(models.Model):
|
|||||||
self.name, picking.name, e,
|
self.name, picking.name, e,
|
||||||
)
|
)
|
||||||
|
|
||||||
def action_print_label(self):
|
def action_refresh_shipping_quote(self):
|
||||||
"""Open the label PDF for printing.
|
"""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
|
Only wired up for FedEx REST today; other carriers fall back
|
||||||
print from their browser. Phase F replaces this with auto-print
|
to a "not supported" message. Add a branch here when wiring
|
||||||
to a network printer.
|
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()
|
self.ensure_one()
|
||||||
ship = self.x_fc_outbound_shipment_id
|
ship = self.x_fc_outbound_shipment_id
|
||||||
@@ -690,11 +1063,64 @@ class FpReceiving(models.Model):
|
|||||||
'No outbound shipping label on this receiving. '
|
'No outbound shipping label on this receiving. '
|
||||||
'Generate the label first.'
|
'Generate the label first.'
|
||||||
))
|
))
|
||||||
return {
|
return ship._action_open_attachment(ship.label_attachment_id)
|
||||||
'type': 'ir.actions.act_url',
|
|
||||||
'url': '/web/content/%d?download=true' % ship.label_attachment_id.id,
|
def _fp_zpl_to_pdf_via_labelary(self, zpl_bytes):
|
||||||
'target': 'new',
|
"""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')
|
notes = fields.Html(string='Notes')
|
||||||
|
|
||||||
line_ids = fields.One2many('fp.receiving.line', 'receiving_id', string='Receiving Lines')
|
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._update_so_receiving_status()
|
||||||
rec.message_post(body=_('Receiving closed.'))
|
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.
|
# Legacy state actions — kept for backward compatibility.
|
||||||
# Deprecated: Sub 8 moves part-level inspection to fp.racking.inspection.
|
# 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
|
import logging
|
||||||
|
|
||||||
from odoo import api, fields, models
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,6 +42,42 @@ class FusionShipment(models.Model):
|
|||||||
copy=False,
|
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
|
# Phase C — resolved carrier tracking URL with the tracking number
|
||||||
# substituted into the carrier.tracking_url template. Used by the
|
# substituted into the carrier.tracking_url template. Used by the
|
||||||
# shipment_labeled email template and any other place that needs a
|
# 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_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_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_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_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_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
|
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"
|
class="btn-primary"
|
||||||
invisible="state not in ('draft', 'inspecting')"/>
|
invisible="state not in ('draft', 'inspecting')"/>
|
||||||
<button name="action_close"
|
<button name="action_close"
|
||||||
string="Close — Racking Confirmed"
|
string="Close Receiving"
|
||||||
type="object"
|
type="object"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
invisible="state not in ('counted', 'staged', 'accepted', 'resolved')"/>
|
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) -->
|
<!-- Legacy actions (hidden by default; surfaces for old records) -->
|
||||||
<button name="action_accept"
|
<button name="action_accept"
|
||||||
string="Accept (legacy)"
|
string="Accept (legacy)"
|
||||||
@@ -77,7 +83,7 @@
|
|||||||
string="Generate Outbound Label"
|
string="Generate Outbound Label"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
icon="fa-print"
|
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"
|
<button name="action_print_label"
|
||||||
type="object"
|
type="object"
|
||||||
string="Print Label"
|
string="Print Label"
|
||||||
@@ -108,6 +114,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<field name="x_fc_has_label" invisible="1"/>
|
<field name="x_fc_has_label" invisible="1"/>
|
||||||
</button>
|
</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>
|
||||||
<div class="alert alert-info" role="alert">
|
<div class="alert alert-info" role="alert">
|
||||||
<i class="fa fa-info-circle me-2"/>
|
<i class="fa fa-info-circle me-2"/>
|
||||||
@@ -139,6 +156,9 @@
|
|||||||
<field name="received_date"/>
|
<field name="received_date"/>
|
||||||
<field name="x_fc_carrier_id"
|
<field name="x_fc_carrier_id"
|
||||||
options="{'no_create': True}"/>
|
options="{'no_create': True}"/>
|
||||||
|
<field name="x_fc_outbound_service_type"
|
||||||
|
invisible="not x_fc_carrier_id"
|
||||||
|
placeholder="Carrier default"/>
|
||||||
<field name="carrier_tracking"/>
|
<field name="carrier_tracking"/>
|
||||||
<!--
|
<!--
|
||||||
Legacy carrier_name (Char) is retained
|
Legacy carrier_name (Char) is retained
|
||||||
@@ -154,6 +174,35 @@
|
|||||||
-->
|
-->
|
||||||
<field name="carrier_name" invisible="1"/>
|
<field name="carrier_name" invisible="1"/>
|
||||||
</group>
|
</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>
|
||||||
<group string="Outbound Packaging"
|
<group string="Outbound Packaging"
|
||||||
invisible="not x_fc_carrier_id">
|
invisible="not x_fc_carrier_id">
|
||||||
|
|||||||
@@ -27,6 +27,22 @@
|
|||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</xpath>
|
</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>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import fp_label_manual_wizard
|
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)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Reports',
|
'name': 'Fusion Plating — Reports',
|
||||||
'version': '19.0.11.15.0',
|
'version': '19.0.11.24.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||||
'depends': [
|
'depends': [
|
||||||
|
|||||||
@@ -38,8 +38,18 @@
|
|||||||
<t t-set="signer_name" t-value="(signer_user and signer_user.name) or ''"/>
|
<t t-set="signer_name" t-value="(signer_user and signer_user.name) or ''"/>
|
||||||
|
|
||||||
<style>
|
<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;
|
.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 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 hr.heavy { border: 0; border-top: 2px solid #000; margin: 6px 0; }
|
||||||
.fp-coc table { width: 100%; border-collapse: collapse; margin-bottom: 6px; }
|
.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 .small-label { font-size: 7.5pt; opacity: 0.7; }
|
||||||
.fp-coc .brand-note { font-size: 7.5pt; color: #888; text-align: center;
|
.fp-coc .brand-note { font-size: 7.5pt; color: #888; text-align: center;
|
||||||
margin-top: 10px; font-style: italic; }
|
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>
|
</style>
|
||||||
|
|
||||||
<div class="fp-coc">
|
<div class="fp-coc">
|
||||||
@@ -193,12 +233,13 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Quantities / line item table -->
|
<!-- Line-item table — the column headers already speak
|
||||||
<div class="text-end small-label" style="margin-top: 8px;">
|
for themselves (Shipped / NC Qty / etc.), so the
|
||||||
<strong t-if="not is_fr">Quantities</strong>
|
hovering "Quantities" caption above was just visual
|
||||||
<strong t-if="is_fr">Quantités</strong>
|
noise. Removed 2026-05-21. Header row + body row
|
||||||
</div>
|
stay together (page-break-inside on tr per CLAUDE.md). -->
|
||||||
<table class="bordered">
|
<table class="bordered" style="margin-top: 8px;
|
||||||
|
page-break-inside: avoid;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 20%;">
|
<th style="width: 20%;">
|
||||||
@@ -244,9 +285,225 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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;">
|
<table style="margin-top: 18px;">
|
||||||
<tr>
|
<tr style="page-break-inside: avoid;">
|
||||||
<td style="width: 50%; vertical-align: top;">
|
<td style="width: 50%; vertical-align: top;">
|
||||||
<div>
|
<div>
|
||||||
<strong t-if="not is_fr">Certified By:</strong>
|
<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):
|
def _process_errors(self, res_body):
|
||||||
err_msgs = []
|
err_msgs = []
|
||||||
for err in res_body.get('errors', []):
|
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)
|
return ','.join(err_msgs)
|
||||||
|
|
||||||
def _process_alerts(self, response):
|
def _process_alerts(self, response):
|
||||||
@@ -395,7 +409,8 @@ class FedexRequest:
|
|||||||
self._strip_customs_for_domestic(request_data)
|
self._strip_customs_for_domestic(request_data)
|
||||||
res = self._send_fedex_request("/rate/v1/rates/quotes", request_data)
|
res = self._send_fedex_request("/rate/v1/rates/quotes", request_data)
|
||||||
try:
|
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):
|
if rate.get('totalNetChargeWithDutiesAndTaxes', 0):
|
||||||
price = rate['totalNetChargeWithDutiesAndTaxes']
|
price = rate['totalNetChargeWithDutiesAndTaxes']
|
||||||
else:
|
else:
|
||||||
@@ -403,8 +418,22 @@ class FedexRequest:
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
raise ValidationError(_('Could not decode response')) from None
|
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 {
|
return {
|
||||||
'price': price,
|
'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),
|
'alert_message': self._process_alerts(res),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2459,13 +2459,29 @@ class DeliveryCarrier(models.Model):
|
|||||||
def fusion_fedex_rest_send_shipping(self, pickings):
|
def fusion_fedex_rest_send_shipping(self, pickings):
|
||||||
res = []
|
res = []
|
||||||
srm = FedexRestRequest(self)
|
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:
|
for picking in pickings:
|
||||||
packages = self._get_packages_from_picking(picking, self.fedex_rest_default_package_type_id)
|
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(
|
response = srm._ship_package(
|
||||||
ship_from_wh=picking.picking_type_id.warehouse_id.partner_id,
|
ship_from_wh=picking.picking_type_id.warehouse_id.partner_id,
|
||||||
ship_from_company=picking.company_id.partner_id,
|
ship_from_company=picking.company_id.partner_id,
|
||||||
ship_to=picking.partner_id,
|
ship_to=picking.partner_id,
|
||||||
sold_to=picking.sale_id.partner_invoice_id,
|
sold_to=invoice_partner,
|
||||||
packages=packages,
|
packages=packages,
|
||||||
currency=picking.sale_id.currency_id.name or picking.company_id.currency_id.name,
|
currency=picking.sale_id.currency_id.name or picking.company_id.currency_id.name,
|
||||||
order_no=picking.sale_id.name,
|
order_no=picking.sale_id.name,
|
||||||
|
|||||||
@@ -267,10 +267,22 @@ class FusionShipment(models.Model):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _action_open_attachment(self, attachment):
|
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()
|
self.ensure_one()
|
||||||
if not attachment:
|
if not attachment:
|
||||||
return False
|
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 {
|
return {
|
||||||
'type': 'ir.actions.act_url',
|
'type': 'ir.actions.act_url',
|
||||||
'url': '/web/content/%s?download=false' % attachment.id,
|
'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