This commit is contained in:
gsinghpal
2026-05-21 04:48:06 -04:00
70 changed files with 11470 additions and 762 deletions

View File

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

View File

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

1
fusion_claims/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.superpowers/

3106
fusion_claims/CLAUDE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.8.0.7',
'version': '19.0.9.2.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -175,6 +175,18 @@
'fusion_claims/static/src/js/attachment_image_compress.js',
'fusion_claims/static/src/js/debug_required_fields.js',
'fusion_claims/static/src/xml/document_preview.xml',
# Dashboard: tokens MUST load before dashboard layout
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
# Dashboard OWL countdown widget
'fusion_claims/static/src/js/fc_posting_countdown.js',
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
],
'web.assets_web_dark': [
# Dark bundle recompiles the same SCSS with the dark
# $o-webclient-color-scheme default so tokens branch correctly.
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
],
},
'images': ['static/description/icon.png'],

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,432 @@
# Fusion Claims Dashboard — Design Spec
**Date:** 2026-05-21
**Module:** `fusion_claims`
**Status:** Design approved, ready for implementation plan
**Replaces:** the existing 4-panel HTML-field dashboard at `models/dashboard.py` + `views/dashboard_views.xml`
---
## 1. Purpose
Surface workflow flags, posting-week context, and per-funder hotlinks on a single dashboard so claims processors, sales reps, and managers can see at a glance what needs action today and how much money is in motion for the current ADP posting cycle.
The existing dashboard is a case-count overview. The new dashboard is action-oriented: "what's stuck, what's due this week, what should I be doing."
## 2. Audience and role behaviour
Single dashboard used by three personas, with auto-applied role filter:
- **Managers** (in `fusion_claims.group_fusion_claims_manager` or `sales_team.group_sale_manager`) — see all cases.
- **Office staff** — same as managers (they are typically in the manager group already, per the module's security model).
- **Sales reps** (only in `group_fusion_claims_user`) — see only SOs where `user_id = self.env.uid`.
A small "Showing your cases" hint appears above the workflow tiles when the role filter is active (driven by computed `is_manager`).
## 3. Scope
**In scope:**
- Posting-period banner with live countdown to submission cutoff
- 3 KPI tiles: Ready to Claim, Claimed This Period, Total AR (ADP-portion)
- 8 quick-action hotlinks: + ADP, + MOD, + ODSP, + WSIB, + Insurance, + MDC, + Hardship, + Private
- "Your Activities" list (top 10 of current user's `mail.activity`)
- Two bottleneck callouts: Approved without POD, Submitted with no ADP response > 14 days
- ADP Pre-Approval workflow tiles (4): Waiting App, App Received, Ready Submission, Needs Correction
- ADP Post-Approval workflow tiles (4): Approved, Ready Delivery, Ready Billing, On Hold
- MOD workflow tiles (5): Awaiting Funding, Funding Approved, PCA Received, Project Complete, POD Submitted
- Other-funder count cards (6): ODSP, WSIB, Insurance, MDC, Hardship, ACSD
- Light + dark theme support via compile-time SCSS branching
**Out of scope:**
- Charts / time-series graphs
- The existing 4 configurable HTML panels (removed)
- A "Recent Cases" power-user view (deferred — separate spec if needed)
- Auto-refresh on window focus (manual reload only)
- Per-user personalisation beyond the role filter (no saved layouts/filters)
- Push notifications, email digests (out of scope, handled elsewhere)
## 4. Architecture
### 4.1 Implementation pattern
**Hybrid: form-view shell + computed fields + small OWL widget for the live countdown.**
Server-rendered Bootstrap-grid form view sits on top of a TransientModel with ~36 computed fields. One OWL field-widget handles the live deadline countdown (ticks every 60 seconds, swaps colour as deadline approaches).
The TransientModel name `fusion.claims.dashboard` is **preserved** — existing menu/action records continue to resolve. The model's internals are rewritten; old fields are dropped.
### 4.2 Files
| File | Action | Purpose |
|---|---|---|
| `models/dashboard.py` | **Rewrite** | TransientModel with ~36 computed fields + role-filter helper + ~24 action methods |
| `views/dashboard_views.xml` | **Rewrite** | Form view: banner → KPIs → quick-actions → 2-column grid |
| `static/src/scss/_fc_dashboard_tokens.scss` | **New** | Colour palette tokens, compile-time `@if $o-webclient-color-scheme == dark` branch |
| `static/src/scss/fc_dashboard.scss` | **New** | Layout + section styles, references tokens |
| `static/src/js/fc_posting_countdown.js` | **New** | OWL field widget for live countdown (~60 lines) |
| `static/src/xml/fc_posting_countdown.xml` | **New** | OWL template (~10 lines) |
| `__manifest__.py` | **Edit** | Bump version (asset cache-bust), add SCSS to **both** `web.assets_backend` AND `web.assets_web_dark`, add JS+XML to backend |
### 4.3 Layout
```
┌──────────────────────────────────────────────────────────────┐
│ BANNER: Posting Period: Mar 5 19 · [OWL: 3d to cutoff] │
├──────────────────────────────────────────────────────────────┤
│ KPI TILES (3-up): Ready | Claimed | Total AR │
├──────────────────────────────────────────────────────────────┤
│ QUICK ACTIONS: + ADP + MOD + ODSP + WSIB + Ins + ... │
├────────────────────────┬─────────────────────────────────────┤
│ LEFT COLUMN │ RIGHT COLUMN │
│ Your Activities │ ADP Pre-Approval (4 tiles) │
│ Bottlenecks │ ADP Post-Approval (4 tiles) │
│ Other Funders (6) │ MOD (5 tiles) │
└────────────────────────┴─────────────────────────────────────┘
```
### 4.4 Data flow
1. User clicks Dashboard menu.
2. Existing `action_fusion_claims_dashboard` creates a fresh TransientModel record.
3. Compute methods run (5 clusters — see §6).
4. Form renders.
5. OWL countdown widget tickets every 60 s, reading `submission_deadline_dt` from the rendered field, formatting it client-side.
6. User clicks a tile → returns `ir.actions.act_window` opening a filtered `sale.order` list.
7. User clicks a quick-action pill → returns `ir.actions.act_window` opening a fresh `sale.order` form with `default_x_fc_sale_type` in context.
8. User clicks Refresh (form header button) → reloads the action.
## 5. Role filter
Central helper on `fusion.claims.dashboard`:
```python
def _role_filter_domain(self):
user = self.env.user
if (user.has_group('fusion_claims.group_fusion_claims_manager')
or user.has_group('sales_team.group_sale_manager')):
return []
return [('user_id', '=', user.id)]
```
Every count/sum compute method prepends `_role_filter_domain()` to its domain. For `account.move` based counts (KPIs), the filter is applied through `x_fc_source_sale_order_id.user_id` (the linked SO's salesperson) because invoices don't have their own `user_id` to filter on in this module.
`is_manager` (Boolean computed) exposed for the view to optionally show a "Showing your cases" hint.
## 6. Field inventory (≈36 fields)
### 6.1 Header / banner
| Field | Type | Description |
|---|---|---|
| `posting_period_label` | Char | e.g. `"Mar 5 Mar 19"` |
| `posting_period_start` | Date | Start of current posting cycle |
| `posting_period_end` | Date | Start of next cycle (exclusive) |
| `submission_deadline_dt` | Datetime | Wed 18:00 of posting week, Toronto TZ |
| `is_manager` | Boolean | Drives role-hint visibility |
| `is_pre_first_posting` | Boolean | True if today < `adp_posting_base_date` |
Derived from helpers already on `adp.posting.schedule.mixin`. Dashboard `_inherit = ['adp.posting.schedule.mixin']`.
### 6.2 KPI tiles
| Field | Type | Source |
|---|---|---|
| `kpi_ready_amount` | Monetary | Sum of `account.move.amount_total` where `x_fc_adp_billing_status='waiting'` AND `adp_exported=False`, role-filtered via linked SO |
| `kpi_ready_count` | Integer | Same filter, count |
| `kpi_claimed_amount` | Monetary | Sum where `x_fc_adp_billing_status in ('submitted','resubmitted')` AND `adp_export_date >= posting_period_start` |
| `kpi_claimed_count` | Integer | Same filter, count |
| `kpi_ar_amount` | Monetary | Sum where `move_type='out_invoice'`, `state='posted'`, `payment_state in ('not_paid','partial')`, `x_fc_invoice_type='adp'` |
| `kpi_ar_count` | Integer | Same filter, count |
| `currency_id` | Many2one | Defaults to `company_id.currency_id` |
### 6.3 Activities (left column)
| Field | Type | Description |
|---|---|---|
| `my_activities_count` | Integer | `mail.activity` where `user_id=current_user` AND `res_model in ('sale.order','account.move','fusion.technician.task')` |
| `my_activities_html` | Html | Top 10 ordered by `date_deadline asc`, links via `/odoo/<model>/<id>`, overdue rows tinted |
### 6.4 Bottlenecks (left column)
| Field | Type | Domain |
|---|---|---|
| `bottleneck_no_pod_count` | Integer | ADP cases `x_fc_adp_application_status in ('approved','approved_deduction')` AND `x_fc_proof_of_delivery=False` |
| `bottleneck_no_response_count` | Integer | ADP cases `x_fc_adp_application_status in ('submitted','resubmitted')` AND `x_fc_claim_submission_date < today - 14 days` |
### 6.5 Other funders (left column)
Each is an Integer count of active (non-terminal) cases:
| Field | Domain |
|---|---|
| `count_odsp` | `x_fc_sale_type in ('odsp','adp_odsp')` excluding division-specific terminal states |
| `count_wsib` | `x_fc_sale_type='wsib'` excluding `case_closed`, `cancelled`, `denied` |
| `count_insurance` | `x_fc_sale_type='insurance'` excluding terminal states |
| `count_mdc` | `x_fc_sale_type='muscular_dystrophy'` excluding terminal states |
| `count_hardship` | `x_fc_sale_type='hardship'` excluding terminal states |
| `count_acsd` | `x_fc_client_type='ACS'` excluding terminal states |
### 6.6 ADP Pre-Approval (right column, 4 tiles)
| Field | Status filter |
|---|---|
| `adp_waiting_app_count` | `x_fc_adp_application_status in ('waiting_for_application','assessment_completed')` |
| `adp_app_received_count` | `x_fc_adp_application_status='application_received'` |
| `adp_ready_submit_count` | `x_fc_adp_application_status='ready_submission'` |
| `adp_needs_correction_count` | `x_fc_adp_application_status='needs_correction'` (rendered as urgent tile) |
`adp_waiting_app_count` and `adp_needs_correction_count` are styled `--urgent` (red tint).
### 6.7 ADP Post-Approval (right column, 4 tiles)
| Field | Status filter |
|---|---|
| `adp_approved_count` | `x_fc_adp_application_status in ('approved','approved_deduction')` |
| `adp_ready_delivery_count` | `x_fc_adp_application_status='ready_delivery'` |
| `adp_ready_bill_count` | `x_fc_adp_application_status='ready_bill'` |
| `adp_on_hold_count` | `x_fc_adp_application_status='on_hold'` (rendered as urgent tile) |
### 6.8 MOD (right column, 5 tiles)
| Field | Status filter |
|---|---|
| `mod_awaiting_funding_count` | `x_fc_mod_status='awaiting_funding'` |
| `mod_funding_approved_count` | `x_fc_mod_status='funding_approved'` |
| `mod_pca_received_count` | `x_fc_mod_status='contract_received'` |
| `mod_project_complete_count` | `x_fc_mod_status='project_complete'` |
| `mod_pod_submitted_count` | `x_fc_mod_status='pod_submitted'` |
## 7. Compute method clustering
Five compute methods, each owning a logical section so an expensive query in one cluster doesn't recompute the rest:
| Method | Fields populated |
|---|---|
| `_compute_banner` | 6 banner fields |
| `_compute_kpis` | 6 KPI fields + `currency_id` |
| `_compute_activities` | 2 activity fields |
| `_compute_workflow_counts` | 13 stage-tile fields (ADP + MOD) |
| `_compute_secondary_counts` | 8 fields (bottlenecks + other funders) |
All compute methods are bound to non-stored `compute='_compute_*'` fields (no `@api.depends` since TransientModel records are throwaway — every dashboard open is a fresh record). Counts use `search_count()` not `search()` to avoid loading recordsets.
## 8. Action methods (~24)
### 8.1 `action_open_<bucket>` (~16)
Thin wrappers returning `ir.actions.act_window`. Where the module already has per-stage actions (e.g. `adp_claims_views.xml` defines `act_window_adp_ready_for_billing`), reuse them via `self.env.ref(...).read()[0]`. Otherwise build the action inline.
Examples:
- `action_open_adp_waiting_app` — opens SO list filtered to `('x_fc_adp_application_status', 'in', ['waiting_for_application', 'assessment_completed'])`
- `action_open_bottleneck_no_pod` — opens SO list filtered to approved-without-POD
- `action_open_my_activities` — opens activity list filtered to current user
### 8.2 `action_create_<funder>_so` (8)
One per funder hotlink. Each opens a fresh `sale.order` form with `default_x_fc_sale_type` in context:
| Method | Context |
|---|---|
| `action_create_adp_so` | `{'default_x_fc_sale_type': 'adp'}` |
| `action_create_mod_so` | `{'default_x_fc_sale_type': 'march_of_dimes'}` |
| `action_create_odsp_so` | `{'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'}` |
| `action_create_wsib_so` | `{'default_x_fc_sale_type': 'wsib'}` |
| `action_create_insurance_so` | `{'default_x_fc_sale_type': 'insurance'}` |
| `action_create_mdc_so` | `{'default_x_fc_sale_type': 'muscular_dystrophy'}` |
| `action_create_hardship_so` | `{'default_x_fc_sale_type': 'hardship'}` |
| `action_create_private_so` | `{'default_x_fc_sale_type': 'direct_private'}` |
User picks ODSP division on the SO form (we default to `standard`, they can change to `sa_mobility` or `ontario_works`).
## 9. Theming (SCSS structure)
### 9.1 File order
Tokens load **first** in each bundle. SCSS variables defined in `_fc_dashboard_tokens.scss` must be in scope when `fc_dashboard.scss` is compiled. Odoo concatenates SCSS within a bundle in registration order, so the manifest registration sequence is load-bearing — see §11.
### 9.2 `_fc_dashboard_tokens.scss`
Single source of truth. Define light values at top level, override with `!global` inside `@if $o-webclient-color-scheme == dark`. Token names use the `$_fc-*` convention (underscore prefix for "private" partials).
Light palette (22 tokens):
```
page-bg: #f7f7f8 card-bg: #ffffff card-border: #d8dadd
text: #2b2b2b text-muted: #6c7480
banner: linear-gradient(#eef2ff → #fce7f3) border: #c7d2fe text: #3730a3
deadline-text: #b91c1c
kpi-bg: #f0f4ff kpi-border: #c7d2fe kpi-num: #1e3a8a
action-bg: #ecfdf5 action-border: #6ee7b7 action-text: #047857
tile-bg: #f3f4f6 tile-border: #e5e7eb tile-num: #111827
urgent-bg: #fee2e2 urgent-border: #fca5a5 urgent-num: #991b1b urgent-text: #7f1d1d
activity-bg: #fefce8 activity-border: #fde047
bottleneck-bg: #fef2f2 bottleneck-border: #fecaca
```
Dark palette overrides (cool blue monochrome banner per Round 3 selection):
```
page-bg: #1a1d21 card-bg: #22262d card-border: #3a3f47
text: #e5e7eb text-muted: #9ca3af
banner: linear-gradient(#1e293b → #1e3a5f) border: #3b82f6 text: #93c5fd
deadline-text: #fca5a5
kpi-bg: #1e293b kpi-border: #334155 kpi-num: #93c5fd
action-bg: #064e3b action-border: #047857 action-text: #6ee7b7
tile-bg: #2d3138 tile-border: #3a3f47 tile-num: #f3f4f6
urgent-bg: #4a1414 urgent-border: #7f1d1d urgent-num: #fca5a5 urgent-text: #fecaca
activity-bg: #3a2e0a activity-border: #854d0e
bottleneck-bg: #3a1414 bottleneck-border: #7f1d1d
```
### 9.3 `fc_dashboard.scss`
Layout file. Re-exports each token as a CSS custom property scoped under `.o_fc_dashboard` so dev-tools can inspect/tweak live, then uses both the SCSS variable (for compile-time work like `darken()`) and the CSS variable (for runtime). Section classes:
- `.o_fc_banner` — gradient + border, flex-row with deadline countdown on the right
- `.o_fc_kpi` (with `.o_fc_kpi__num`) — 3-up KPI tiles
- `.o_fc_pill` — quick-action button pills
- `.o_fc_activities`, `.o_fc_bottleneck` — left-column section backgrounds
- `.o_fc_tile`, `.o_fc_tile--urgent` (with `.o_fc_tile__num`) — workflow stage tiles
- `.o_fc_countdown--info` / `.o_fc_countdown--warning` / `.o_fc_countdown--danger` / `.o_fc_countdown--muted` — countdown widget colour levels (driven by OWL state)
### 9.4 Verification
After deploy, in `odoo-shell`:
```python
env['ir.qweb']._get_asset_bundle('web.assets_backend').css() # light bundle URL
env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() # dark bundle URL
```
The two URLs must differ. If they're identical, the dark bundle didn't recompile — fix by deleting `ir.attachment` rows under `/web/assets/%` and restarting Odoo.
## 10. OWL countdown widget
### 10.1 Why a widget
The rest of the dashboard is fine being recomputed on page open — case counts move slowly. The countdown ("3 days 4 hours to cutoff") needs to tick without a page refresh, and its colour needs to shift as the deadline approaches (info → warning → danger).
### 10.2 Behaviour
- Registered as a field widget under the name `fc_posting_countdown`.
- Reads `submission_deadline_dt` from `props.record.data`.
- Ticks every 60 seconds via `setInterval`. Cleared on `onWillDestroy`.
- Four levels with auto-shift:
- `> 3 days remaining`**info** (banner text colour)
- `13 days`**warning** (amber)
- `< 24 hours`**danger** (urgent-num colour)
- `past deadline`**muted** (text-muted colour), text reads "Cutoff passed"
- Uses Luxon for date math (already loaded by Odoo).
### 10.3 Template
```xml
<templates xml:space="preserve">
<t t-name="fusion_claims.PostingCountdown" owl="1">
<span t-att-class="'o_fc_countdown o_fc_countdown--' + state.level"
t-esc="state.text"/>
</t>
</templates>
```
### 10.4 Use in form view
```xml
<field name="submission_deadline_dt"
widget="fc_posting_countdown"
nolabel="1"
readonly="1"/>
```
## 11. Manifest changes
```python
'version': '<bump minor>', # e.g. 19.0.8.0.7 → 19.0.9.0.0 for asset cache-bust per CLAUDE.md §Asset Cache Busting
'data': [
# ...existing entries (data files load order unchanged)...
'views/dashboard_views.xml', # rewritten
],
'assets': {
'web.assets_backend': [
# ...existing entries...
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss', # tokens FIRST
'fusion_claims/static/src/scss/fc_dashboard.scss',
'fusion_claims/static/src/js/fc_posting_countdown.js',
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
],
'web.assets_web_dark': [
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
# No JS in dark bundle — Odoo loads JS once from backend.
],
},
```
Token file is registered **before** layout file in **both** bundles. JS+XML only in backend.
## 12. Edge cases
### 12.1 Pre-first-posting
If today < `fusion_claims.adp_posting_base_date` (default 2026-01-23), `_get_current_posting_date()` returns the base date itself. Treatment:
- `posting_period_label` reads `"Posting starts Jan 23"`.
- `submission_deadline_dt` set to first Wednesday at 18:00.
- KPI tiles all show `$0 / 0` (no posting period to bill against yet).
- `is_pre_first_posting=True` is exposed; view shows a one-line info note above the KPIs.
### 12.2 No invoices / empty system
All counts compute to 0. KPI tiles render `$0.00`. Activities section renders an empty-state message ("No activities assigned"). Bottleneck section hides itself when both counts are zero.
### 12.3 Sales rep with no assigned SOs
`_role_filter_domain()` returns `[('user_id', '=', user.id)]`. All counts → 0. The form still renders; "Showing your cases" hint plus an empty-state message ("You have no assigned cases").
### 12.4 Portal user accidentally clicks dashboard menu
The dashboard menu is already gated by `groups_id` on the existing menu item to `fusion_claims.group_fusion_claims_user` (internal users only). Confirm this is preserved in the rewritten `dashboard_views.xml`.
### 12.5 Currency mix
KPI sums assume a single company currency. `currency_id` defaults to `company_id.currency_id`. If invoices in another currency exist, they are summed in their own currency by Odoo's standard behaviour — out of scope to handle multi-currency for this dashboard. Document this limitation in the design note.
## 13. Decisions explicitly excluded
- **Auto-refresh on window focus** — considered, dropped to keep scope tight. Manual refresh via form header button is sufficient.
- **The 4 configurable HTML panels from the existing dashboard** — removed entirely. If a "Recent Cases" view is needed later, that's a separate spec.
- **Per-funder workflow tiles for ODSP / WSIB / Insurance / MDC / Hardship** — those funders get a count card only, not a row of stage tiles. Decision: keep the dashboard focused on the two highest-volume funders (ADP, MOD).
- **Toggle between "My Cases" and "All Cases"** — group-based auto-filter only. Sales reps see their cases, managers see everything, no switch.
## 14. Acceptance criteria
1. Dashboard menu opens to a single page; old 4-panel UI gone.
2. Banner shows current posting period and a live (ticking) countdown to Wed 6 PM cutoff.
3. 3 KPI tiles render with correct dollar amounts for Ready / Claimed This Period / Total AR.
4. 8 quick-action pills open a fresh SO form with the correct `x_fc_sale_type` pre-applied.
5. All 17 workflow tiles show non-stale counts (verified by clicking a tile → resulting SO list count matches the tile number).
6. Both bottleneck callouts compute and render; clicking opens the matching filtered SO list.
7. Sales reps see only their own cases; managers see all.
8. Light and dark themes render the dashboard without any invisible / low-contrast elements. Verified by:
- Opening in light mode → no `display:none`-like artifacts, all text readable.
- Switching to dark mode (user profile → Color Scheme → Dark → reload) → all colours shift to the dark palette, banner gradient is the cool blue monochrome.
9. Asset bundles compile to distinct URLs in both themes (verified with the §9.4 snippet).
10. No regression on existing dashboard menu item / action references — module loads cleanly, no XML resolution errors.
## 15. Open questions / non-decisions
None. All design choices are locked in. Implementation plan can proceed.

View File

@@ -4,159 +4,763 @@
from odoo import api, fields, models
CASE_TYPE_SELECTION = [
('adp', 'ADP'),
('odsp', 'ODSP'),
('march_of_dimes', 'March of Dimes'),
('hardship', 'Hardship Funding'),
('acsd', 'ACSD'),
('muscular_dystrophy', 'Muscular Dystrophy'),
('insurance', 'Insurance'),
('wsib', 'WSIB'),
]
TYPE_DOMAINS = {
'adp': [('x_fc_sale_type', 'in', ['adp', 'adp_odsp'])],
'odsp': [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp'])],
'march_of_dimes': [('x_fc_sale_type', '=', 'march_of_dimes')],
'hardship': [('x_fc_sale_type', '=', 'hardship')],
'acsd': [('x_fc_client_type', '=', 'ACS')],
'muscular_dystrophy': [('x_fc_sale_type', '=', 'muscular_dystrophy')],
'insurance': [('x_fc_sale_type', '=', 'insurance')],
'wsib': [('x_fc_sale_type', '=', 'wsib')],
}
TYPE_LABELS = dict(CASE_TYPE_SELECTION)
class FusionClaimsDashboard(models.TransientModel):
_name = 'fusion.claims.dashboard'
_inherit = 'fusion_claims.adp.posting.schedule.mixin'
_description = 'Fusion Claims Dashboard'
_rec_name = 'name'
name = fields.Char(default='Dashboard', readonly=True)
# Case counts by funding type
adp_count = fields.Integer(compute='_compute_stats')
odsp_count = fields.Integer(compute='_compute_stats')
march_of_dimes_count = fields.Integer(compute='_compute_stats')
hardship_count = fields.Integer(compute='_compute_stats')
acsd_count = fields.Integer(compute='_compute_stats')
muscular_dystrophy_count = fields.Integer(compute='_compute_stats')
insurance_count = fields.Integer(compute='_compute_stats')
wsib_count = fields.Integer(compute='_compute_stats')
total_profiles = fields.Integer(compute='_compute_stats')
# =========================================================================
# Role-aware filter
# =========================================================================
is_manager = fields.Boolean(compute='_compute_is_manager')
# Panel selectors (4 panels)
panel1_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 1', default='adp')
panel2_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 2', default='odsp')
panel3_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 3', default='march_of_dimes')
panel4_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 4', default='hardship')
# Panel HTML
panel1_html = fields.Html(compute='_compute_panels', sanitize=False)
panel2_html = fields.Html(compute='_compute_panels', sanitize=False)
panel3_html = fields.Html(compute='_compute_panels', sanitize=False)
panel4_html = fields.Html(compute='_compute_panels', sanitize=False)
panel1_title = fields.Char(compute='_compute_panels')
panel2_title = fields.Char(compute='_compute_panels')
panel3_title = fields.Char(compute='_compute_panels')
panel4_title = fields.Char(compute='_compute_panels')
def _compute_stats(self):
SO = self.env['sale.order'].sudo()
Profile = self.env['fusion.client.profile'].sudo()
def _compute_is_manager(self):
manager_group = self.env.ref('fusion_claims.group_fusion_claims_manager',
raise_if_not_found=False)
sale_mgr_group = self.env.ref('sales_team.group_sale_manager',
raise_if_not_found=False)
for rec in self:
rec.adp_count = SO.search_count(TYPE_DOMAINS['adp'])
rec.odsp_count = SO.search_count(TYPE_DOMAINS['odsp'])
rec.march_of_dimes_count = SO.search_count(TYPE_DOMAINS['march_of_dimes'])
rec.hardship_count = SO.search_count(TYPE_DOMAINS['hardship'])
rec.acsd_count = SO.search_count(TYPE_DOMAINS['acsd'])
rec.muscular_dystrophy_count = SO.search_count(TYPE_DOMAINS['muscular_dystrophy'])
rec.insurance_count = SO.search_count(TYPE_DOMAINS['insurance'])
rec.wsib_count = SO.search_count(TYPE_DOMAINS['wsib'])
rec.total_profiles = Profile.search_count([])
@api.depends('panel1_type', 'panel2_type', 'panel3_type', 'panel4_type')
def _compute_panels(self):
SO = self.env['sale.order'].sudo()
for rec in self:
for i in range(1, 5):
ptype = getattr(rec, f'panel{i}_type') or 'adp'
domain = TYPE_DOMAINS.get(ptype, [])
orders = SO.search(domain, order='create_date desc', limit=50)
count = SO.search_count(domain)
title = f'Window {i} - {TYPE_LABELS.get(ptype, ptype)} ({count} cases)'
html = rec._build_top_list(orders)
setattr(rec, f'panel{i}_title', title)
setattr(rec, f'panel{i}_html', html)
def _build_top_list(self, orders):
if not orders:
return '<p class="text-muted text-center py-4">No cases found</p>'
rows = []
for o in orders:
status = o.x_fc_adp_application_status or ''
status_label = dict(o._fields['x_fc_adp_application_status'].selection).get(status, status)
rows.append(
f'<tr>'
f'<td><a href="/odoo/sales/{o.id}">{o.name}</a></td>'
f'<td>{o.partner_id.name or ""}</td>'
f'<td>{status_label}</td>'
f'<td class="text-end">${o.amount_total:,.2f}</td>'
f'</tr>'
user = rec.env.user
rec.is_manager = bool(
(manager_group and user.has_group('fusion_claims.group_fusion_claims_manager'))
or (sale_mgr_group and user.has_group('sales_team.group_sale_manager'))
)
return (
'<table class="table table-sm table-hover mb-0">'
'<thead><tr><th>Order</th><th>Client</th><th>Status</th><th class="text-end">Total</th></tr></thead>'
'<tbody>' + ''.join(rows) + '</tbody></table>'
)
def action_open_order(self, order_id):
"""Open a specific sale order with breadcrumbs."""
def _role_filter_domain(self):
"""Common domain prefix for SO-based counts.
Managers (fusion_claims.group_fusion_claims_manager or
sales_team.group_sale_manager) see everything.
Other users see only SOs where they are the salesperson.
"""
self.ensure_one()
if self.is_manager:
return []
return [('user_id', '=', self.env.user.id)]
def _month_start(self):
from datetime import date
return date.today().replace(day=1)
# =========================================================================
# Header banner
# =========================================================================
posting_period_label = fields.Char(compute='_compute_banner')
posting_period_start = fields.Date(compute='_compute_banner')
posting_period_end = fields.Date(compute='_compute_banner')
submission_deadline_dt = fields.Datetime(compute='_compute_banner')
is_pre_first_posting = fields.Boolean(compute='_compute_banner')
def _compute_banner(self):
from datetime import date, datetime, time, timedelta
import pytz
today = date.today()
for rec in self:
base_date = rec._get_adp_posting_base_date()
rec.is_pre_first_posting = today < base_date
current = rec._get_current_posting_date(today)
nxt = rec._get_next_posting_date(today)
# If we're sitting on a posting date, current == next; treat
# the period as the one starting today.
if current == nxt:
period_start = current
period_end = current + timedelta(days=rec._get_adp_posting_frequency())
else:
period_start = current
period_end = nxt
rec.posting_period_start = period_start
rec.posting_period_end = period_end
if rec.is_pre_first_posting:
rec.posting_period_label = f"Posting starts {base_date.strftime('%b %d')}"
else:
rec.posting_period_label = (
f"{period_start.strftime('%b %d')} "
f"{period_end.strftime('%b %d')}"
)
wednesday = rec._get_posting_week_wednesday(nxt)
naive_deadline = datetime.combine(wednesday, time(18, 0, 0))
# Store as UTC; users see it in their TZ; OWL widget computes in local TZ.
tz = pytz.timezone(rec.env.user.tz or 'America/Toronto')
local_deadline = tz.localize(naive_deadline)
rec.submission_deadline_dt = local_deadline.astimezone(pytz.UTC).replace(tzinfo=None)
# =========================================================================
# KPI tiles (3-up)
# =========================================================================
currency_id = fields.Many2one('res.currency', compute='_compute_kpis')
kpi_ready_amount = fields.Monetary(compute='_compute_kpis',
currency_field='currency_id')
kpi_ready_count = fields.Integer(compute='_compute_kpis')
kpi_claimed_amount = fields.Monetary(compute='_compute_kpis',
currency_field='currency_id')
kpi_claimed_count = fields.Integer(compute='_compute_kpis')
kpi_ar_amount = fields.Monetary(compute='_compute_kpis',
currency_field='currency_id')
kpi_ar_count = fields.Integer(compute='_compute_kpis')
def _invoice_role_filter(self):
"""Role filter for invoices — applied through linked SO's user_id."""
self.ensure_one()
if self.is_manager:
return []
return [('x_fc_source_sale_order_id.user_id', '=', self.env.user.id)]
def _compute_kpis(self):
Move = self.env['account.move'].sudo()
for rec in self:
rec.currency_id = rec.env.company.currency_id
inv_filter = rec._invoice_role_filter()
# KPI 1: Ready to Claim
ready_domain = inv_filter + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', '=', 'waiting'),
('adp_exported', '=', False),
]
ready_invoices = Move.search(ready_domain)
rec.kpi_ready_count = len(ready_invoices)
rec.kpi_ready_amount = sum(ready_invoices.mapped('amount_total'))
# KPI 2: Claimed This Period
claimed_domain = inv_filter + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', 'in', ['submitted', 'resubmitted']),
('adp_export_date', '>=', rec.posting_period_start),
]
claimed_invoices = Move.search(claimed_domain)
rec.kpi_claimed_count = len(claimed_invoices)
rec.kpi_claimed_amount = sum(claimed_invoices.mapped('amount_total'))
# KPI 3: Total AR (ADP-portion invoices, unpaid)
ar_domain = inv_filter + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_invoice_type', '=', 'adp'),
('payment_state', 'in', ['not_paid', 'partial']),
]
ar_invoices = Move.search(ar_domain)
rec.kpi_ar_count = len(ar_invoices)
rec.kpi_ar_amount = sum(ar_invoices.mapped('amount_total'))
# =========================================================================
# Activities (left column)
# =========================================================================
my_activities_count = fields.Integer(compute='_compute_activities')
my_activities_html = fields.Html(compute='_compute_activities', sanitize=False)
def _compute_activities(self):
Activity = self.env['mail.activity'].sudo()
domain = [
('user_id', '=', self.env.user.id),
('res_model', 'in', ['sale.order', 'account.move', 'fusion.technician.task']),
]
for rec in self:
activities = Activity.search(domain, order='date_deadline asc', limit=10)
rec.my_activities_count = Activity.search_count(domain)
if not activities:
rec.my_activities_html = (
'<p class="o_fc_empty">No activities assigned.</p>'
)
continue
from datetime import date
today = date.today()
rows = []
for act in activities:
overdue = act.date_deadline and act.date_deadline < today
row_class = 'o_fc_activity_row o_fc_activity_overdue' if overdue else 'o_fc_activity_row'
deadline_text = act.date_deadline.strftime('%b %d') if act.date_deadline else ''
url = f'/odoo/{act.res_model.replace(".", "_")}/{act.res_id}'
rows.append(
f'<div class="{row_class}">'
f'<a href="{url}"><b>{act.summary or act.activity_type_id.name or "Activity"}</b></a>'
f'<span class="o_fc_activity_deadline">{deadline_text}</span>'
f'</div>'
)
rec.my_activities_html = '\n'.join(rows)
# =========================================================================
# Bottlenecks (left column) + Other funder counts
# =========================================================================
bottleneck_no_pod_count = fields.Integer(compute='_compute_secondary_counts')
bottleneck_no_response_count = fields.Integer(compute='_compute_secondary_counts')
count_odsp = fields.Integer(compute='_compute_secondary_counts')
count_wsib = fields.Integer(compute='_compute_secondary_counts')
count_insurance = fields.Integer(compute='_compute_secondary_counts')
count_mdc = fields.Integer(compute='_compute_secondary_counts')
count_hardship = fields.Integer(compute='_compute_secondary_counts')
count_acsd = fields.Integer(compute='_compute_secondary_counts')
def _compute_secondary_counts(self):
from datetime import date, timedelta
SO = self.env['sale.order'].sudo()
cutoff_14d_ago = date.today() - timedelta(days=14)
for rec in self:
base = rec._role_filter_domain()
active = base + [('state', '!=', 'cancel')]
rec.bottleneck_no_pod_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
('x_fc_proof_of_delivery', '=', False),
])
rec.bottleneck_no_response_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
('x_fc_claim_submission_date', '<', cutoff_14d_ago),
])
rec.count_odsp = SO.search_count(active + [
('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']),
])
rec.count_wsib = SO.search_count(active + [('x_fc_sale_type', '=', 'wsib')])
rec.count_insurance = SO.search_count(active + [('x_fc_sale_type', '=', 'insurance')])
rec.count_mdc = SO.search_count(active + [('x_fc_sale_type', '=', 'muscular_dystrophy')])
rec.count_hardship = SO.search_count(active + [('x_fc_sale_type', '=', 'hardship')])
rec.count_acsd = SO.search_count(active + [('x_fc_client_type', '=', 'ACS')])
# =========================================================================
# ADP Pre-Approval (right column, 4 tiles)
# =========================================================================
adp_waiting_app_count = fields.Integer(compute='_compute_workflow_counts')
adp_app_received_count = fields.Integer(compute='_compute_workflow_counts')
adp_ready_submit_count = fields.Integer(compute='_compute_workflow_counts')
adp_needs_correction_count = fields.Integer(compute='_compute_workflow_counts')
# =========================================================================
# ADP Post-Approval (right column, 4 tiles)
# =========================================================================
adp_approved_count = fields.Integer(compute='_compute_workflow_counts')
adp_ready_delivery_count = fields.Integer(compute='_compute_workflow_counts')
adp_ready_bill_count = fields.Integer(compute='_compute_workflow_counts')
adp_on_hold_count = fields.Integer(compute='_compute_workflow_counts')
# =========================================================================
# MOD (right column, 5 tiles)
# =========================================================================
mod_awaiting_funding_count = fields.Integer(compute='_compute_workflow_counts')
mod_funding_approved_count = fields.Integer(compute='_compute_workflow_counts')
mod_pca_received_count = fields.Integer(compute='_compute_workflow_counts')
mod_project_complete_count = fields.Integer(compute='_compute_workflow_counts')
mod_pod_submitted_count = fields.Integer(compute='_compute_workflow_counts')
def _compute_workflow_counts(self):
SO = self.env['sale.order'].sudo()
for rec in self:
base = rec._role_filter_domain()
# ADP Pre-Approval
rec.adp_waiting_app_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed']),
])
rec.adp_app_received_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'application_received'),
])
rec.adp_ready_submit_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'ready_submission'),
])
rec.adp_needs_correction_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'needs_correction'),
])
# ADP Post-Approval
rec.adp_approved_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
])
rec.adp_ready_delivery_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'ready_delivery'),
])
rec.adp_ready_bill_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'ready_bill'),
])
rec.adp_on_hold_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'on_hold'),
])
# MOD
rec.mod_awaiting_funding_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'awaiting_funding'),
])
rec.mod_funding_approved_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'funding_approved'),
])
rec.mod_pca_received_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'contract_received'),
])
rec.mod_project_complete_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'project_complete'),
])
rec.mod_pod_submitted_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'pod_submitted'),
])
# =========================================================================
# This Month rollup (4-up secondary KPI strip)
# =========================================================================
count_month_submitted = fields.Integer(compute='_compute_this_month')
count_month_approved = fields.Integer(compute='_compute_this_month')
count_month_delivered = fields.Integer(compute='_compute_this_month')
count_month_billed = fields.Integer(compute='_compute_this_month')
def _compute_this_month(self):
SO = self.env['sale.order'].sudo()
for rec in self:
base = rec._role_filter_domain()
ms = rec._month_start()
rec.count_month_submitted = SO.search_count(base + [
('x_fc_claim_submission_date', '>=', ms),
])
rec.count_month_approved = SO.search_count(base + [
('x_fc_claim_approval_date', '>=', ms),
])
rec.count_month_delivered = SO.search_count(base + [
('x_fc_adp_delivery_date', '>=', ms),
])
rec.count_month_billed = SO.search_count(base + [
('x_fc_billing_date', '>=', ms),
])
# =========================================================================
# Pipeline $ by stage (4-up money-in-motion strip)
# =========================================================================
pipeline_pre_amount = fields.Monetary(compute='_compute_pipeline',
currency_field='currency_id')
pipeline_submitted_amount = fields.Monetary(compute='_compute_pipeline',
currency_field='currency_id')
pipeline_approved_amount = fields.Monetary(compute='_compute_pipeline',
currency_field='currency_id')
pipeline_ready_bill_amount = fields.Monetary(compute='_compute_pipeline',
currency_field='currency_id')
def _compute_pipeline(self):
SO = self.env['sale.order'].sudo()
for rec in self:
base = rec._role_filter_domain()
pre = SO.search(base + [
('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed',
'application_received', 'ready_submission']),
])
sub = SO.search(base + [
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
])
app = SO.search(base + [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
])
bill = SO.search(base + [
('x_fc_adp_application_status', '=', 'ready_bill'),
])
rec.pipeline_pre_amount = sum(pre.mapped('amount_total'))
rec.pipeline_submitted_amount = sum(sub.mapped('amount_total'))
rec.pipeline_approved_amount = sum(app.mapped('amount_total'))
rec.pipeline_ready_bill_amount = sum(bill.mapped('amount_total'))
# =========================================================================
# Aging buckets (disjoint: 30-59d, 60-89d, 90+d)
# =========================================================================
aging_30_count = fields.Integer(compute='_compute_aging')
aging_60_count = fields.Integer(compute='_compute_aging')
aging_90_count = fields.Integer(compute='_compute_aging')
def _compute_aging(self):
from datetime import date, timedelta
SO = self.env['sale.order'].sudo()
today = date.today()
cut_30 = today - timedelta(days=30)
cut_60 = today - timedelta(days=60)
cut_90 = today - timedelta(days=90)
# "Active" = SO not cancelled at order level, AND if it has an ADP
# status, it's not in a terminal ADP state.
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
for rec in self:
base = rec._role_filter_domain() + [
('state', '!=', 'cancel'),
'|',
('x_fc_adp_application_status', '=', False),
('x_fc_adp_application_status', 'not in', terminal_adp),
]
rec.aging_30_count = SO.search_count(base + [
('create_date', '<', cut_30),
('create_date', '>=', cut_60),
])
rec.aging_60_count = SO.search_count(base + [
('create_date', '<', cut_60),
('create_date', '>=', cut_90),
])
rec.aging_90_count = SO.search_count(base + [
('create_date', '<', cut_90),
])
# =========================================================================
# Recent ADP Exports (last 5)
# =========================================================================
recent_exports_html = fields.Html(compute='_compute_recent_exports',
sanitize=False)
recent_exports_count = fields.Integer(compute='_compute_recent_exports')
def _compute_recent_exports(self):
Exp = self.env['fusion_claims.adp.export.record'].sudo()
for rec in self:
records = Exp.search([], order='export_date desc', limit=5)
rec.recent_exports_count = Exp.search_count([])
if not records:
rec.recent_exports_html = (
'<p class="o_fc_empty">No exports yet.</p>'
)
continue
rows = []
for r in records:
total = sum(r.invoice_ids.mapped('amount_total'))
date_str = (r.export_date.strftime('%b %d, %Y')
if r.export_date else '')
label = r.posting_period_label or r.name or 'Export'
inv_count = r.invoice_count or 0
rows.append(
f'<div class="o_fc_export_row" '
f'data-export-id="{r.id}">'
f'<div class="o_fc_export_label">'
f'<b>{label}</b>'
f'<br/><small>{date_str} · {inv_count} inv</small>'
f'</div>'
f'<div class="o_fc_export_amount">${total:,.0f}</div>'
f'</div>'
)
rec.recent_exports_html = '\n'.join(rows)
# =========================================================================
# Open-list action methods
# =========================================================================
def _so_list_action(self, name, domain):
return {
'type': 'ir.actions.act_window',
'name': 'Sale Order',
'name': name,
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': order_id,
'view_mode': 'list,form',
'domain': self._role_filter_domain() + domain,
'target': 'current',
}
def action_open_adp(self):
return self._open_type_action('adp')
# ----- ADP Pre-Approval -----
def action_open_adp_waiting_app(self):
return self._so_list_action('ADP — Waiting for Application', [
('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed']),
])
def action_open_odsp(self):
return self._open_type_action('odsp')
def action_open_adp_app_received(self):
return self._so_list_action('ADP — Application Received', [
('x_fc_adp_application_status', '=', 'application_received'),
])
def action_open_march(self):
return self._open_type_action('march_of_dimes')
def action_open_adp_ready_submit(self):
return self._so_list_action('ADP — Ready for Submission', [
('x_fc_adp_application_status', '=', 'ready_submission'),
])
def action_open_hardship(self):
return self._open_type_action('hardship')
def action_open_adp_needs_correction(self):
return self._so_list_action('ADP — Needs Correction', [
('x_fc_adp_application_status', '=', 'needs_correction'),
])
def action_open_acsd(self):
return self._open_type_action('acsd')
# ----- ADP Post-Approval -----
def action_open_adp_approved(self):
return self._so_list_action('ADP — Approved', [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
])
def action_open_muscular(self):
return self._open_type_action('muscular_dystrophy')
def action_open_adp_ready_delivery(self):
return self._so_list_action('ADP — Ready for Delivery', [
('x_fc_adp_application_status', '=', 'ready_delivery'),
])
def action_open_insurance(self):
return self._open_type_action('insurance')
def action_open_adp_ready_bill(self):
return self._so_list_action('ADP — Ready to Bill', [
('x_fc_adp_application_status', '=', 'ready_bill'),
])
def action_open_wsib(self):
return self._open_type_action('wsib')
def action_open_adp_on_hold(self):
return self._so_list_action('ADP — On Hold', [
('x_fc_adp_application_status', '=', 'on_hold'),
])
def action_open_profiles(self):
return {
'type': 'ir.actions.act_window', 'name': 'Client Profiles',
'res_model': 'fusion.client.profile', 'view_mode': 'list,form',
}
# ----- MOD -----
def action_open_mod_awaiting_funding(self):
return self._so_list_action('MOD — Awaiting Funding', [
('x_fc_mod_status', '=', 'awaiting_funding'),
])
def _open_type_action(self, type_key):
def action_open_mod_funding_approved(self):
return self._so_list_action('MOD — Funding Approved', [
('x_fc_mod_status', '=', 'funding_approved'),
])
def action_open_mod_pca_received(self):
return self._so_list_action('MOD — PCA Received', [
('x_fc_mod_status', '=', 'contract_received'),
])
def action_open_mod_project_complete(self):
return self._so_list_action('MOD — Project Complete', [
('x_fc_mod_status', '=', 'project_complete'),
])
def action_open_mod_pod_submitted(self):
return self._so_list_action('MOD — POD Submitted', [
('x_fc_mod_status', '=', 'pod_submitted'),
])
# ----- Other funders -----
def action_open_odsp_cases(self):
return self._so_list_action('ODSP Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']),
])
def action_open_wsib_cases(self):
return self._so_list_action('WSIB Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', '=', 'wsib'),
])
def action_open_insurance_cases(self):
return self._so_list_action('Insurance Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', '=', 'insurance'),
])
def action_open_mdc_cases(self):
return self._so_list_action('Muscular Dystrophy Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', '=', 'muscular_dystrophy'),
])
def action_open_hardship_cases(self):
return self._so_list_action('Hardship Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', '=', 'hardship'),
])
def action_open_acsd_cases(self):
return self._so_list_action('ACSD Cases', [
('state', '!=', 'cancel'),
('x_fc_client_type', '=', 'ACS'),
])
# ----- Bottlenecks -----
def action_open_bottleneck_no_pod(self):
return self._so_list_action('Bottleneck — Approved without POD', [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
('x_fc_proof_of_delivery', '=', False),
])
def action_open_bottleneck_no_response(self):
from datetime import date, timedelta
cutoff = date.today() - timedelta(days=14)
return self._so_list_action('Bottleneck — Submitted, no response', [
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
('x_fc_claim_submission_date', '<', cutoff),
])
# ----- Activities -----
def action_open_my_activities(self):
return {
'type': 'ir.actions.act_window',
'name': f'{TYPE_LABELS.get(type_key, type_key)} Cases',
'res_model': 'sale.order', 'view_mode': 'list,form',
'domain': TYPE_DOMAINS.get(type_key, []),
'name': 'My Activities',
'res_model': 'mail.activity',
'view_mode': 'list,form',
'domain': [
('user_id', '=', self.env.user.id),
('res_model', 'in', ['sale.order', 'account.move',
'fusion.technician.task']),
],
'target': 'current',
}
# ----- KPI drill-downs -----
def action_open_kpi_ready(self):
return {
'type': 'ir.actions.act_window',
'name': 'Ready to Claim (ADP)',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': self._invoice_role_filter() + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', '=', 'waiting'),
('adp_exported', '=', False),
],
'target': 'current',
}
def action_open_kpi_claimed(self):
return {
'type': 'ir.actions.act_window',
'name': 'Claimed This Period',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': self._invoice_role_filter() + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', 'in', ['submitted', 'resubmitted']),
('adp_export_date', '>=', self.posting_period_start),
],
'target': 'current',
}
def action_open_kpi_ar(self):
return {
'type': 'ir.actions.act_window',
'name': 'Total AR (ADP)',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': self._invoice_role_filter() + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_invoice_type', '=', 'adp'),
('payment_state', 'in', ['not_paid', 'partial']),
],
'target': 'current',
}
# =========================================================================
# Create-SO hotlinks
# =========================================================================
def _create_so_action(self, name, ctx_extra):
context = dict(self.env.context)
context.update(ctx_extra)
return {
'type': 'ir.actions.act_window',
'name': name,
'res_model': 'sale.order',
'view_mode': 'form',
'view_id': False,
'context': context,
'target': 'current',
}
def action_create_adp_so(self):
return self._create_so_action('New ADP Order',
{'default_x_fc_sale_type': 'adp'})
def action_create_mod_so(self):
return self._create_so_action('New MOD Order',
{'default_x_fc_sale_type': 'march_of_dimes'})
def action_create_odsp_so(self):
return self._create_so_action('New ODSP Order', {
'default_x_fc_sale_type': 'odsp',
'default_x_fc_odsp_division': 'standard',
})
def action_create_wsib_so(self):
return self._create_so_action('New WSIB Order',
{'default_x_fc_sale_type': 'wsib'})
def action_create_insurance_so(self):
return self._create_so_action('New Insurance Order',
{'default_x_fc_sale_type': 'insurance'})
def action_create_mdc_so(self):
return self._create_so_action('New MDC Order',
{'default_x_fc_sale_type': 'muscular_dystrophy'})
def action_create_hardship_so(self):
return self._create_so_action('New Hardship Order',
{'default_x_fc_sale_type': 'hardship'})
def action_create_private_so(self):
return self._create_so_action('New Private Order',
{'default_x_fc_sale_type': 'direct_private'})
# =========================================================================
# Additional drill-downs (This Month, Pipeline, Aging, Exports)
# =========================================================================
def action_open_month_submitted(self):
return self._so_list_action('Submitted This Month', [
('x_fc_claim_submission_date', '>=', self._month_start()),
])
def action_open_month_approved(self):
return self._so_list_action('Approved This Month', [
('x_fc_claim_approval_date', '>=', self._month_start()),
])
def action_open_month_delivered(self):
return self._so_list_action('Delivered This Month', [
('x_fc_adp_delivery_date', '>=', self._month_start()),
])
def action_open_month_billed(self):
return self._so_list_action('Billed This Month', [
('x_fc_billing_date', '>=', self._month_start()),
])
def action_open_pipeline_pre(self):
return self._so_list_action('Pipeline — Pre-Submission', [
('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed',
'application_received', 'ready_submission']),
])
def action_open_pipeline_submitted(self):
return self._so_list_action('Pipeline — Submitted to ADP', [
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
])
def action_open_aging_30(self):
from datetime import date, timedelta
today = date.today()
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
return self._so_list_action('Aging — 30 to 59 Days', [
('state', '!=', 'cancel'),
'|',
('x_fc_adp_application_status', '=', False),
('x_fc_adp_application_status', 'not in', terminal_adp),
('create_date', '<', today - timedelta(days=30)),
('create_date', '>=', today - timedelta(days=60)),
])
def action_open_aging_60(self):
from datetime import date, timedelta
today = date.today()
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
return self._so_list_action('Aging — 60 to 89 Days', [
('state', '!=', 'cancel'),
'|',
('x_fc_adp_application_status', '=', False),
('x_fc_adp_application_status', 'not in', terminal_adp),
('create_date', '<', today - timedelta(days=60)),
('create_date', '>=', today - timedelta(days=90)),
])
def action_open_aging_90(self):
from datetime import date, timedelta
today = date.today()
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
return self._so_list_action('Aging — 90+ Days', [
('state', '!=', 'cancel'),
'|',
('x_fc_adp_application_status', '=', False),
('x_fc_adp_application_status', 'not in', terminal_adp),
('create_date', '<', today - timedelta(days=90)),
])
def action_open_recent_exports(self):
return {
'type': 'ir.actions.act_window',
'name': 'ADP Export History',
'res_model': 'fusion_claims.adp.export.record',
'view_mode': 'list,form',
'target': 'current',
}

View File

@@ -0,0 +1,63 @@
/** @odoo-module **/
// Fusion Claims — Posting Period Countdown
// Reads the submission_deadline_dt field, computes "Nd Xh to cutoff" client-side,
// re-renders every 60 seconds, swaps colour class as the deadline approaches.
// Copyright 2026 Nexa Systems Inc.
// License OPL-1
import { Component, useState, onWillDestroy } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
class FcPostingCountdown extends Component {
static template = "fusion_claims.PostingCountdown";
static props = { ...standardFieldProps };
setup() {
this.state = useState({ text: "", level: "info" });
this._render();
this._timer = setInterval(() => this._render(), 60_000);
onWillDestroy(() => {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
});
}
_render() {
const deadline = this.props.record.data[this.props.name];
if (!deadline) {
this.state.text = "";
this.state.level = "muted";
return;
}
// Odoo provides a luxon DateTime for Datetime fields
const now = luxon.DateTime.now();
const diff = deadline.diff(now, ["days", "hours", "minutes"]).toObject();
if (diff.days < 0 || (diff.days === 0 && diff.hours < 0)) {
this.state.text = "Cutoff passed";
this.state.level = "muted";
return;
}
const days = Math.floor(diff.days);
const hours = Math.floor(diff.hours);
if (days < 1) {
this.state.text = `${hours}h to cutoff`;
this.state.level = "danger";
} else if (days < 3) {
this.state.text = `${days}d ${hours}h to cutoff`;
this.state.level = "warning";
} else {
this.state.text = `${days} days to cutoff`;
this.state.level = "info";
}
}
}
registry.category("fields").add("fc_posting_countdown", {
component: FcPostingCountdown,
});

View File

@@ -0,0 +1,81 @@
// =============================================================================
// Fusion Claims Dashboard — Palette Tokens
// Compile-time branch on $o-webclient-color-scheme so the same SCSS file
// produces different palettes in web.assets_backend (light) and
// web.assets_web_dark (dark). Tokens load FIRST in each bundle.
// =============================================================================
$o-webclient-color-scheme: bright !default;
// ---------- LIGHT (defaults) ----------
$_fc-page-bg: #f7f7f8 !default;
$_fc-card-bg: #ffffff !default;
$_fc-card-border: #d8dadd !default;
$_fc-text: #2b2b2b !default;
$_fc-text-muted: #6c7480 !default;
$_fc-banner-from: #eef2ff !default;
$_fc-banner-to: #fce7f3 !default;
$_fc-banner-border: #c7d2fe !default;
$_fc-banner-text: #3730a3 !default;
$_fc-deadline-text: #b91c1c !default;
$_fc-kpi-bg: #f0f4ff !default;
$_fc-kpi-border: #c7d2fe !default;
$_fc-kpi-num: #1e3a8a !default;
$_fc-action-bg: #ecfdf5 !default;
$_fc-action-border: #6ee7b7 !default;
$_fc-action-text: #047857 !default;
$_fc-tile-bg: #f3f4f6 !default;
$_fc-tile-border: #e5e7eb !default;
$_fc-tile-num: #111827 !default;
$_fc-urgent-bg: #fee2e2 !default;
$_fc-urgent-border: #fca5a5 !default;
$_fc-urgent-num: #991b1b !default;
$_fc-urgent-text: #7f1d1d !default;
$_fc-activity-bg: #fefce8 !default;
$_fc-activity-border: #fde047 !default;
$_fc-bottleneck-bg: #fef2f2 !default;
$_fc-bottleneck-border: #fecaca !default;
// ---------- DARK overrides ----------
@if $o-webclient-color-scheme == dark {
$_fc-page-bg: #1a1d21 !global;
$_fc-card-bg: #22262d !global;
$_fc-card-border: #3a3f47 !global;
$_fc-text: #e5e7eb !global;
$_fc-text-muted: #9ca3af !global;
// Cool blue monochrome banner (selected option A from brainstorm)
$_fc-banner-from: #1e293b !global;
$_fc-banner-to: #1e3a5f !global;
$_fc-banner-border: #3b82f6 !global;
$_fc-banner-text: #93c5fd !global;
$_fc-deadline-text: #fca5a5 !global;
$_fc-kpi-bg: #1e293b !global;
$_fc-kpi-border: #334155 !global;
$_fc-kpi-num: #93c5fd !global;
$_fc-action-bg: #064e3b !global;
$_fc-action-border: #047857 !global;
$_fc-action-text: #6ee7b7 !global;
$_fc-tile-bg: #2d3138 !global;
$_fc-tile-border: #3a3f47 !global;
$_fc-tile-num: #f3f4f6 !global;
$_fc-urgent-bg: #4a1414 !global;
$_fc-urgent-border: #7f1d1d !global;
$_fc-urgent-num: #fca5a5 !global;
$_fc-urgent-text: #fecaca !global;
$_fc-activity-bg: #3a2e0a !global;
$_fc-activity-border: #854d0e !global;
$_fc-bottleneck-bg: #3a1414 !global;
$_fc-bottleneck-border: #7f1d1d !global;
}

View File

@@ -0,0 +1,282 @@
// =============================================================================
// Fusion Claims Dashboard — Layout & Section Styles
// Consumes tokens from _fc_dashboard_tokens.scss (must load FIRST in bundle).
// =============================================================================
// =============================================================================
// Force full-width sheet on the dashboard. The sheet defaults to ~1100-1300px
// max-width via `flex: 1 1 <fixed>` plus a CSS max-width. We override both
// at every possible nesting level + use !important to beat media-query rules.
// =============================================================================
// 1. The sheet itself
.o_fc_dashboard_sheet,
.o_form_sheet.o_fc_dashboard_sheet,
.o_form_view .o_fc_dashboard_sheet,
.o_form_renderer .o_fc_dashboard_sheet {
max-width: 100% !important;
width: 100% !important;
min-width: 100% !important;
flex: 1 1 100% !important;
flex-basis: 100% !important;
margin: 0 !important;
}
// 2. The sheet-bg wrapper around the sheet
.o_form_view:has(.o_fc_dashboard_sheet) .o_form_sheet_bg,
.o_form_renderer:has(.o_fc_dashboard_sheet) .o_form_sheet_bg,
.o_form_sheet_bg:has(> .o_fc_dashboard_sheet) {
max-width: 100% !important;
width: 100% !important;
flex: 1 1 100% !important;
}
// 3. The form view itself
.o_form_view.o_fc_dashboard,
.o_form_view:has(.o_fc_dashboard_sheet) {
max-width: 100% !important;
width: 100% !important;
}
// 4. Legacy fallback (older Odoo selector pattern)
.o_fc_dashboard .o_form_sheet {
max-width: 100% !important;
width: 100% !important;
flex: 1 1 100% !important;
}
.o_fc_dashboard {
// Re-export tokens as CSS custom properties for devtools inspection
--fc-page-bg: #{$_fc-page-bg};
--fc-card-bg: #{$_fc-card-bg};
--fc-card-border: #{$_fc-card-border};
--fc-text: #{$_fc-text};
--fc-text-muted: #{$_fc-text-muted};
--fc-banner-from: #{$_fc-banner-from};
--fc-banner-to: #{$_fc-banner-to};
--fc-banner-border: #{$_fc-banner-border};
--fc-banner-text: #{$_fc-banner-text};
--fc-deadline-text: #{$_fc-deadline-text};
--fc-kpi-bg: #{$_fc-kpi-bg};
--fc-kpi-border: #{$_fc-kpi-border};
--fc-kpi-num: #{$_fc-kpi-num};
--fc-action-bg: #{$_fc-action-bg};
--fc-action-border: #{$_fc-action-border};
--fc-action-text: #{$_fc-action-text};
--fc-tile-bg: #{$_fc-tile-bg};
--fc-tile-border: #{$_fc-tile-border};
--fc-tile-num: #{$_fc-tile-num};
--fc-urgent-bg: #{$_fc-urgent-bg};
--fc-urgent-border: #{$_fc-urgent-border};
--fc-urgent-num: #{$_fc-urgent-num};
--fc-urgent-text: #{$_fc-urgent-text};
--fc-activity-bg: #{$_fc-activity-bg};
--fc-activity-border: #{$_fc-activity-border};
--fc-bottleneck-bg: #{$_fc-bottleneck-bg};
--fc-bottleneck-border: #{$_fc-bottleneck-border};
background: var(--fc-page-bg);
color: $_fc-text;
.o_fc_banner {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(90deg, var(--fc-banner-from), var(--fc-banner-to));
border: 1px solid var(--fc-banner-border);
border-radius: 8px;
padding: 10px 14px;
font-weight: 600;
color: var(--fc-banner-text);
}
.o_fc_banner__deadline { font-weight: 700; }
.o_fc_kpi {
background: var(--fc-kpi-bg);
border: 1px solid var(--fc-kpi-border);
border-radius: 8px;
padding: 14px 10px;
text-align: center;
transition: transform 0.15s ease;
&:hover { transform: translateY(-2px); }
}
.o_fc_kpi__num {
display: block;
font-size: 1.6rem;
font-weight: 700;
color: var(--fc-kpi-num);
}
.o_fc_kpi__lbl {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--fc-text-muted);
margin-top: 2px;
}
// Secondary KPI variant — smaller, denser. Used for "This Month" and
// "Pipeline by stage" tile strips.
.o_fc_kpi--secondary {
padding: 10px 6px;
.o_fc_kpi__num { font-size: 1.15rem; }
.o_fc_kpi__lbl { font-size: 0.68rem; }
}
.o_fc_actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.o_fc_pill {
background: var(--fc-action-bg);
border: 1px solid var(--fc-action-border);
color: var(--fc-action-text);
border-radius: 16px;
padding: 5px 12px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
&:hover { background: var(--fc-action-border); }
}
.o_fc_section {
background: var(--fc-card-bg);
border: 1px solid var(--fc-card-border);
border-radius: 8px;
padding: 10px 12px;
}
.o_fc_h6 {
display: flex;
align-items: center;
font-size: 0.9rem;
font-weight: 700;
margin-bottom: 8px;
color: var(--fc-text);
}
.o_fc_tag {
display: inline-block;
font-size: 0.65rem;
padding: 2px 7px;
border-radius: 4px;
background: var(--fc-banner-border);
color: var(--fc-banner-text);
margin-left: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.o_fc_tile {
background: var(--fc-tile-bg);
border: 1px solid var(--fc-tile-border);
border-radius: 6px;
padding: 8px 6px;
text-align: center;
font-size: 0.75rem;
line-height: 1.3;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
}
.o_fc_tile__num {
display: block;
font-size: 1.3rem;
font-weight: 700;
color: var(--fc-tile-num);
margin-bottom: 2px;
}
.o_fc_tile--urgent {
background: var(--fc-urgent-bg);
border-color: var(--fc-urgent-border);
color: var(--fc-urgent-text);
.o_fc_tile__num { color: var(--fc-urgent-num); }
}
.o_fc_activities {
background: var(--fc-activity-bg);
border: 1px solid var(--fc-activity-border);
border-radius: 8px;
padding: 10px 12px;
}
.o_fc_activity_row {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px dashed var(--fc-card-border);
font-size: 0.85rem;
&:last-child { border-bottom: none; }
}
.o_fc_activity_overdue {
color: var(--fc-urgent-text);
font-weight: 600;
}
.o_fc_activity_deadline { color: var(--fc-text-muted); }
.o_fc_empty {
color: var(--fc-text-muted);
font-style: italic;
text-align: center;
padding: 12px;
margin: 0;
}
.o_fc_bottleneck {
background: var(--fc-bottleneck-bg);
border: 1px solid var(--fc-bottleneck-border);
border-radius: 8px;
padding: 10px 12px;
}
.o_fc_bottleneck_row {
display: block;
width: 100%;
text-align: left;
padding: 4px 0;
color: var(--fc-text);
text-decoration: none;
&:hover { color: var(--fc-urgent-num); text-decoration: underline; }
}
// Recent ADP Exports list rows
.o_fc_export_row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px dashed var(--fc-card-border);
font-size: 0.85rem;
&:last-child { border-bottom: none; }
}
.o_fc_export_label small {
color: var(--fc-text-muted);
font-size: 0.72rem;
}
.o_fc_export_amount {
font-weight: 700;
color: var(--fc-kpi-num);
font-variant-numeric: tabular-nums;
}
// Countdown widget colour levels (driven by OWL state)
.o_fc_countdown {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-weight: 700;
font-size: 0.85rem;
}
.o_fc_countdown--info { color: var(--fc-banner-text); }
.o_fc_countdown--warning { color: #d97706; } // amber (intentional fixed hex)
.o_fc_countdown--danger { color: var(--fc-urgent-num); }
.o_fc_countdown--muted { color: var(--fc-text-muted); font-style: italic; }
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_claims.PostingCountdown">
<span t-attf-class="o_fc_countdown o_fc_countdown--{{state.level}}"
t-esc="state.text"/>
</t>
</templates>

View File

@@ -2,3 +2,4 @@
from . import test_signed_pages_gate
from . import test_application_received_wizard
from . import test_dashboard

View File

@@ -0,0 +1,367 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fusion_claims')
class TestFusionClaimsDashboard(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Dashboard = cls.env['fusion.claims.dashboard']
cls.User = cls.env['res.users']
cls.Partner = cls.env['res.partner']
# Manager user (sees everything)
cls.manager = cls.User.create({
'name': 'Test Dashboard Manager',
'login': 'test_dash_mgr',
'group_ids': [
(4, cls.env.ref('fusion_claims.group_fusion_claims_manager').id),
(4, cls.env.ref('sales_team.group_sale_salesman').id),
],
})
# Sales rep (sees only own cases)
cls.salesrep = cls.User.create({
'name': 'Test Dashboard Salesrep',
'login': 'test_dash_rep',
'group_ids': [
(4, cls.env.ref('fusion_claims.group_fusion_claims_user').id),
(4, cls.env.ref('sales_team.group_sale_salesman').id),
],
})
cls.partner = cls.Partner.create({'name': 'Test Client'})
@classmethod
def _make_invoice(cls, user, billing_status, amount=1000.0,
exported=False, export_date=None,
invoice_type='adp', payment_state='not_paid'):
"""Helper: create a posted ADP invoice linked to an SO owned by `user`."""
so = cls.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': cls.partner.id,
'user_id': user.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'approved',
})
invoice = cls.env['account.move'].with_context(skip_sync=True).create({
'move_type': 'out_invoice',
'partner_id': cls.partner.id,
'x_fc_source_sale_order_id': so.id,
'x_fc_invoice_type': invoice_type,
'x_fc_adp_billing_status': billing_status,
'adp_exported': exported,
'adp_export_date': export_date,
'invoice_line_ids': [(0, 0, {
'name': 'Test line',
'quantity': 1.0,
'price_unit': amount,
'tax_ids': [(5, 0)], # clear taxes so amount_total == price_unit
})],
})
invoice.action_post()
invoice.with_context(skip_sync=True).write({'payment_state': payment_state})
return invoice
def test_dashboard_record_creates(self):
dashboard = self.Dashboard.create({})
self.assertTrue(dashboard.id, "Dashboard record should be creatable")
self.assertEqual(dashboard.name, 'Dashboard')
def test_role_filter_empty_for_manager(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard._role_filter_domain(), [],
"Manager should see all cases (empty domain)")
def test_role_filter_restricts_for_salesrep(self):
dashboard = self.Dashboard.with_user(self.salesrep).create({})
domain = dashboard._role_filter_domain()
self.assertEqual(domain, [('user_id', '=', self.salesrep.id)],
"Sales rep should see only their own SOs")
def test_is_manager_true_for_manager(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertTrue(dashboard.is_manager)
def test_is_manager_false_for_salesrep(self):
dashboard = self.Dashboard.with_user(self.salesrep).create({})
self.assertFalse(dashboard.is_manager)
# -------------------------------------------------------------------------
# Task 2 — Banner
# -------------------------------------------------------------------------
def test_banner_posting_period_label_format(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
label = dashboard.posting_period_label
self.assertTrue(any(month in label
for month in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']),
"Label should contain a month abbreviation")
def test_banner_posting_period_start_and_end_are_dates(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertTrue(dashboard.posting_period_start)
self.assertTrue(dashboard.posting_period_end)
delta = (dashboard.posting_period_end - dashboard.posting_period_start).days
self.assertEqual(delta, 14)
def test_banner_submission_deadline_is_wednesday_6pm(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
deadline = dashboard.submission_deadline_dt
self.assertTrue(deadline, "Deadline should be set")
# Stored in UTC; convert to user's TZ to assert the wall-clock weekday/hour
import pytz
tz = pytz.timezone(self.manager.tz or 'America/Toronto')
local = pytz.UTC.localize(deadline).astimezone(tz)
self.assertEqual(local.weekday(), 2, "Deadline should be Wednesday")
self.assertEqual(local.hour, 18, "Deadline should be 18:00 (6 PM)")
def test_is_pre_first_posting_false_when_today_is_past_base_date(self):
# Test runs after 2026-01-23 by default.
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertFalse(dashboard.is_pre_first_posting)
# -------------------------------------------------------------------------
# Task 3 — KPI tiles
# -------------------------------------------------------------------------
def test_kpi_ready_counts_waiting_invoices_not_exported(self):
self._make_invoice(self.manager, 'waiting', amount=500.0, exported=False)
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.kpi_ready_count, 1)
self.assertAlmostEqual(dashboard.kpi_ready_amount, 500.0, places=2)
def test_kpi_ready_excludes_already_exported(self):
from datetime import date
self._make_invoice(self.manager, 'waiting', amount=500.0,
exported=True, export_date=date.today())
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.kpi_ready_count, 0)
self.assertAlmostEqual(dashboard.kpi_ready_amount, 0.0, places=2)
def test_kpi_claimed_counts_exported_in_current_period(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
in_period_date = dashboard.posting_period_start
self._make_invoice(self.manager, 'submitted', amount=700.0,
exported=True, export_date=in_period_date)
dashboard2 = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard2.kpi_claimed_count, 1)
self.assertAlmostEqual(dashboard2.kpi_claimed_amount, 700.0, places=2)
def test_kpi_ar_counts_posted_unpaid_adp_invoices(self):
self._make_invoice(self.manager, 'submitted', amount=2000.0,
exported=True, payment_state='not_paid')
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.kpi_ar_count, 1)
self.assertAlmostEqual(dashboard.kpi_ar_amount, 2000.0, places=2)
def test_kpi_ready_respects_role_filter(self):
self._make_invoice(self.manager, 'waiting', amount=500.0)
dashboard_rep = self.Dashboard.with_user(self.salesrep).create({})
self.assertEqual(dashboard_rep.kpi_ready_count, 0,
"Salesrep must not see manager's invoice")
# -------------------------------------------------------------------------
# Task 4 — Activities + bottlenecks
# -------------------------------------------------------------------------
def test_my_activities_count_zero_when_none(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.my_activities_count, 0)
def test_my_activities_count_picks_up_user_activity(self):
so = self.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': self.partner.id,
'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
})
self.env['mail.activity'].create({
'res_model_id': self.env['ir.model']._get('sale.order').id,
'res_id': so.id,
'res_model': 'sale.order',
'user_id': self.manager.id,
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'summary': 'Test activity',
})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.my_activities_count, 1)
self.assertIn('Test activity', dashboard.my_activities_html or '')
def test_bottleneck_no_pod_count(self):
self.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': self.partner.id,
'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'approved',
})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.bottleneck_no_pod_count, 1)
def test_bottleneck_no_response_count(self):
from datetime import date, timedelta
old_date = date.today() - timedelta(days=20)
self.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': self.partner.id,
'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'submitted',
'x_fc_claim_submission_date': old_date,
})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.bottleneck_no_response_count, 1)
# -------------------------------------------------------------------------
# Task 5 — Other funder counts
# -------------------------------------------------------------------------
def test_other_funder_counts_segregate_by_sale_type(self):
SO = self.env['sale.order'].with_context(skip_status_validation=True)
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'odsp'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'wsib'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'insurance'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'muscular_dystrophy'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'hardship'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp', 'x_fc_client_type': 'ACS'})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.count_odsp, 1)
self.assertEqual(dashboard.count_wsib, 1)
self.assertEqual(dashboard.count_insurance, 1)
self.assertEqual(dashboard.count_mdc, 1)
self.assertEqual(dashboard.count_hardship, 1)
self.assertEqual(dashboard.count_acsd, 1)
def test_other_funder_counts_exclude_cancelled(self):
so = self.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'wsib',
})
so.with_context(skip_status_validation=True).write({'state': 'cancel'})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.count_wsib, 0)
# -------------------------------------------------------------------------
# Task 6 — ADP + MOD workflow counts
# -------------------------------------------------------------------------
def test_adp_pre_approval_tile_counts(self):
SO = self.env['sale.order'].with_context(skip_status_validation=True)
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'waiting_for_application'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'application_received'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'ready_submission'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'needs_correction'})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.adp_waiting_app_count, 1)
self.assertEqual(dashboard.adp_app_received_count, 1)
self.assertEqual(dashboard.adp_ready_submit_count, 1)
self.assertEqual(dashboard.adp_needs_correction_count, 1)
def test_adp_post_approval_tile_counts(self):
SO = self.env['sale.order'].with_context(skip_status_validation=True)
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'approved'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'ready_delivery'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'ready_bill'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'on_hold'})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.adp_approved_count, 1)
self.assertEqual(dashboard.adp_ready_delivery_count, 1)
self.assertEqual(dashboard.adp_ready_bill_count, 1)
self.assertEqual(dashboard.adp_on_hold_count, 1)
def test_mod_tile_counts(self):
SO = self.env['sale.order'].with_context(skip_status_validation=True)
for status in ('awaiting_funding', 'funding_approved', 'contract_received',
'project_complete', 'pod_submitted'):
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'march_of_dimes',
'x_fc_mod_status': status})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.mod_awaiting_funding_count, 1)
self.assertEqual(dashboard.mod_funding_approved_count, 1)
self.assertEqual(dashboard.mod_pca_received_count, 1)
self.assertEqual(dashboard.mod_project_complete_count, 1)
self.assertEqual(dashboard.mod_pod_submitted_count, 1)
# -------------------------------------------------------------------------
# Task 7 — Open-list action methods
# -------------------------------------------------------------------------
def test_action_open_adp_waiting_app_returns_correct_domain(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_open_adp_waiting_app()
self.assertEqual(action['res_model'], 'sale.order')
self.assertIn(('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed']),
action['domain'])
def test_action_open_bottleneck_no_pod_returns_correct_domain(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_open_bottleneck_no_pod()
self.assertEqual(action['res_model'], 'sale.order')
self.assertIn(('x_fc_proof_of_delivery', '=', False), action['domain'])
def test_action_open_mod_awaiting_funding_returns_correct_domain(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_open_mod_awaiting_funding()
self.assertEqual(action['res_model'], 'sale.order')
self.assertIn(('x_fc_mod_status', '=', 'awaiting_funding'), action['domain'])
def test_action_open_my_activities_returns_activity_model(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_open_my_activities()
self.assertEqual(action['res_model'], 'mail.activity')
# -------------------------------------------------------------------------
# Task 8 — Create-SO hotlinks
# -------------------------------------------------------------------------
def test_action_create_adp_so_has_default_sale_type(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_create_adp_so()
self.assertEqual(action['res_model'], 'sale.order')
self.assertEqual(action['view_mode'], 'form')
self.assertEqual(action['context']['default_x_fc_sale_type'], 'adp')
def test_action_create_mod_so_has_default_sale_type(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_create_mod_so()
self.assertEqual(action['context']['default_x_fc_sale_type'], 'march_of_dimes')
def test_action_create_odsp_so_has_division_default(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_create_odsp_so()
self.assertEqual(action['context']['default_x_fc_sale_type'], 'odsp')
self.assertEqual(action['context']['default_x_fc_odsp_division'], 'standard')
def test_all_create_so_actions_exist(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
for method_name, expected_type in [
('action_create_adp_so', 'adp'),
('action_create_mod_so', 'march_of_dimes'),
('action_create_odsp_so', 'odsp'),
('action_create_wsib_so', 'wsib'),
('action_create_insurance_so', 'insurance'),
('action_create_mdc_so', 'muscular_dystrophy'),
('action_create_hardship_so', 'hardship'),
('action_create_private_so', 'direct_private'),
]:
action = getattr(dashboard, method_name)()
self.assertEqual(action['res_model'], 'sale.order')
self.assertEqual(action['context']['default_x_fc_sale_type'], expected_type,
f"{method_name} returned wrong default sale type")

View File

@@ -4,151 +4,536 @@
<field name="name">fusion.claims.dashboard.form</field>
<field name="model">fusion.claims.dashboard</field>
<field name="arch" type="xml">
<form string="Dashboard" create="0" delete="0">
<sheet>
<!-- ===== FUNDING CARDS (one line, bigger) ===== -->
<div class="d-flex flex-nowrap gap-2 mb-4 overflow-auto">
<div invisible="adp_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_adp" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="adp_count"/></div>
<div style="font-size: 0.85rem;">ADP</div>
</div>
</button>
<form string="Dashboard" create="0" delete="0" edit="0"
class="o_fc_dashboard">
<sheet class="o_fc_dashboard_sheet">
<!-- Hidden invariants used by buttons + widgets -->
<field name="currency_id" invisible="1"/>
<field name="posting_period_start" invisible="1"/>
<field name="is_manager" invisible="1"/>
<field name="is_pre_first_posting" invisible="1"/>
<!-- BANNER -->
<div class="o_fc_banner mb-3">
<div class="o_fc_banner__label">
<i class="fa fa-calendar me-2"/>
<span>Posting Period: </span>
<field name="posting_period_label" nolabel="1"
class="fw-bold"/>
</div>
<div invisible="odsp_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_odsp" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="odsp_count"/></div>
<div style="font-size: 0.85rem;">ODSP</div>
</div>
</button>
</div>
<div invisible="march_of_dimes_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_march" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="march_of_dimes_count"/></div>
<div style="font-size: 0.85rem;">March of Dimes</div>
</div>
</button>
</div>
<div invisible="hardship_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_hardship" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="hardship_count"/></div>
<div style="font-size: 0.85rem;">Hardship</div>
</div>
</button>
</div>
<div invisible="acsd_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_acsd" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="acsd_count"/></div>
<div style="font-size: 0.85rem;">ACSD</div>
</div>
</button>
</div>
<div invisible="muscular_dystrophy_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_muscular" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="muscular_dystrophy_count"/></div>
<div style="font-size: 0.85rem;">Muscular Dystrophy</div>
</div>
</button>
</div>
<div invisible="insurance_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_insurance" type="object" class="btn p-0 w-100 border-0">
<div class="text-dark text-center py-3 px-2" style="background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="insurance_count"/></div>
<div style="font-size: 0.85rem;">Insurance</div>
</div>
</button>
</div>
<div invisible="wsib_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_wsib" type="object" class="btn p-0 w-100 border-0">
<div class="text-dark text-center py-3 px-2" style="background: linear-gradient(135deg, #ff9a9e 0%, #fad0c4 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="wsib_count"/></div>
<div style="font-size: 0.85rem;">WSIB</div>
</div>
</button>
</div>
<div invisible="total_profiles == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_profiles" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #30cfd0 0%, #330867 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="total_profiles"/></div>
<div style="font-size: 0.85rem;">Profiles</div>
</div>
</button>
<div class="o_fc_banner__deadline">
<field name="submission_deadline_dt"
widget="fc_posting_countdown"
nolabel="1" readonly="1"/>
</div>
</div>
<!-- ===== PANEL SELECTORS (4 dropdowns) ===== -->
<!-- "Showing your cases" hint when role-filtered -->
<div class="alert alert-info py-2 mb-2"
invisible="is_manager">
Showing your assigned cases only.
</div>
<!-- KPI TILES (3-up) -->
<div class="row g-2 mb-3">
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 1</div>
<field name="panel1_type" nolabel="1"/>
<div class="col-12 col-md-4">
<button name="action_open_kpi_ready" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi">
<span class="o_fc_kpi__num">
<field name="kpi_ready_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Ready to Claim
(<field name="kpi_ready_count" nolabel="1"/>)
</span>
</div>
</button>
</div>
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 2</div>
<field name="panel2_type" nolabel="1"/>
<div class="col-12 col-md-4">
<button name="action_open_kpi_claimed" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi">
<span class="o_fc_kpi__num">
<field name="kpi_claimed_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Claimed This Period
(<field name="kpi_claimed_count" nolabel="1"/>)
</span>
</div>
</button>
</div>
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 3</div>
<field name="panel3_type" nolabel="1"/>
</div>
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 4</div>
<field name="panel4_type" nolabel="1"/>
<div class="col-12 col-md-4">
<button name="action_open_kpi_ar" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi">
<span class="o_fc_kpi__num">
<field name="kpi_ar_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Total AR
(<field name="kpi_ar_count" nolabel="1"/>)
</span>
</div>
</button>
</div>
</div>
<!-- ===== TOP PANELS ROW 1 ===== -->
<div class="row g-3 mb-3">
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<field name="panel1_title" nolabel="1"/>
<!-- THIS MONTH ROLLUP (4 count tiles) -->
<div class="row g-2 mb-3">
<div class="col-6 col-md-3">
<button name="action_open_month_submitted" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num"><field name="count_month_submitted" nolabel="1"/></span>
<span class="o_fc_kpi__lbl">Submitted MTD</span>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel1_html" class="w-100" nolabel="1"/>
</div>
</div>
</button>
</div>
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<field name="panel2_title" nolabel="1"/>
<div class="col-6 col-md-3">
<button name="action_open_month_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num"><field name="count_month_approved" nolabel="1"/></span>
<span class="o_fc_kpi__lbl">Approved MTD</span>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel2_html" class="w-100" nolabel="1"/>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_month_delivered" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num"><field name="count_month_delivered" nolabel="1"/></span>
<span class="o_fc_kpi__lbl">Delivered MTD</span>
</div>
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_month_billed" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num"><field name="count_month_billed" nolabel="1"/></span>
<span class="o_fc_kpi__lbl">Billed MTD</span>
</div>
</button>
</div>
</div>
<!-- ===== TOP PANELS ROW 2 ===== -->
<!-- PIPELINE $ BY STAGE (4 amount tiles) -->
<div class="row g-2 mb-3">
<div class="col-6 col-md-3">
<button name="action_open_pipeline_pre" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num">
<field name="pipeline_pre_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Pipeline · Pre-Submit</span>
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_pipeline_submitted" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num">
<field name="pipeline_submitted_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Pipeline · Submitted</span>
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num">
<field name="pipeline_approved_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Pipeline · Approved</span>
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_bill" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num">
<field name="pipeline_ready_bill_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Pipeline · Ready to Bill</span>
</div>
</button>
</div>
</div>
<!-- QUICK ACTION PILLS -->
<div class="o_fc_actions mb-3">
<button name="action_create_adp_so" type="object"
class="o_fc_pill">+ ADP</button>
<button name="action_create_mod_so" type="object"
class="o_fc_pill">+ MOD</button>
<button name="action_create_odsp_so" type="object"
class="o_fc_pill">+ ODSP</button>
<button name="action_create_wsib_so" type="object"
class="o_fc_pill">+ WSIB</button>
<button name="action_create_insurance_so" type="object"
class="o_fc_pill">+ Insurance</button>
<button name="action_create_mdc_so" type="object"
class="o_fc_pill">+ MDC</button>
<button name="action_create_hardship_so" type="object"
class="o_fc_pill">+ Hardship</button>
<button name="action_create_private_so" type="object"
class="o_fc_pill">+ Private</button>
</div>
<!-- RESPONSIVE GRID — 5/7 on lg, 3/5/4 on xl (≥1200px) -->
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<field name="panel3_title" nolabel="1"/>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel3_html" class="w-100" nolabel="1"/>
</div>
<!-- COLUMN 1: Personal / actionable (Activities + Bottlenecks) -->
<div class="col-12 col-lg-5 col-xl-3">
<!-- Your Activities -->
<div class="o_fc_activities mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-thumb-tack me-2"/>
Your Activities
<span class="o_fc_tag">
<field name="my_activities_count" nolabel="1"/>
</span>
<button name="action_open_my_activities" type="object"
class="btn btn-link btn-sm ms-auto p-0">
View all
</button>
</h6>
<field name="my_activities_html" nolabel="1"/>
</div>
<!-- Bottlenecks -->
<div class="o_fc_bottleneck mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-exclamation-triangle me-2"/>
Bottlenecks
</h6>
<button name="action_open_bottleneck_no_pod" type="object"
class="o_fc_bottleneck_row btn btn-link p-0">
Approved without POD:
<span class="fw-bold ms-1">
<field name="bottleneck_no_pod_count" nolabel="1"/>
</span>
</button>
<button name="action_open_bottleneck_no_response" type="object"
class="o_fc_bottleneck_row btn btn-link p-0">
Submitted &gt; 14d, no response:
<span class="fw-bold ms-1">
<field name="bottleneck_no_response_count" nolabel="1"/>
</span>
</button>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">
<field name="panel4_title" nolabel="1"/>
<!-- COLUMN 2: Workflow center (ADP + MOD) -->
<div class="col-12 col-lg-7 col-xl-5">
<!-- ADP Pre-Approval -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">ADP
<span class="o_fc_tag">Pre-Approval</span>
</h6>
<div class="row g-2">
<div class="col-6 col-md-3">
<button name="action_open_adp_waiting_app" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="adp_waiting_app_count" nolabel="1"/>
</span>Waiting App
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_app_received" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_app_received_count" nolabel="1"/>
</span>App Received
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_submit" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_ready_submit_count" nolabel="1"/>
</span>Ready Submit
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_needs_correction" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="adp_needs_correction_count" nolabel="1"/>
</span>Needs Correction
</div>
</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel4_html" class="w-100" nolabel="1"/>
</div>
<!-- ADP Post-Approval -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">ADP
<span class="o_fc_tag">Post-Approval</span>
</h6>
<div class="row g-2">
<div class="col-6 col-md-3">
<button name="action_open_adp_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_approved_count" nolabel="1"/>
</span>Approved
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_delivery" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_ready_delivery_count" nolabel="1"/>
</span>Ready Delivery
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_bill" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_ready_bill_count" nolabel="1"/>
</span>Ready Bill
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_on_hold" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="adp_on_hold_count" nolabel="1"/>
</span>On Hold
</div>
</button>
</div>
</div>
</div>
<!-- MOD -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">MOD</h6>
<div class="row g-2">
<div class="col-6 col-md-2">
<button name="action_open_mod_awaiting_funding" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_awaiting_funding_count" nolabel="1"/>
</span>Awaiting
</div>
</button>
</div>
<div class="col-6 col-md-2">
<button name="action_open_mod_funding_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_funding_approved_count" nolabel="1"/>
</span>Approved
</div>
</button>
</div>
<div class="col-6 col-md-2">
<button name="action_open_mod_pca_received" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_pca_received_count" nolabel="1"/>
</span>PCA
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_mod_project_complete" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_project_complete_count" nolabel="1"/>
</span>Proj. Done
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_mod_pod_submitted" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_pod_submitted_count" nolabel="1"/>
</span>POD Submitted
</div>
</button>
</div>
</div>
</div>
</div>
<!-- COLUMN 3: Analytics (Aging + Other Funders + Recent Exports)
Full-width below cols 1+2 on lg, dedicated right-column on xl -->
<div class="col-12 col-xl-4">
<!-- Aging buckets -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-clock-o me-2"/>
Aging
</h6>
<div class="row g-2">
<div class="col-4">
<button name="action_open_aging_30" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="aging_30_count" nolabel="1"/>
</span>30 59d
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_aging_60" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="aging_60_count" nolabel="1"/>
</span>60 89d
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_aging_90" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="aging_90_count" nolabel="1"/>
</span>90+ d
</div>
</button>
</div>
</div>
</div>
<!-- Other Funders -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">Other Funders</h6>
<div class="row g-2">
<div class="col-4">
<button name="action_open_odsp_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_odsp" nolabel="1"/>
</span>ODSP
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_wsib_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_wsib" nolabel="1"/>
</span>WSIB
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_insurance_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_insurance" nolabel="1"/>
</span>Insurance
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_mdc_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_mdc" nolabel="1"/>
</span>MDC
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_hardship_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_hardship" nolabel="1"/>
</span>Hardship
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_acsd_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_acsd" nolabel="1"/>
</span>ACSD
</div>
</button>
</div>
</div>
</div>
<!-- Recent ADP Exports (last 5) -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-file-text-o me-2"/>
Recent ADP Exports
<span class="o_fc_tag">
<field name="recent_exports_count" nolabel="1"/>
</span>
<button name="action_open_recent_exports" type="object"
class="btn btn-link btn-sm ms-auto p-0">
View all
</button>
</h6>
<field name="recent_exports_html" nolabel="1"/>
</div>
</div>
</div>
</sheet>
</form>
</field>
@@ -162,4 +547,13 @@
<field name="view_id" ref="view_fusion_claims_dashboard_form"/>
<field name="target">current</field>
</record>
<!-- Dashboard Menu — top of the Fusion Claims app, sequence=1 so it
renders before "All Orders" (sequence=2) and becomes the default
landing when clicking the app icon. -->
<menuitem id="menu_fusion_claims_dashboard"
name="Dashboard"
parent="menu_adp_claims_root"
action="action_fusion_claims_dashboard"
sequence="1"/>
</odoo>

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,11 +27,23 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
| **Signature unification** | All FP reports (WO Detail, CoC, CoC Chronological) now read signatures from a single source: `signer_user.x_fc_signature_image` (Plating Signature). Retired: HR Employee signature lookup AND `res.company.x_fc_coc_signature_override` (UI removed; column kept, no migration). See rule 14b. | `fusion_plating_certificates`, `fusion_plating_reports`, `fusion_plating_jobs` |
| **Report palette overhaul** | Green `res.company.primary_color` → hardcoded neutral palette: `#c1c1c1` header backgrounds, `#1d1f1e` th text, `#2e2e2e` h2/h4 titles (bumped to 20pt portrait / 22pt landscape). Grand Total row also `#c1c1c1`. Work Order Detail blue `#1a4d80` retired in favour of the same palette. Title format now "Type # Number" (Quotation # …, Sales Order # …, Invoice # …, Packing Slip # …, Work Order Traveller # …). See rule 14a. | `fusion_plating_reports` 19.0.11.14.0, `fusion_plating_jobs` 19.0.10.8.0 |
| **Report border rendering** | After two failed attempts (px→mm conversion + dpi bump; then `border-collapse: separate` single-side-per-cell), settled on **`border-collapse: collapse` + longhand borders + `background-clip: padding-box`**. Verticals are a hair softer than horizontals on entech wkhtmltopdf — accepted as the lesser evil vs misaligned tables. See rule 14a, last paragraph. **Don't retry the single-side pattern.** | `fusion_plating_reports` |
| **CoC + thickness = ONE cert (page 2 merge)** | When a customer has both `x_fc_send_coc` and `x_fc_send_thickness_report` on (or part has `certificate_requirement='coc_thickness'`), `_resolve_required_cert_types` returns **`{'coc'}` only** — the thickness data is delivered as page 2 of the CoC PDF via `_fp_merge_thickness_into_pdf`, not as a separate `thickness_report` cert. Standalone `thickness_report` certs are only created when CoC is OFF and thickness is ON (rare). The earlier "two certs" behavior was a bug — don't restore it. | `fusion_plating_jobs`, `fusion_plating_certificates` |
| **Page-break-inside: avoid placement** | When a long QWeb report dumps content into multi-page PDFs via wkhtmltopdf, the company header (rendered as `--header-html`) can overlap body content if a page break lands mid-row in a table. **Apply `page-break-inside: avoid` to `<tr>` elements** (and to wrapper `<div>`s that wrap whole logical sections like signature blocks), not to `<table>`. On entech wkhtmltopdf, `<table>`-level `page-break-inside` is unreliable when the table is long enough to definitely break; per-row is honoured. Pattern: keep individual readings/rows together so the wkhtmltopdf header zone never overlaps mid-row content. Wrap the larger logical block (cert thickness section, signature + certification statement) in `<div style="page-break-inside: avoid;">` to keep it together when it fits and naturally wrap to a fresh page when it doesn't. | `fusion_plating_reports/report/report_coc.xml` |
| **`opacity` + `italic` muted text renders jagged on entech wkhtmltopdf** | The obvious pattern for a subtle footnote — `font-style: italic; opacity: 0.7;` (used by `.fp-coc .small-label`) — produces washed-out, jagged characters that look "broken" or "messed up" on the printed PDF. Visually it reads as garbled text even though the source is clean. **Use solid grey (`color: #555`) at normal weight instead** for muted secondary text. Same workaround applies to any `opacity`-driven greyed-out element bound for wkhtmltopdf. The existing `.small-label` class still exists for legacy callers but new code should prefer an explicit `color:` style. | `fusion_plating_reports` |
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix:** size `paperformat.margin_top` to the actual rendered header height, then drop body `padding-top` to a tiny visual gap (~5mm). Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12). Update the right one and don't bleed changes across reports. **Corollary — don't use negative `margin-top` to "tighten" the gap** (e.g. `.my-page { margin-top: -10px; }` to pull the H1 up under the header). The body wrapper sits at the bottom edge of the reserved margin_top zone; any negative margin pushes content INTO the header band, where wkhtmltopdf clips the top of glyphs (looks like the title is half-eaten). If the gap really feels too big, shrink the title font instead, or reduce `paperformat.margin_top` so the entire header zone is shorter. **For customer-facing portrait reports** (SO confirmation, quote, invoice, packing slip, BoL) the canonical compact paperformat is `fusion_plating_reports.paperformat_fp_a4_portrait` (margin_top=22mm, header_spacing=3mm, keeps the standard header band). Bind it via `<field name="paperformat_id" ref="fusion_plating_reports.paperformat_fp_a4_portrait"/>` rather than creating yet-another-one. **Two compounding-padding traps to be aware of:** (1) Odoo's `.page` class has `padding: 1cm` baked in (Bootstrap-derived). If you wrap your body in `<div class="page">` AND add a body `padding-top: 15mm`, you get the paperformat margin_top + 10mm Odoo + 15mm yours = ~65mm of dead space above the title. To remove the .page contribution without losing its left/right padding, override only the top: `.fp-report.fp-sale .page { padding-top: 0 !important; }`. CoC sidesteps this by NOT using an inner `.page` div — it wraps directly in `<div class="fp-coc">` and puts padding on that. (2) The base `.fp-report table.bordered th, .fp-report table.bordered td` rule applies borders explicitly, BUT a separate cascade still bleeds borders onto NESTED `<table>` elements even when the inner table has no `.bordered` class — `border: 0 !important` on the cells does NOT reliably override it (some wkhtmltopdf rendering paths still draw the lines). **Don't use a `<table>` for non-bordered layouts** like a title/barcode strip; use `<div>` + `float: right` / flexbox instead. Saves an hour of CSS specificity arguments with wkhtmltopdf. (3) **CSS comments inside QWeb `<style>` blocks are XML-parsed** — writing `/* don't use a <table> here */` makes lxml see a literal `<table>` opening tag and the file fails to load with `XMLSyntaxError: Opening and ending tag mismatch`. Strip the angle brackets from any HTML-like literals in CSS comments: write `/* don't use a table here */` or quote it as `"<table>"`. | `fusion_plating_reports`, `report.paperformat` |
| **CoC + thickness = ONE cert (page 2 merge OR inline body)** | When a customer has both `x_fc_send_coc` and `x_fc_send_thickness_report` on (or part has `certificate_requirement='coc_thickness'`), `_resolve_required_cert_types` returns **`{'coc'}` only**. Standalone `thickness_report` certs are only created when CoC is OFF and thickness is ON (rare). The earlier "two certs" behavior was a bug — don't restore it. **Two rendering paths exist for the thickness data in the CoC PDF:** (1) **Page-2 PDF merge** via `_fp_merge_thickness_into_pdf` — used when there's a real PDF source (operator uploaded a Fischerscope PDF, or QC has `thickness_report_pdf_id`). (2) **Inline readings table in the CoC body** — used when `thickness_reading_ids` is populated but there's no PDF source (e.g. RTF upload parsed to readings, manually typed readings). Lives in `report_coc.xml` between the parts table and the signature block, gated on `doc.thickness_reading_ids`. Both can coexist on a cert — PDF merges as page 2, readings render inline; usually only one path has data per cert. | `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports` |
| **Smart-button "create or view" pattern** | For a smart button that toggles between "create" and "view" states, use **one** idempotent button with `widget="statinfo"`, not two sibling buttons gated by mutually-exclusive `invisible` expressions. Custom `<div class="o_stat_info">` without `<span class="o_stat_value">` renders awkwardly in Odoo 19 (numbers + label expected); `statinfo` handles the standard structure automatically. The action method itself should branch on whether the linked record exists (create-then-open or just open). | any module with smart buttons |
| **stock.move.name removed** | Odoo 19 dropped the `name` field on `stock.move`. Passing `name` in a create dict raises `ValueError: Invalid field 'name' on model 'stock.move'`. Use `description_picking` instead (the operator-facing line label on the picking). The DB column is gone too — `name` doesn't exist as a stored field. | any code that builds stock.move records |
| **`mail.template.body_html` is `Markup` + jsonb** | Two gotchas: (1) `tpl.body_html` returns a `markupsafe.Markup` object. `Markup.replace(old, new)` *escapes both args* — quotes in `old` become `&#39;` so the literal pre-escape string never matches. **Cast to `str(tpl.body_html)` before calling `.replace`**. (2) The DB column is `jsonb` (translatable). Direct `UPDATE ... SET body_html = '...'` SQL fails with `invalid input syntax for type json`; either use ORM `tpl.write({'body_html': ...})` or wrap raw SQL with `jsonb_build_object('en_US', ...)`. (3) Mail-template XML data files typically use `<odoo noupdate="1">` so `-u <module>` does NOT reload them — users can edit templates in the UI and the module won't overwrite. To sync XML edits to existing records, write a one-shot post-migration or update via `odoo shell`. | any code scripting `mail.template.body_html` |
| **Recordsets use `__slots__` — no transient attrs** | Odoo 19's `BaseModel` declares `__slots__ = ['env', '_ids', '_prefetch_ids']`, so `picking._my_stash = data` raises `AttributeError: 'stock.picking' object has no attribute '_my_stash'`. The error reads like a missing field but it's actually Python rejecting the assignment. Don't stash transient state on a recordset between method calls — pass it as a method arg, store on the caller's `self`, or use `env.context` for cross-frame plumbing. Caught here because `fp_receiving._fp_build_shipping_picking` tried to attach `_fp_outbound_packages` to the picking before handing off to `_fp_apply_shipping_result`; the catch-all `except Exception` swallowed it and surfaced the misleading "Carrier API call failed" wizard. | any code that wants to attach data to a recordset between calls |
| **labelary.com dependency for ZPL→PDF** | `fusion_plating_receiving` POSTs ZPL labels to `https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/` to get a PDF rasterization, so one FedEx ship call can populate both the PDF and ZPL smart buttons on the receiving form. **Privacy:** every outbound label's shipping address + tracking number leaves the network and hits labelary's servers (no payment data, but real customer info). **Operational:** anonymous tier is ~5 req/s; add an API key in the labelary helper if you ever ship more than that. PDF→ZPL is intentionally not attempted — that direction is impractical and FedEx's `/ship` endpoint only returns one format per shipment, so the carrier MUST be configured for ZPLII (not PDF) for the dual-format flow to work. Switching the carrier back to PDF will silently drop the ZPL button. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
| **FedEx ZPL ships with `^POI` — strip it** | FedEx's REST `/ship` endpoint returns ZPL with `^POI` (Print Orientation = Invert) baked in, which flips the label 180° on the printer. On a desktop direct-thermal like the Zebra ZD450 that prints upside-down for the operator, and labelary mirrors the inversion in the PDF preview. `_fp_apply_shipping_result` creates a `*-fixed.zpl` copy of the FedEx attachment with `^POI` removed and points the shipment + smart buttons at the cleaned copy; the original FedEx ZPL stays on the picking for audit. **Don't restore `^POI`** — both the PDF preview and the Zebra output need it stripped. If a future printer needs inverted orientation, configure the printer driver instead of putting `^POI` back. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
| **Per-shipment service override via `fp_service_type_override` context key** | Operator picks a FedEx service tier on `fp.receiving.x_fc_outbound_service_type` (Priority Overnight, 2Day, Ground, etc.). `action_generate_outbound_label` passes the chosen code through to `carrier.send_shipping` via `with_context(fp_service_type_override=…)`. `fusion_shipping.fusion_fedex_rest_send_shipping` reads the context key and overrides `srm.service_type` for that call only — carrier default is untouched. Empty/blank override falls back to `carrier.fedex_rest_service_type`. Only FedEx is wired up right now; mirroring this for Canada Post / UPS is a separate task. | `fusion_plating_receiving/models/fp_receiving.py``fusion_shipping/models/delivery_carrier.py` |
| **`mail.template.body_html` is `Markup` + jsonb** | Two gotchas: (1) `tpl.body_html` returns a `markupsafe.Markup` object. `Markup.replace(old, new)` *escapes both args* — quotes in `old` become `&#39;` so the literal pre-escape string never matches. **Cast to `str(tpl.body_html)` before calling `.replace`**. (2) The DB column is `jsonb` (translatable). Direct `UPDATE ... SET body_html = '...'` SQL fails with `invalid input syntax for type json`; either use ORM `tpl.write({'body_html': ...})` or wrap raw SQL with `jsonb_build_object('en_US', ...)`. (3) Mail-template XML data files typically use `<odoo noupdate="1">` so `-u <module>` does NOT reload them — users can edit templates in the UI and the module won't overwrite. To sync XML edits to existing records: temporarily flip the wrapper to `<odoo noupdate="0">`, redeploy and `-u`, then revert (and `UPDATE ir_model_data SET noupdate=true ...` to restore protection). Alternatively, post-migration script or odoo shell write. (4) **`mail.template.report_name` was removed in Odoo 19** — the dynamic PDF-filename field now lives on `ir.actions.report.print_report_name` instead. Old `<field name="report_name">` entries in mail-template data files silently survive while protected by noupdate=1, but the moment you force-reload they error with `Invalid field 'report_name' in 'mail.template'`. Strip them or move the expression to the report action. | any code scripting `mail.template.body_html` |
| **`message_post(body=...)` HTML-escapes by default** | A plain `str` body with `<b>` tags renders as literal `<b>foo</b>` text in chatter — operators see angle brackets, not bold. Wrap the template in `Markup(_('... <b>%s</b> ...'))` and use `%`/`format_map` for substitutions; markupsafe escapes the substituted values automatically so user input still can't inject HTML. Pattern: `self.message_post(body=Markup(_('Tracking: <b>%s</b>')) % tracking)`. | any model posting HTML-formatted chatter |
| **OWL `t-out` escapes plain JS strings — wrap with `markup()`** | The JS-side analogue of the `message_post` markup gotcha. `t-out="state.html"` only renders unescaped HTML when the value is a `markup()`-tagged string from `@odoo/owl`; a plain string (e.g. straight off an RPC response) gets HTML-escaped and the user sees literal `<p>foo</p>` text. Caught here because `fp_record_inputs_dialog.js` was assigning `this.state.instructionsHtml = data.instructions_html` raw — recipe author's `<p>...</p>` rendered as visible tags in the operator dialog. **Fix:** `import { markup } from "@odoo/owl"` and wrap RPC-returned HTML: `this.state.html = markup(data.html || "")`. Same rule for any OWL component that ingests HTML from the server and pushes it through `t-out`. | any OWL component rendering server-returned HTML via `t-out` |
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
| **Customer-facing reports use bilingual EN/FR labels** | Every customer-facing report label (column titles, section banners, totals, document title) renders English first and French second. **Default to inline slash format** ("English / French" on one line) — easier to scan and saves vertical space. **Use the stacked variant only for cells too narrow** for the French word to fit on the same line (QTY, UOM, narrow column headers in dense tables). CSS classes live in the `fp_sale_bilingual_styles` template in `report_fp_sale.xml`. **Inline (default):** `.fp-bl-en { font-weight:bold; }` + `.fp-bl-sep { color:#999; margin:0 3px; }` + `.fp-bl-fr { font-weight:normal; font-style:italic; color:#555; }`. Pattern: `<span class="fp-bl-en">English</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">French</span>`. **Stacked (narrow cells):** `.fp-bl-en-stk` + `.fp-bl-fr-stk` (each `display:block`). **Always render both spans even when EN and FR are the same word** (e.g. "Description / Description", "Taxes / Taxes") — visual consistency across the row matters more than the redundancy; dropping the FR span on identical-word labels leaves an obvious gap when scanning down a column of headers. When a report has a barcode block, encode `doc.name` via `ir.actions.report.barcode_data_uri('Code128', doc.name, 600, 100)` (the helper inlines a data URI — don't `/report/barcode/...` over HTTP, wkhtmltopdf network fetches fail on entech). Apply to ALL outward-facing reports (SO confirmation, quote, invoice, CoC, packing slip, BoL); internal-only reports (job traveller, WO sticker) can stay English. | `fusion_plating_reports/report/report_fp_sale.xml` (canonical), every customer-facing report |
### Pending — IN PROGRESS when this session ended

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.21.5.1',
'version': '19.0.21.5.2',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -12,6 +12,19 @@
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- Header buttons: make draft Confirm the primary CTA, demote/rename
Send to "Send Email" (red), and reorder so Confirm sits first. -->
<xpath expr="//header/button[@name='action_confirm' and not(@id)]" position="attributes">
<attribute name="class">btn-primary</attribute>
</xpath>
<xpath expr="//header/button[@id='quotation_send_primary']" position="attributes">
<attribute name="string">Send Email</attribute>
<attribute name="class">btn-danger</attribute>
</xpath>
<xpath expr="//header/button[@id='quotation_send_primary']" position="before">
<xpath expr="//header/button[@name='action_confirm' and not(@id)]" position="move"/>
</xpath>
<!-- Hide standard Delivery button: our Transfers button (below) shows
all stock.picking records - inbound receipts AND outbound deliveries -
which matches the plating workflow better than outbound-only. -->
@@ -131,11 +144,9 @@
string="Job #"/>
</list>
</field>
<!-- Row 1: RFQ/PO (left) + Scheduling (right) — pairs the two
tallest groups so neither column dangles empty. -->
<group>
<group string="Configurator (legacy)" invisible="not x_fc_configurator_id">
<field name="x_fc_configurator_id" readonly="1"/>
<field name="x_fc_process_summary" readonly="1"/>
</group>
<group string="RFQ / PO">
<field name="x_fc_po_number"/>
<field name="upload_rfq_file"
@@ -161,29 +172,6 @@
<field name="x_fc_po_override_reason"
invisible="not x_fc_po_override"/>
</group>
</group>
<group>
<group string="Invoicing">
<field name="x_fc_invoice_strategy"/>
<field name="x_fc_deposit_percent"
invisible="x_fc_invoice_strategy != 'deposit'"/>
<field name="x_fc_progress_initial_percent"
invisible="x_fc_invoice_strategy != 'progress'"/>
<field name="x_fc_final_invoice_id" readonly="1"
invisible="not x_fc_final_invoice_id"/>
</group>
<group string="Delivery">
<field name="x_fc_rush_order"/>
<field name="x_fc_delivery_method"/>
<field name="x_fc_receiving_status"/><!-- Will become computed when fusion_plating_receiving is installed -->
</group>
</group>
<group>
<group string="Customer Reference">
<field name="x_fc_customer_job_number"/>
<field name="x_fc_contact_phone"/>
<field name="x_fc_ship_via"/>
</group>
<group string="Scheduling">
<field name="x_fc_planned_start_date"/>
<field name="x_fc_internal_deadline"/>
@@ -203,7 +191,33 @@
<field name="x_fc_block_partial_shipments"/>
</group>
</group>
<!-- Row 2: Invoicing + Delivery (unchanged pairing). -->
<group>
<group string="Invoicing">
<field name="x_fc_invoice_strategy"/>
<field name="x_fc_deposit_percent"
invisible="x_fc_invoice_strategy != 'deposit'"/>
<field name="x_fc_progress_initial_percent"
invisible="x_fc_invoice_strategy != 'progress'"/>
<field name="x_fc_final_invoice_id" readonly="1"
invisible="not x_fc_final_invoice_id"/>
</group>
<group string="Delivery">
<field name="x_fc_rush_order"/>
<field name="x_fc_delivery_method"/>
<field name="x_fc_receiving_status"/><!-- Will become computed when fusion_plating_receiving is installed -->
</group>
</group>
<!-- Row 3: Customer Reference + Margin — both short groups, so
pairing them keeps the right column from going blank. -->
<group>
<group string="Customer Reference">
<field name="x_fc_customer_job_number"/>
<field name="x_fc_contact_phone"/>
<field name="x_fc_ship_via"/>
</group>
<group string="Margin">
<div colspan="2"
invisible="x_fc_margin_available"
@@ -222,14 +236,29 @@
<field name="x_fc_margin_available" invisible="1"/>
</group>
</group>
<!-- Row 4: Notes — two side-by-side textareas instead of the
previous broken separator-in-group layout. -->
<group>
<group string="Internal Notes">
<field name="x_fc_internal_note" nolabel="1"
placeholder="Internal notes for estimator / planner / shop floor..."/>
</group>
<separator string="External Notes (customer-visible)"/>
<field name="x_fc_external_note"
<group string="External Notes (customer-visible)">
<field name="x_fc_external_note" nolabel="1"
placeholder="Notes that appear on the acknowledgement and portal..."/>
</group>
</group>
<!-- Legacy configurator block — invisible on new SOs (only
the handful that came through the old quote configurator
flow have x_fc_configurator_id set). Kept at the bottom
so it doesn't waste vertical space on the common case. -->
<group invisible="not x_fc_configurator_id">
<group string="Configurator (legacy)">
<field name="x_fc_configurator_id" readonly="1"/>
<field name="x_fc_process_summary" readonly="1"/>
</group>
</group>
</page>
</xpath>
@@ -307,13 +336,13 @@
<field name="name">sale.order.list.fp</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<list string="Sale Orders" decoration-info="state == 'draft'"
<list string="Sale Orders" create="0" decoration-info="state == 'draft'"
decoration-muted="state == 'cancel'"
decoration-danger="x_fc_is_late_forecast">
<header>
<button name="%(action_fp_direct_order_wizard)d"
type="action"
string="+ New Direct Order"
string="New Order"
class="btn-primary"
display="always"/>
</header>

View File

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

View File

@@ -1540,6 +1540,23 @@ class FpJob(models.Model):
# qty tracking truly doesn't apply).
skip_qty_gate = self.env.context.get('fp_skip_qty_reconcile')
if not skip_qty_gate and job.qty:
# Smooth the typical "clean close" case so the operator
# doesn't have to manually type qty_done = ordered_qty
# every time. Conditions for safe auto-fill:
# - operator has NOT recorded any scrap or done qty
# (so we're not overriding their explicit entry)
# - the receiving closed with matching qty (parts
# physically came in as expected)
# - no visual-inspection rejects recorded
# When any of those fail, fall through to the gate so
# the operator reconciles by hand. Mirrors the receiving
# `_update_job_qty_received` pattern: server fills the
# obvious default, operator owns the edge cases.
if (not job.qty_done and not job.qty_scrapped
and not (job.qty_visual_inspection_rejects or 0)
and job.qty_received
and abs(job.qty_received - job.qty) < 0.0001):
job.qty_done = job.qty
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
if abs(accounted - job.qty) > 0.0001:
raise UserError(_(
@@ -1658,34 +1675,73 @@ class FpJob(models.Model):
look up by job_ref. Setting both ends keeps every consumer
happy.
Phase A — mirrors x_fc_carrier_id and x_fc_outbound_shipment_id
from the linked receiving so the delivery carries the shipping
choices made at receipt time. Shipping crew can override later.
Auto-populates everything we can resolve from upstream
records so the shipping crew doesn't have to re-type
addresses / contacts / dates that already exist on the SO:
- delivery_address_id, contact_name, contact_phone — SO's
partner_shipping_id (falls back to partner_id)
- scheduled_date — SO.commitment_date
- source_facility_id — job.facility_id
- x_fc_carrier_id, x_fc_outbound_shipment_id — from the
SO's first receiving record (set at receive time)
- coc_attachment_id — issued cert.attachment_id for this
job (if a CoC is already issued before delivery exists;
otherwise the cert's action_issue back-fills it later)
Everything skips silently when the source field doesn't
exist or the source value is blank, so older install
topologies and partially-configured jobs still get a
delivery — just less pre-filled.
"""
self.ensure_one()
if self.delivery_id:
return
Delivery = self.env['fusion.plating.delivery'].sudo()
vals = self._fp_resolve_delivery_defaults(Delivery)
try:
delivery = Delivery.create(vals)
self.delivery_id = delivery.id
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create delivery: %s", self.name, e,
)
def _fp_resolve_delivery_defaults(self, Delivery):
"""Build the create-vals for a fresh delivery, OR the
write-vals for refreshing an existing one. Centralised so
the create path, the per-cert post-issue sync, and any
future 'Refresh from Source' button all stay consistent.
"""
self.ensure_one()
vals = {'partner_id': self.partner_id.id}
if 'x_fc_job_id' in Delivery._fields:
vals['x_fc_job_id'] = self.id
if 'job_ref' in Delivery._fields:
vals['job_ref'] = self.name
if 'x_fc_job_id' not in Delivery._fields \
and 'job_ref' not in Delivery._fields:
_logger.warning(
"Job %s: fusion.plating.delivery has no job link field; "
"delivery created without job back-reference.", self.name,
)
# Mirror outbound carrier + shipment from the SO's first
# receiving record. If there are multiple receivings (split
# shipments), the shipping crew can change either field on the
# delivery form. Defensive: skip when fields aren't present
# (older instance) or no receiving exists.
if (self.sale_order_id
and 'x_fc_receiving_ids' in self.sale_order_id._fields
and self.sale_order_id.x_fc_receiving_ids):
recv = self.sale_order_id.x_fc_receiving_ids[:1]
# Delivery address + contact details from the SO. shipping
# partner is preferred (that's where parts physically go);
# fall back to the SO's main partner when no separate ship-to.
so = self.sale_order_id
ship_to = (so.partner_shipping_id or so.partner_id) if so else False
if ship_to:
if 'delivery_address_id' in Delivery._fields:
vals['delivery_address_id'] = ship_to.id
if 'contact_name' in Delivery._fields and ship_to.name:
vals['contact_name'] = ship_to.name
if 'contact_phone' in Delivery._fields:
vals['contact_phone'] = ship_to.phone or ship_to.mobile or ''
# Scheduled date — operator can adjust; this just primes it
# so they're not staring at a blank field.
if so and so.commitment_date and 'scheduled_date' in Delivery._fields:
vals['scheduled_date'] = so.commitment_date
# Source facility comes from the job (where it was plated).
if self.facility_id and 'source_facility_id' in Delivery._fields:
vals['source_facility_id'] = self.facility_id.id
# Outbound carrier + shipment mirrored from the SO's first
# receiving record (the crew chose these at receipt time).
if (so and 'x_fc_receiving_ids' in so._fields
and so.x_fc_receiving_ids):
recv = so.x_fc_receiving_ids[:1]
if 'x_fc_carrier_id' in Delivery._fields \
and 'x_fc_carrier_id' in recv._fields \
and recv.x_fc_carrier_id:
@@ -1696,13 +1752,21 @@ class FpJob(models.Model):
vals['x_fc_outbound_shipment_id'] = (
recv.x_fc_outbound_shipment_id.id
)
try:
delivery = Delivery.create(vals)
self.delivery_id = delivery.id
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create delivery: %s", self.name, e,
)
# CoC PDF — if a cert for this job is already issued and
# the delivery field accepts an attachment, link it. The
# cert's action_issue also calls _fp_sync_to_delivery for
# the case where the cert issues AFTER the delivery exists.
Cert = self.env.get('fp.certificate')
if Cert is not None and 'coc_attachment_id' in Delivery._fields:
issued_cert = Cert.sudo().search([
('x_fc_job_id', '=', self.id),
('certificate_type', '=', 'coc'),
('state', '=', 'issued'),
('attachment_id', '!=', False),
], order='issue_date desc, id desc', limit=1)
if issued_cert and issued_cert.attachment_id:
vals['coc_attachment_id'] = issued_cert.attachment_id.id
return vals
def _fp_create_certificates(self):
"""Auto-create one draft fp.certificate per type returned by

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Logistics',
'version': '19.0.3.9.0',
'version': '19.0.3.11.0',
'category': 'Manufacturing/Plating',
'summary': (
'Pickup & delivery for plating shops: vehicle master, driver '

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
@@ -186,9 +188,9 @@ class FpDelivery(models.Model):
}
shipment = self.env['fusion.shipment'].sudo().create(vals)
self.x_fc_outbound_shipment_id = shipment.id
self.message_post(body=_(
self.message_post(body=Markup(_(
'Outbound shipment <b>%s</b> created (draft).'
) % shipment.name)
)) % shipment.name)
return self.action_view_outbound_shipment()
def action_view_outbound_shipment(self):
@@ -258,6 +260,100 @@ class FpDelivery(models.Model):
def _fp_parent_counter_field(self):
return 'x_fc_pn_delivery_count'
def action_view_coc(self):
"""Open the certificate record this delivery's CoC PDF came
from. The attachment carries res_model + res_id, so we
navigate to that record (operator gets all cert info — issue
date, void wizard, reset, etc.) rather than just opening the
raw PDF. Falls back to opening the attachment directly if
someone manually attached a PDF that isn't a cert.
"""
self.ensure_one()
att = self.coc_attachment_id
if not att:
raise UserError(_('No CoC linked to this delivery.'))
if att.res_model == 'fp.certificate' and att.res_id:
return {
'type': 'ir.actions.act_window',
'name': _('Certificate of Conformance'),
'res_model': 'fp.certificate',
'res_id': att.res_id,
'view_mode': 'form',
'target': 'current',
}
# Plain attachment — open via PDF preview helper if available.
if hasattr(att, 'action_fusion_preview'):
return att.action_fusion_preview(title=att.name or 'CoC')
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%d?download=false' % att.id,
'target': 'new',
}
def action_view_packing_list(self):
"""Open the packing-list PDF via fusion_pdf_preview (or fall
back to a new tab when the preview helper isn't installed).
Packing lists don't have a backing model — they're attachments
only — so we don't navigate to a record.
"""
self.ensure_one()
att = self.packing_list_attachment_id
if not att:
raise UserError(_('No packing list attached to this delivery.'))
if hasattr(att, 'action_fusion_preview'):
return att.action_fusion_preview(
title=att.name or 'Packing List',
model_name=self._name,
record_ids=self.id,
)
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%d?download=false' % att.id,
'target': 'new',
}
def action_refresh_from_source(self):
"""Re-pull delivery address / contact / scheduled date / source
facility / carrier / CoC from the linked job → SO → receiving →
cert chain. Only fills BLANK fields — never overwrites operator
edits. Use when an upstream value changed after the delivery
was auto-created, or to backfill an old delivery that was
created before the auto-populate hook existed.
"""
for rec in self:
job = (rec.x_fc_job_id
if 'x_fc_job_id' in rec._fields else False)
if not job:
# Fall back via job_ref Char if M2O is empty (older data)
if rec.job_ref and 'fp.job' in self.env:
job = self.env['fp.job'].sudo().search(
[('name', '=', rec.job_ref)], limit=1,
)
if not job:
raise UserError(_(
'Delivery %s has no linked job — nothing to '
'refresh from.'
) % rec.name)
Delivery = rec.env['fusion.plating.delivery']
defaults = job._fp_resolve_delivery_defaults(Delivery)
# Drop fields the operator already filled — never clobber
# manual edits. Includes the partner/job links since those
# are non-overridable.
fill = {
k: v for k, v in defaults.items()
if v and not rec[k]
}
if not fill:
rec.message_post(body=_(
'Refresh from source: nothing to update — every '
'field already populated.'
))
continue
rec.sudo().write(fill)
rec.message_post(body=_(
'Refresh from source filled: %s'
) % ', '.join(sorted(fill.keys())))
@api.model_create_multi
def create(self, vals_list):
"""Parent-derived name (DLV-<parent>[-NN]) with legacy-sequence

View File

@@ -55,6 +55,17 @@
invisible="state in ('delivered','cancelled')"/>
<button name="action_reset_to_draft" string="Reset to Draft" type="object"
invisible="state != 'cancelled'"/>
<!-- Pulls delivery address / contact / scheduled
date / source facility / carrier / CoC from
the job → SO → receiving → cert chain. Only
fills BLANK fields, never overwrites operator
edits. Useful when upstream data changed or
to backfill an old delivery. -->
<button name="action_refresh_from_source"
string="Refresh from Source"
type="object" class="btn-secondary"
icon="fa-refresh"
invisible="state in ('delivered','cancelled')"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,scheduled,en_route,delivered"/>
</header>
@@ -68,6 +79,30 @@
widget="statinfo"
string="Outbound Shipment"/>
</button>
<!-- CoC smart button → cert record (not the
raw PDF — operator can print/reset/void
from the cert form). -->
<button name="action_view_coc"
type="object"
class="oe_stat_button"
icon="fa-certificate"
invisible="not coc_attachment_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">CoC</span>
</div>
</button>
<!-- Packing list smart button → PDF preview
dialog (packing lists are attachments
only, no backing model to navigate to). -->
<button name="action_view_packing_list"
type="object"
class="oe_stat_button"
icon="fa-list-alt"
invisible="not packing_list_attachment_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Packing List</span>
</div>
</button>
</div>
<div class="oe_title">
<label for="name"/>

View File

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

View File

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

View File

@@ -3,8 +3,11 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import base64
import logging
from types import SimpleNamespace
import requests
from markupsafe import Markup
from odoo import api, fields, models, _
@@ -12,6 +15,13 @@ from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# labelary.com — free ZPL→PDF rasterization service. 8dpmm = 203dpi
# (ZD450 default), label size 4x6 in. No API key required for the
# typical low-volume use case (limit: ~5 req/s anonymous). PDF output
# is requested via the Accept header.
_LABELARY_URL = 'https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/'
_LABELARY_TIMEOUT = 10
class FpReceiving(models.Model):
"""Parts receiving record.
@@ -101,6 +111,65 @@ class FpReceiving(models.Model):
help='Who picks up the parts when work is done. Used to generate '
'the return shipping label on the linked Outbound Shipment.',
)
x_fc_outbound_service_type = fields.Selection(
selection='_fp_get_service_type_selection',
string='Service Type',
tracking=True,
help='Override the carrier default for this shipment. Leave '
'blank to use the carrier-level default (typically Ground '
'or Priority depending on configuration). Pick a faster '
'tier (e.g. Priority Overnight) when the customer is '
'paying for expedited delivery.',
)
# Curated FedEx services for a Canadian B2B plating shop. The
# carrier-level selection (~38 options) is overwhelming and mostly
# noise — frieght tiers want 150+ lb, regional services don't apply
# to CA-origin shipments, distribution-program services need extra
# account config. Sweep against the live sandbox (see
# scripts/fp_fedex_service_matrix.py) confirmed these 12 are the
# only ones realistically usable for parts shipments. If a future
# contract enables more, append here.
_FP_USABLE_FEDEX_SERVICES = (
# CA domestic
'FEDEX_GROUND', # Cheapest, 1-7 days
'FEDEX_EXPRESS_SAVER', # 3-day economy
'FEDEX_2_DAY', # 2 business days
'FEDEX_2_DAY_AM', # 2 business days, 10:30am
'STANDARD_OVERNIGHT', # Next day, end of day
'PRIORITY_OVERNIGHT', # Next day, 10:30am
'FIRST_OVERNIGHT', # Next day, 8:00am
# International (CA -> US / EU / APAC)
'FEDEX_INTERNATIONAL_CONNECT_PLUS', # Mid-tier intl
'INTERNATIONAL_ECONOMY', # 4-5 day intl economy
'FEDEX_INTERNATIONAL_PRIORITY', # 1-3 day intl
'FEDEX_INTERNATIONAL_PRIORITY_EXPRESS', # 1-3 day intl premium
'INTERNATIONAL_FIRST', # Earliest intl available
)
@api.model
def _fp_get_service_type_selection(self):
"""Curated FedEx service selection for the receiving form.
Pulls labels from the carrier's full selection (so they match
whatever fusion_shipping ships) but filters to the codes in
_FP_USABLE_FEDEX_SERVICES. Order in the dropdown follows the
tuple — cheapest CA-domestic first, premium international last.
Empty list when fusion_shipping isn't installed.
"""
Carrier = self.env.get('delivery.carrier')
if Carrier is None:
return []
field = Carrier._fields.get('fedex_rest_service_type')
if not field:
return []
labels = dict(field.selection)
return [
(code, labels.get(code, code))
for code in self._FP_USABLE_FEDEX_SERVICES
if code in labels
]
x_fc_outbound_shipment_id = fields.Many2one(
'fusion.shipment', string='Outbound Shipment', tracking=True,
ondelete='set null',
@@ -117,13 +186,29 @@ class FpReceiving(models.Model):
help='True when the linked outbound shipment has a label PDF '
'attached. Drives the Print Label smart-button visibility.',
)
x_fc_has_label_zpl = fields.Boolean(
compute='_compute_x_fc_has_label',
help='True when the linked outbound shipment has a ZPL label '
'attached. Drives the Print ZPL smart-button visibility.',
)
x_fc_shipping_quote_html = fields.Html(
string='Shipping Quote',
readonly=True, copy=False, sanitize=False,
help='Estimated cost + delivery date from the carrier API. '
'Click "Refresh Quote" to fetch the latest. Reflects the '
'currently-selected Service Type, weight, and dimensions.',
)
@api.depends('x_fc_outbound_shipment_id.label_attachment_id')
@api.depends(
'x_fc_outbound_shipment_id.label_attachment_id',
'x_fc_outbound_shipment_id.x_fc_label_zpl_attachment_id',
)
def _compute_x_fc_has_label(self):
for rec in self:
rec.x_fc_has_label = bool(
rec.x_fc_outbound_shipment_id
and rec.x_fc_outbound_shipment_id.label_attachment_id
ship = rec.x_fc_outbound_shipment_id
rec.x_fc_has_label = bool(ship and ship.label_attachment_id)
rec.x_fc_has_label_zpl = bool(
ship and ship.x_fc_label_zpl_attachment_id
)
# ---- Phase C — Outbound packaging fields -----------------------------
@@ -293,15 +378,54 @@ class FpReceiving(models.Model):
# ---- Phase C — Generate Outbound Label -------------------------------
def action_generate_outbound_label(self):
"""One-button label generation.
"""Open the confirmation wizard before the actual API call.
Branches on carrier.delivery_type:
- 'fixed' (no API integration): opens manual entry wizard.
- 'fusion_*' (API integration): synthesizes a stock.picking,
calls the existing carrier.<provider>_send_shipping method,
copies the result back to the linked fusion.shipment.
- On API exception: falls back to the manual wizard with the
error message in the note field.
Two guards live here so the user can't accidentally bill
themselves for duplicate shipments:
1. If a label is already attached to the linked shipment,
refuse to regenerate — operator must void the shipment
first.
2. Otherwise pop fp.label.generate.wizard so the operator
confirms carrier + service tier + weight before any API
call. The wizard's action_confirm calls
_fp_actually_generate_outbound_label.
"""
self.ensure_one()
self._fp_validate_label_inputs()
if self.x_fc_outbound_shipment_id \
and self.x_fc_outbound_shipment_id.label_attachment_id:
raise UserError(_(
'A shipping label already exists for this receiving '
'(shipment %s). Void that shipment first if you need '
'to regenerate — otherwise every click would create a '
'new billable FedEx shipment with its own tracking '
'number.'
) % self.x_fc_outbound_shipment_id.name)
carrier = self.x_fc_carrier_id
if carrier.delivery_type == 'fixed':
return self._fp_open_manual_label_wizard(note=_(
'Carrier "%s" has no API integration configured. Enter '
'the label PDF and tracking number below to record the '
'shipment manually.'
) % carrier.name)
Wizard = self.env['fp.label.generate.wizard']
wiz = Wizard.create(Wizard._fp_default_from_receiving(self.env, self))
return {
'type': 'ir.actions.act_window',
'name': _('Generate Label — %s') % self.name,
'res_model': 'fp.label.generate.wizard',
'res_id': wiz.id,
'view_mode': 'form',
'target': 'new',
}
def _fp_actually_generate_outbound_label(self):
"""Make the actual carrier API call to create the shipping
label. Called by fp.label.generate.wizard.action_confirm after
the operator has confirmed service + weight in the wizard.
Same fall-through behaviour as before: API failure drops to
the manual-label wizard with the error pre-filled.
"""
self.ensure_one()
self._fp_validate_label_inputs()
@@ -320,7 +444,17 @@ class FpReceiving(models.Model):
self._fp_sync_packaging_to_shipment()
try:
picking = self._fp_build_shipping_picking()
shipping_data = carrier.send_shipping(picking)
# Per-shipment service override (e.g. Priority Overnight)
# rides through to the carrier API via context. Empty
# falls back to the carrier default. See
# fusion_shipping.fusion_fedex_rest_send_shipping for the
# consumer.
ship_carrier = carrier
if self.x_fc_outbound_service_type:
ship_carrier = carrier.with_context(
fp_service_type_override=self.x_fc_outbound_service_type,
)
shipping_data = ship_carrier.send_shipping(picking)
self._fp_apply_shipping_result(picking, shipping_data)
except UserError:
raise
@@ -413,8 +547,24 @@ class FpReceiving(models.Model):
"""Synthesize a stock.picking just to carry the data needed by
carrier.send_shipping. The picking is auto-validated to 'done'
state so it doesn't sit as draft in operator views.
Idempotent: if a prior call left a non-validated picking on
x_fc_shipping_picking_id (e.g. the API call crashed before
reaching button_validate), cancel it before building a fresh
one. Without this guard, every retry of "Generate Outbound
Label" leaks another WH/OUT picking into the Ready queue.
"""
self.ensure_one()
prior = self.x_fc_shipping_picking_id
if prior and prior.state not in ('done', 'cancel'):
try:
prior.sudo().action_cancel()
except Exception:
_logger.warning(
'Receiving %s: could not cancel stale shipping '
'picking %s (state=%s); leaving it in place.',
self.name, prior.name, prior.state,
)
Picking = self.env['stock.picking'].sudo()
warehouse = self.env['stock.warehouse'].sudo().search(
[('company_id', '=', self.env.company.id)], limit=1,
@@ -525,11 +675,6 @@ class FpReceiving(models.Model):
'location_dest_id': Move.location_dest_id.id,
'result_package_id': pkg.id,
})
# Stash packages on the picking via a transient attr so
# _fp_apply_shipping_result can walk them in the same order
# the API processes them (FedEx returns labels in the
# order packages were submitted).
picking._fp_outbound_packages = packages
self.x_fc_shipping_picking_id = picking.id
return picking
@@ -581,6 +726,72 @@ class FpReceiving(models.Model):
('res_model', '=', 'stock.picking'),
('res_id', '=', picking.id),
], order='id asc')
# Split labels by format so the two smart buttons (Print PDF /
# Print ZPL) on the receiving form each open the right file.
# FedEx names ZPL labels '...ZPLII'; PDFs are 'application/pdf'.
pdf_atts = label_atts.filtered(
lambda a: (a.mimetype or '').lower() == 'application/pdf'
or (a.name or '').lower().endswith('.pdf')
)
zpl_atts = label_atts.filtered(
lambda a: 'zpl' in (a.name or '').lower()
)
# FedEx ZPL ships with `^POI` (print-orientation invert), which
# flips the label 180° on the printer. On a desktop thermal
# like the Zebra ZD450 that comes out upside-down for the
# operator AND labelary renders the PDF preview inverted to
# match. Strip ^POI from a copy of the ZPL so both surfaces
# show right-side-up. Original FedEx ZPL on the picking is
# left untouched for audit. The cleaned copy is what operators
# see (PDF preview + ZPL download).
Attachment = self.env['ir.attachment'].sudo()
cleaned_zpl_atts = self.env['ir.attachment'].sudo()
for zpl in zpl_atts:
raw = base64.b64decode(zpl.datas) if zpl.datas else b''
if not raw:
continue
cleaned = raw.replace(b'^POI', b'')
if cleaned == raw:
# No ^POI present — keep using the original attachment.
cleaned_zpl_atts |= zpl
continue
cleaned_name = (zpl.name or 'label.zpl').rsplit('.', 1)
cleaned_name = '%s-fixed.%s' % (
cleaned_name[0],
cleaned_name[1] if len(cleaned_name) > 1 else 'zpl',
)
cleaned_zpl_atts |= Attachment.create({
'name': cleaned_name,
'res_model': 'stock.picking',
'res_id': picking.id,
'datas': base64.b64encode(cleaned),
'mimetype': 'text/plain',
})
zpl_atts = cleaned_zpl_atts or zpl_atts
# When the carrier returned ZPL but not PDF, render a PDF
# rasterization via labelary so the Print PDF smart button has
# something to open. One FedEx ship call → two smart buttons.
# Best-effort: if labelary is unreachable, the ZPL button still
# works and the operator can print from the Zebra directly.
if zpl_atts and not pdf_atts:
for zpl in zpl_atts:
pdf_bytes = self._fp_zpl_to_pdf_via_labelary(
base64.b64decode(zpl.datas) if zpl.datas else None
)
if not pdf_bytes:
continue
pdf_name = (zpl.name or 'label.zpl').rsplit('.', 1)[0] + '.pdf'
new_pdf = Attachment.create({
'name': pdf_name,
'res_model': 'stock.picking',
'res_id': picking.id,
'datas': base64.b64encode(pdf_bytes),
'mimetype': 'application/pdf',
})
pdf_atts |= new_pdf
# Primary slot keeps backward-compat: prefer PDF for the main
# button, fall back to whatever the carrier returned otherwise.
primary_atts = pdf_atts or label_atts
# Per-package shipping_data list — one entry per package.
sd_list = shipping_data if isinstance(shipping_data, list) else [
shipping_data
@@ -608,23 +819,28 @@ class FpReceiving(models.Model):
primary_tracking = per_pkg_trackings[0] if per_pkg_trackings else ''
# Write per-row labels + tracking. Attachments are paired by
# index — N labels and N rows. Excess on either side is ignored.
# Use primary_atts (PDF-preferred) so the per-row "Label" link
# opens a printable PDF, not raw ZPL.
for idx, row in enumerate(rows):
row_vals = {}
if idx < len(per_pkg_trackings):
row_vals['tracking_number'] = per_pkg_trackings[idx]
if idx < len(label_atts):
row_vals['label_attachment_id'] = label_atts[idx].id
if idx < len(primary_atts):
row_vals['label_attachment_id'] = primary_atts[idx].id
if row_vals:
row.sudo().write(row_vals)
# Shipment-level fields. Primary label = first attachment; mirror
# all labels onto x_fc_label_attachment_ids for the multi-print UX.
# Shipment-level fields. Primary label = PDF (or first attachment
# if carrier didn't return PDF); ZPL goes into its own slot so
# the Print ZPL button can find it.
vals = {'status': 'confirmed'}
if primary_tracking:
vals['tracking_number'] = primary_tracking
if label_atts:
vals['label_attachment_id'] = label_atts[0].id
if primary_atts:
vals['label_attachment_id'] = primary_atts[0].id
if 'x_fc_label_attachment_ids' in ship._fields:
vals['x_fc_label_attachment_ids'] = [(6, 0, label_atts.ids)]
vals['x_fc_label_attachment_ids'] = [(6, 0, primary_atts.ids)]
if zpl_atts and 'x_fc_label_zpl_attachment_id' in ship._fields:
vals['x_fc_label_zpl_attachment_id'] = zpl_atts[0].id
# Link the synthetic stock.picking so the Transfer field shows
# it on the shipment form. Also refresh sender/recipient/carrier
# defaults in case the operator changed carrier between create
@@ -638,7 +854,7 @@ class FpReceiving(models.Model):
ship.sudo().write(vals)
self.message_post(body=Markup(_(
'Outbound label generated. Tracking: <b>%s</b>'
)) % (tracking_number or '(see attached PDF)'))
)) % (primary_tracking or '(see attached PDF)'))
# Validate the synthetic picking so it lands in 'done' state
# instead of sitting at 'ready'. The shipping label is the proof
# of dispatch — keeping the picking open misleads anyone looking
@@ -676,12 +892,169 @@ class FpReceiving(models.Model):
self.name, picking.name, e,
)
def action_print_label(self):
"""Open the label PDF for printing.
def action_refresh_shipping_quote(self):
"""Fetch a rate quote from FedEx for the current carrier +
service + weight/dims and store it as HTML for the preview
panel. Best-effort: any failure renders a friendly error
message in the same panel instead of raising.
Returns the standard Odoo download action so the operator can
print from their browser. Phase F replaces this with auto-print
to a network printer.
Only wired up for FedEx REST today; other carriers fall back
to a "not supported" message. Add a branch here when wiring
Canada Post / UPS rate quotes.
"""
self.ensure_one()
try:
carrier = self.x_fc_carrier_id
if not carrier:
raise UserError(_('Pick an Outbound Carrier first.'))
if not self.x_fc_weight or self.x_fc_weight <= 0:
raise UserError(_('Enter a non-zero Weight first.'))
so = self.sale_order_id
if not so:
raise UserError(_(
'No sale order linked — cannot resolve sender / '
'recipient addresses for the quote.'
))
if carrier.delivery_type != 'fusion_fedex_rest':
self.x_fc_shipping_quote_html = self._fp_quote_html_msg(
_('Rate quote is only wired up for FedEx REST '
'right now. Carrier "%s" is not supported.') % (
carrier.name,
),
is_error=True,
)
return
result = self._fp_quote_fedex_rate(carrier, so)
self.x_fc_shipping_quote_html = self._fp_format_shipping_quote(
result
)
except UserError as exc:
self.x_fc_shipping_quote_html = self._fp_quote_html_msg(
str(exc), is_error=True,
)
except Exception as exc:
_logger.warning(
'Receiving %s: shipping quote failed: %s', self.name, exc,
)
self.x_fc_shipping_quote_html = self._fp_quote_html_msg(
_('Quote failed: %s') % exc, is_error=True,
)
def _fp_quote_fedex_rate(self, carrier, so):
"""Call FedEx /rate/v1/rates/quotes with the receiving's
current weight + service-type override. Returns the dict from
FedexRestRequest._get_shipping_price (price, service_name,
delivery_timestamp, etc.).
"""
# Lazy import — fusion_plating_receiving depends on
# fusion_shipping but importing at module load order can race
# with the registry. Inside-method keeps everything sane.
from odoo.addons.fusion_shipping.api.fedex_rest.request import (
FedexRequest as FedexRestRequest,
)
srm = FedexRestRequest(carrier)
if self.x_fc_outbound_service_type:
srm.service_type = self.x_fc_outbound_service_type
package_type = (
carrier.fedex_rest_default_package_type_id.shipper_package_code
or 'YOUR_PACKAGING'
) if carrier.fedex_rest_default_package_type_id else 'YOUR_PACKAGING'
pkg = SimpleNamespace(
weight=self.x_fc_weight,
dimension={
'length': self.x_fc_length or 0,
'width': self.x_fc_width or 0,
'height': self.x_fc_height or 0,
},
packaging_type=package_type,
total_cost=0,
commodities=[],
currency_id=so.currency_id,
)
ship_from = (
so.warehouse_id.partner_id
if so.warehouse_id else self.env.company.partner_id
)
return srm._get_shipping_price(
ship_from=ship_from,
ship_to=so.partner_shipping_id or so.partner_id,
packages=[pkg],
currency=so.currency_id.name,
)
def _fp_format_shipping_quote(self, result):
"""Render a rate-quote result dict as the HTML the form panel
displays. Kept here so the styling decisions live next to the
view that consumes them.
"""
price = result.get('price') or 0.0
currency = result.get('currency') or ''
service_name = result.get('service_name') or ''
service_code = result.get('service_type') or ''
delivery = result.get('delivery_timestamp') or ''
day_of_week = result.get('day_of_week') or ''
transit = result.get('transit_time') or ''
# Trim the FedEx ISO timestamp to "YYYY-MM-DD HH:MM" if present.
if delivery and 'T' in delivery:
delivery = delivery.replace('T', ' ')[:16]
eta_line = ''
if delivery or day_of_week:
eta_line = '<div><strong>Estimated delivery:</strong> %s%s</div>' % (
delivery or '(date not provided)',
' (%s)' % day_of_week.title() if day_of_week else '',
)
transit_line = (
'<div><strong>Transit:</strong> %s</div>'
% transit.replace('_', ' ').title()
) if transit else ''
# Colours come from fp_shipping_quote.scss (theme-aware). Only
# structural styling lives inline (sizes, weights, spacing).
return (
'<div class="fp_shipping_quote_body" style="font-size: 14px;">'
'<div style="font-size: 22px; font-weight: 700; margin-bottom: 8px;">'
'%(currency)s %(price).2f'
'</div>'
'<div style="margin-bottom: 4px;"><strong>%(service)s</strong>'
'%(code)s</div>'
'%(eta)s'
'%(transit)s'
'<div class="fp_shipping_quote_footnote" '
'style="font-size: 11px; opacity: 0.65; margin-top: 10px;">'
'Quote is an estimate from FedEx — final charges may differ.'
'</div>'
'</div>'
) % {
'currency': currency,
'price': price,
'service': service_name or service_code or 'Carrier service',
'code': ' <span style="opacity:0.65;">(%s)</span>' % service_code if (
service_name and service_code and service_name != service_code
) else '',
'eta': eta_line,
'transit': transit_line,
}
def _fp_quote_html_msg(self, msg, is_error=False):
"""Wrap a one-line message in the same styling as the quote
panel so the right-side column doesn't flicker between layouts.
Colour comes from the wrapper class (theme-aware); errors use
the Bootstrap danger semantic so dark/light both look right.
"""
klass = 'text-danger' if is_error else ''
icon = 'fa-exclamation-triangle' if is_error else 'fa-info-circle'
return (
'<div class="%s" style="font-size: 14px;">'
'<i class="fa %s me-2"/>%s'
'</div>'
) % (klass, icon, msg)
def action_print_label(self):
"""Open the primary (PDF) label in the fusion_pdf_preview dialog.
Delegates to fusion.shipment._action_open_attachment, which
routes PDFs through the preview client action and falls back
to a new-tab URL when fusion_pdf_preview isn't installed. See
CLAUDE.md "PDF Preview" for the contract.
"""
self.ensure_one()
ship = self.x_fc_outbound_shipment_id
@@ -690,11 +1063,64 @@ class FpReceiving(models.Model):
'No outbound shipping label on this receiving. '
'Generate the label first.'
))
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%d?download=true' % ship.label_attachment_id.id,
'target': 'new',
}
return ship._action_open_attachment(ship.label_attachment_id)
def _fp_zpl_to_pdf_via_labelary(self, zpl_bytes):
"""POST raw ZPL to labelary and return the rendered PDF bytes.
Returns None on any failure — caller treats labelary as a
best-effort enhancement, never a blocker for label generation.
See CLAUDE.md "labelary.com dependency" for privacy + ratelimit
notes.
"""
if not zpl_bytes:
return None
try:
res = requests.post(
_LABELARY_URL,
data=zpl_bytes,
headers={
'Accept': 'application/pdf',
'Content-Type': 'application/x-www-form-urlencoded',
},
timeout=_LABELARY_TIMEOUT,
)
except requests.RequestException as exc:
_logger.warning(
'Receiving %s: labelary ZPL→PDF request failed: %s',
self.name, exc,
)
return None
if not res.ok:
_logger.warning(
'Receiving %s: labelary returned %s%s',
self.name, res.status_code, res.text[:200],
)
return None
return res.content
def action_print_label_zpl(self):
"""Open the ZPL/ZPLII label for direct-to-thermal-printer use.
Visibility on the form is gated by x_fc_has_label_zpl so this
only appears when a ZPL attachment is actually present — i.e.
the carrier returned ZPL on Generate, or a ZPL fetch was added
later. When no ZPL exists, the operator should use the PDF
button instead (PDF prints on any printer).
"""
self.ensure_one()
ship = self.x_fc_outbound_shipment_id
if not ship or not ship.x_fc_label_zpl_attachment_id:
raise UserError(_(
'No ZPL label on this shipment. Use the PDF version, '
'or switch the FedEx carrier label format to ZPLII and '
'regenerate.'
))
return ship.x_fc_label_zpl_attachment_id.action_fusion_preview(
title=ship.x_fc_label_zpl_attachment_id.name or 'ZPL Label',
model_name=self._name,
record_ids=self.id,
)
notes = fields.Html(string='Notes')
line_ids = fields.One2many('fp.receiving.line', 'receiving_id', string='Receiving Lines')
@@ -804,6 +1230,47 @@ class FpReceiving(models.Model):
rec._update_so_receiving_status()
rec.message_post(body=_('Receiving closed.'))
def action_reset_to_counted(self):
"""Reset a Closed receiving back to Counted.
Recovery escape hatch for when receiving was closed prematurely.
Blocked once downstream work has begun — operator must cancel
every fp.job spawned from this SO and avoid touching any step
before the rewind is allowed. Without the gate it's trivial to
rewind a receiving while jobs are mid-flight, which silently
breaks the qty_received feed and the cert mark-done gate.
"""
Job = self.env.get('fp.job')
for rec in self:
if rec.state != 'closed':
raise UserError(_('Only Closed receivings can be reset.'))
if Job is not None and rec.sale_order_id:
jobs = Job.sudo().search([
('sale_order_id', '=', rec.sale_order_id.id),
])
if jobs:
started = jobs.step_ids.filtered(
lambda s: s.state in (
'in_progress', 'paused', 'done', 'skipped',
)
)
if started:
raise UserError(_(
'Cannot reset — %d step(s) on this order have '
'been started. Reset is only allowed before '
'work begins.'
) % len(started))
active = jobs.filtered(lambda j: j.state != 'cancelled')
if active:
raise UserError(_(
'Cannot reset — %d work order(s) on this sale '
'order are not cancelled. Cancel them first, '
'then retry.'
) % len(active))
rec.state = 'counted'
rec._update_so_receiving_status()
rec.message_post(body=_('Receiving reset to Counted.'))
# -------------------------------------------------------------------------
# Legacy state actions — kept for backward compatibility.
# Deprecated: Sub 8 moves part-level inspection to fp.racking.inspection.

View File

@@ -10,7 +10,8 @@ here (not in fusion_shipping) to keep the upstream module untouched.
"""
import logging
from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
@@ -41,6 +42,42 @@ class FusionShipment(models.Model):
copy=False,
)
# Separate slot for the ZPL version of the label. FedEx (and most
# carriers) return one format per ship-call; the primary
# label_attachment_id holds whatever the carrier was configured to
# return (we default to PDF). This field is populated only when a
# ZPL variant has been fetched explicitly. Two slots = two smart
# buttons on the receiving form, one per format.
x_fc_label_zpl_attachment_id = fields.Many2one(
'ir.attachment',
string='ZPL Label',
copy=False,
help='ZPL/ZPLII version of the shipping label. Empty unless '
'the carrier returned ZPL (or a ZPL fetch was triggered '
'separately).',
)
def action_view_label_zpl(self):
"""Download the ZPL label for direct-to-thermal-printer use.
ZPL is text/plain — the PDF preview dialog can't render it, so
this stays on the legacy download path (no preview, just a file
the operator sends to their Zebra). Mirrors fp.receiving's
action_print_label_zpl so the button exists on both forms.
"""
self.ensure_one()
if not self.x_fc_label_zpl_attachment_id:
raise UserError(_(
'No ZPL label on this shipment. Use the PDF version, '
'or switch the carrier label format to ZPLII and '
'regenerate.'
))
return self.x_fc_label_zpl_attachment_id.action_fusion_preview(
title=self.x_fc_label_zpl_attachment_id.name or 'ZPL Label',
model_name=self._name,
record_ids=self.id,
)
# Phase C — resolved carrier tracking URL with the tracking number
# substituted into the carrier.tracking_url template. Used by the
# shipment_labeled email template and any other place that needs a

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,6 +41,34 @@
<field name="dpi">90</field>
</record>
<!-- ============================================================= -->
<!-- Compact A4 Portrait for customer-facing reports -->
<!-- (SO confirmation, quotation, invoice, packing slip, BoL). -->
<!-- Keeps the external_layout header band (logo + company addr) -->
<!-- but shrinks the reserved zone from Odoo's default ~40mm to -->
<!-- 22mm so the document title sits ~5mm under the logo instead -->
<!-- of 30mm. header_spacing kept at 3mm so the header HTML never -->
<!-- bleeds into body content on a page break. See CLAUDE.md row -->
<!-- "wkhtmltopdf header overlap" for the underlying mechanic. -->
<!-- ============================================================= -->
<record id="paperformat_fp_a4_portrait" model="report.paperformat">
<field name="name">Fusion Plating A4 Portrait (Compact)</field>
<field name="default" eval="False"/>
<field name="format">A4</field>
<field name="orientation">Portrait</field>
<!-- margin_top sized for the standard FP header (ENTECH logo +
2-line company address). Earlier 22mm clipped it — the logo
+ name + address actually need ~28mm. 32mm leaves a small
clean gap before the title. Tighter than Odoo's 40mm default. -->
<field name="margin_top">32</field>
<field name="margin_bottom">15</field>
<field name="margin_left">10</field>
<field name="margin_right">10</field>
<field name="header_line" eval="False"/>
<field name="header_spacing">3</field>
<field name="dpi">90</field>
</record>
<!-- ============================================================= -->
<!-- 1. Certificate of Conformance (Portal Job) — Landscape -->
<!-- ============================================================= -->
@@ -266,6 +294,14 @@
<field name="print_report_name">(object.state in ('draft', 'sent') and 'Quotation - %s' % object.name) or 'Order - %s' % object.name</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<!-- Uses Odoo's default paperformat so web.external_layout's
header/footer band gets its reserved space correctly (same
approach as report_coc_en / report_coc_fr). Title spacing
below the header is controlled by `padding-top` on the body
wrapper in report_fp_sale.xml — NOT by a custom paperformat,
since trimming the paperformat margin makes the header HTML
bleed into the body. See CLAUDE.md "wkhtmltopdf header
overlap" for the underlying mechanic. -->
</record>
<record id="action_report_fp_sale_landscape" model="ir.actions.report">

View File

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

View File

@@ -3,11 +3,150 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Fusion Plating — Packing Slip / Shipping Confirmation (Portrait + Landscape).
Binds to stock.picking. Shows parts, quantities, lot/serial tracking,
and a receiver sign-off.
Binds to stock.picking. Bill-To / Ship-To boxes, bilingual column
headers, Received-By signature block and a QR code for scan-to-sign.
-->
<odoo>
<!-- ============================================================= -->
<!-- Shared bits -->
<!-- ============================================================= -->
<template id="fp_packing_slip_styles">
<style>
.fp-ps-addrtable td { vertical-align: top; padding: 8px 10px; font-size: 10pt; }
.fp-ps-addrtable .fp-ps-addr-label { font-weight: bold; font-size: 9pt; color: #333; text-transform: uppercase; margin-bottom: 4px; }
.fp-ps-info-table th { background-color: #eaeaea; }
.fp-ps-info-table td { text-align: center; font-size: 11pt; padding: 8px; }
.fp-ps-items-table th { font-size: 8.5pt; line-height: 1.1; padding: 4px 4px; }
.fp-ps-items-table th .fp-fr { display: block; font-weight: normal; color: #555; font-size: 7.5pt; }
.fp-ps-items-table td { font-size: 9.5pt; padding: 5px 5px; }
.fp-ps-num { text-align: center; }
.fp-ps-sig-table td { padding: 10px 12px; vertical-align: top; }
.fp-ps-sig-line { border-bottom: 1px solid #000; min-height: 38px; margin-top: 4px; }
.fp-ps-sig-label { font-weight: bold; font-size: 9pt; text-transform: uppercase; color: #333; }
.fp-ps-sig-sub { font-size: 8pt; color: #666; }
.fp-ps-qr-box { text-align: center; padding: 6px; }
.fp-ps-qr-box img { width: 110px; height: 110px; display: inline-block; }
.fp-ps-qr-caption { font-size: 9pt; color: #333; margin-top: 4px; line-height: 1.2; }
.fp-ps-qr-caption .fp-fr { display: block; color: #666; font-size: 8pt; }
</style>
</template>
<!-- Address box content (shared by portrait + landscape) -->
<template id="fp_packing_slip_addr_block">
<div class="fp-ps-addr-label" t-esc="label"/>
<strong><span t-esc="partner.name or ''"/></strong>
<div t-field="partner"
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
</template>
<!-- Items table (shared markup; only widths change between layouts) -->
<template id="fp_packing_slip_items">
<table class="bordered fp-ps-items-table">
<thead>
<tr>
<th t-att-style="w_ordered or 'width: 8%;'">
Ordered<span class="fp-fr">Comm.</span>
</th>
<th t-att-style="w_shipped or 'width: 8%;'">
Shipped<span class="fp-fr">EXP</span>
</th>
<th t-att-style="w_bo or 'width: 8%;'">
B/O<span class="fp-fr">À venir</span>
</th>
<th class="text-start" t-att-style="w_part or 'width: 17%;'">
Part Number<span class="fp-fr">N° de pièce</span>
</th>
<th t-att-style="w_po or 'width: 11%;'">
PO<span class="fp-fr">B/C</span>
</th>
<th t-att-style="w_wo or 'width: 11%;'">
WO<span class="fp-fr">B/T</span>
</th>
<th t-att-style="w_process or 'width: 14%;'">
Process<span class="fp-fr">Procédé</span>
</th>
<th class="text-start" t-att-style="w_desc or 'width: 23%;'">
Description
</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.move_ids_without_package" t-as="move">
<t t-set="line" t-value="move.sale_line_id or move"/>
<t t-set="ordered_qty" t-value="move.product_uom_qty or 0.0"/>
<t t-set="done_qty" t-value="move.quantity or 0.0"/>
<t t-set="bo_qty" t-value="ordered_qty - done_qty if ordered_qty &gt; done_qty else 0.0"/>
<t t-set="wo_job" t-value="doc.env['fp.job'].search([('sale_order_line_ids', 'in', move.sale_line_id.ids)], limit=1) if move.sale_line_id else doc.env['fp.job']"/>
<t t-set="proc_variant" t-value="(move.sale_line_id.x_fc_process_variant_id if move.sale_line_id and 'x_fc_process_variant_id' in move.sale_line_id._fields else False)"/>
<t t-set="proc_label" t-value="(proc_variant.variant_label or proc_variant.name) if proc_variant else ((move.sale_line_id.x_fc_part_catalog_id.default_process_id.variant_label or move.sale_line_id.x_fc_part_catalog_id.default_process_id.name) if move.sale_line_id and move.sale_line_id.x_fc_part_catalog_id and move.sale_line_id.x_fc_part_catalog_id.default_process_id else '')"/>
<tr>
<td class="fp-ps-num">
<span t-esc="int(ordered_qty) if ordered_qty == int(ordered_qty) else ordered_qty"/>
</td>
<td class="fp-ps-num">
<span t-esc="int(done_qty) if done_qty == int(done_qty) else done_qty"/>
</td>
<td class="fp-ps-num">
<span t-esc="int(bo_qty) if bo_qty == int(bo_qty) else bo_qty"/>
</td>
<td>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
</td>
<td class="fp-ps-num">
<span t-esc="po_number or '-'"/>
</td>
<td class="fp-ps-num">
<t t-if="wo_job"><span t-esc="wo_job.name"/></t>
<t t-else="">-</t>
</td>
<td class="fp-ps-num">
<span t-esc="proc_label or '-'"/>
</td>
<td>
<t t-call="fusion_plating_reports.customer_line_description"/>
</td>
</tr>
</t>
</tbody>
</table>
</template>
<!-- Signature + QR strip (shared) -->
<template id="fp_packing_slip_signoff">
<table class="bordered fp-ps-sig-table" style="margin-top: 14px;">
<tbody>
<tr>
<td style="width: 38%;">
<div class="fp-ps-sig-label">
Received By
<span style="font-weight: normal; color: #666; font-size: 8pt;"> / Reçu par</span>
</div>
<div class="fp-ps-sig-line"/>
<div class="fp-ps-sig-sub">Print name &amp; signature</div>
</td>
<td style="width: 32%;">
<div class="fp-ps-sig-label">
Received Date
<span style="font-weight: normal; color: #666; font-size: 8pt;"> / Date de réception</span>
</div>
<div class="fp-ps-sig-line"/>
<div class="fp-ps-sig-sub">YYYY-MM-DD</div>
</td>
<td style="width: 30%;" class="fp-ps-qr-box">
<t t-if="qr_uri">
<img t-att-src="qr_uri" alt="QR Code"/>
</t>
<div class="fp-ps-qr-caption">
Scan the QR Code to Sign
<span class="fp-fr">Scannez le code QR pour signer</span>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<!-- ============================================================= -->
<!-- PORTRAIT -->
<!-- ============================================================= -->
@@ -16,6 +155,25 @@
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<t t-call="fusion_plating_reports.fp_packing_slip_styles"/>
<!-- =========================================
Pre-compute fields from the picking chain.
doc → stock.picking, doc.sale_id → SO,
partner_invoice_id → bill-to (falls back
to commercial_partner). carrier presence
decides "Ready for pick up" vs tracking ref.
========================================= -->
<t t-set="bill_partner" t-value="(doc.sale_id.partner_invoice_id if doc.sale_id and doc.sale_id.partner_invoice_id else (doc.partner_id.commercial_partner_id or doc.partner_id))"/>
<t t-set="ship_partner" t-value="doc.partner_id"/>
<t t-set="has_carrier" t-value="'carrier_id' in doc._fields and doc.carrier_id"/>
<t t-set="ship_via" t-value="(doc.carrier_id.name if has_carrier else (doc.sale_id.x_fc_ship_via if doc.sale_id and 'x_fc_ship_via' in doc.sale_id._fields and doc.sale_id.x_fc_ship_via else 'CUSTOMER PICKUP'))"/>
<t t-set="tracking_ref" t-value="doc.carrier_tracking_ref if 'carrier_tracking_ref' in doc._fields and doc.carrier_tracking_ref else False"/>
<t t-set="tracking_text" t-value="tracking_ref if tracking_ref else ('Ready for pick up' if not has_carrier else '—')"/>
<t t-set="po_number" t-value="(doc.sale_id.client_order_ref if doc.sale_id and doc.sale_id.client_order_ref else '')"/>
<t t-set="qr_payload" t-value="doc.name or ''"/>
<t t-set="qr_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('QR', qr_payload, 220, 220) if qr_payload else False"/>
<div class="fp-report">
<div class="page">
@@ -24,92 +182,67 @@
<span t-field="doc.name"/>
</h4>
<!-- From / To -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">FROM</th>
<th style="width: 50%;">SHIP TO</th>
</tr>
</thead>
<!-- Bill To / Ship To -->
<table class="bordered fp-ps-addrtable">
<tbody>
<tr>
<td style="height: 80px;">
<strong><span t-field="doc.company_id.name"/></strong><br/>
<div t-field="doc.company_id.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
</td>
<td style="height: 80px;">
<strong><span t-field="doc.partner_id.name"/></strong><br/>
<div t-field="doc.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone'], 'no_marker': True}"/>
</td>
</tr>
</tbody>
</table>
<!-- Shipment info -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 25%;">SHIP DATE</th>
<th class="info-header" style="width: 25%;">SOURCE</th>
<th class="info-header" style="width: 25%;">OPERATION</th>
<th class="info-header" style="width: 25%;">CARRIER</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.scheduled_date" t-options="{'widget': 'date'}"/></td>
<td class="text-center"><span t-esc="doc.origin or '-'"/></td>
<td class="text-center"><span t-field="doc.picking_type_id"/></td>
<td class="text-center">
<t t-if="'carrier_id' in doc._fields and doc.carrier_id">
<span t-field="doc.carrier_id"/>
<td style="width: 50%;">
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
<t t-set="label" t-value="'Bill To:'"/>
<t t-set="partner" t-value="bill_partner"/>
</t>
</td>
<td style="width: 50%;">
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
<t t-set="label" t-value="'Ship To:'"/>
<t t-set="partner" t-value="ship_partner"/>
</t>
<t t-else="">-</t>
</td>
</tr>
</tbody>
</table>
<!-- Products -->
<table class="bordered">
<!-- Ship details -->
<table class="bordered fp-ps-info-table">
<thead>
<tr>
<th class="text-start" style="width: 22%;">PART NUMBER</th>
<th class="text-start" style="width: 34%;">DESCRIPTION</th>
<th style="width: 12%;">QTY</th>
<th style="width: 10%;">UOM</th>
<th style="width: 22%;">LOT / SERIAL</th>
<th style="width: 33%;">
Ship Via<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Mode d'expédition</span>
</th>
<th style="width: 33%;">
Shipping Date<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Date d'expédition</span>
</th>
<th style="width: 34%;">
Tracking #<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">N° de suivi</span>
</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.move_ids_without_package" t-as="move">
<tr>
<t t-set="line" t-value="move.sale_line_id or move"/>
<td>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
</td>
<td>
<t t-call="fusion_plating_reports.customer_line_description"/>
</td>
<td class="text-center">
<span t-esc="int(move.quantity) if move.quantity == int(move.quantity) else move.quantity"/>
</td>
<td class="text-center"><span t-field="move.product_uom"/></td>
<td>
<t t-foreach="move.move_line_ids" t-as="ml">
<t t-if="ml.lot_id">
<span t-field="ml.lot_id.name"/><br/>
</t>
</t>
</td>
</tr>
</t>
<tr>
<td><span t-esc="ship_via"/></td>
<td>
<t t-if="doc.scheduled_date">
<span t-field="doc.scheduled_date" t-options="{'widget': 'date'}"/>
</t>
<t t-else=""></t>
</td>
<td><span t-esc="tracking_text"/></td>
</tr>
</tbody>
</table>
<!-- Items -->
<t t-call="fusion_plating_reports.fp_packing_slip_items">
<t t-set="w_ordered" t-value="'width: 8%;'"/>
<t t-set="w_shipped" t-value="'width: 8%;'"/>
<t t-set="w_bo" t-value="'width: 8%;'"/>
<t t-set="w_part" t-value="'width: 17%;'"/>
<t t-set="w_po" t-value="'width: 11%;'"/>
<t t-set="w_wo" t-value="'width: 11%;'"/>
<t t-set="w_process" t-value="'width: 14%;'"/>
<t t-set="w_desc" t-value="'width: 23%;'"/>
</t>
<!-- Notes -->
<t t-if="doc.note">
<div style="margin-top: 10px;">
@@ -118,21 +251,8 @@
</div>
</t>
<!-- Sign off -->
<div class="row" style="margin-top: 30px;">
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Shipper (Signature / Date)</div>
</div>
</div>
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Receiver (Signature / Date)</div>
</div>
</div>
</div>
<!-- Sign-off + QR -->
<t t-call="fusion_plating_reports.fp_packing_slip_signoff"/>
</div>
</div>
@@ -149,6 +269,18 @@
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<t t-call="fusion_plating_reports.fp_packing_slip_styles"/>
<t t-set="bill_partner" t-value="(doc.sale_id.partner_invoice_id if doc.sale_id and doc.sale_id.partner_invoice_id else (doc.partner_id.commercial_partner_id or doc.partner_id))"/>
<t t-set="ship_partner" t-value="doc.partner_id"/>
<t t-set="has_carrier" t-value="'carrier_id' in doc._fields and doc.carrier_id"/>
<t t-set="ship_via" t-value="(doc.carrier_id.name if has_carrier else (doc.sale_id.x_fc_ship_via if doc.sale_id and 'x_fc_ship_via' in doc.sale_id._fields and doc.sale_id.x_fc_ship_via else 'CUSTOMER PICKUP'))"/>
<t t-set="tracking_ref" t-value="doc.carrier_tracking_ref if 'carrier_tracking_ref' in doc._fields and doc.carrier_tracking_ref else False"/>
<t t-set="tracking_text" t-value="tracking_ref if tracking_ref else ('Ready for pick up' if not has_carrier else '—')"/>
<t t-set="po_number" t-value="(doc.sale_id.client_order_ref if doc.sale_id and doc.sale_id.client_order_ref else '')"/>
<t t-set="qr_payload" t-value="doc.name or ''"/>
<t t-set="qr_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('QR', qr_payload, 220, 220) if qr_payload else False"/>
<div class="fp-landscape">
<div class="page">
@@ -157,104 +289,67 @@
<span t-field="doc.name"/>
</h2>
<!-- From / To -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">FROM</th>
<th style="width: 50%;">SHIP TO</th>
</tr>
</thead>
<!-- Bill To / Ship To -->
<table class="bordered fp-ps-addrtable">
<tbody>
<tr>
<td style="height: 80px; font-size: 12pt;">
<strong><span t-field="doc.company_id.name"/></strong><br/>
<div t-field="doc.company_id.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
<td style="width: 50%;">
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
<t t-set="label" t-value="'Bill To:'"/>
<t t-set="partner" t-value="bill_partner"/>
</t>
</td>
<td style="height: 80px; font-size: 12pt;">
<strong><span t-field="doc.partner_id.name"/></strong><br/>
<div t-field="doc.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone'], 'no_marker': True}"/>
<td style="width: 50%;">
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
<t t-set="label" t-value="'Ship To:'"/>
<t t-set="partner" t-value="ship_partner"/>
</t>
</td>
</tr>
</tbody>
</table>
<!-- Shipment info -->
<table class="bordered info-table">
<!-- Ship details -->
<table class="bordered fp-ps-info-table">
<thead>
<tr>
<th>SHIP DATE</th>
<th>SOURCE</th>
<th>OPERATION</th>
<th>CARRIER</th>
<th>TRACKING REF</th>
<th style="width: 33%;">
Ship Via<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Mode d'expédition</span>
</th>
<th style="width: 33%;">
Shipping Date<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Date d'expédition</span>
</th>
<th style="width: 34%;">
Tracking #<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">N° de suivi</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.scheduled_date" t-options="{'widget': 'date'}"/></td>
<td class="text-center"><span t-esc="doc.origin or '-'"/></td>
<td class="text-center"><span t-field="doc.picking_type_id"/></td>
<td class="text-center">
<t t-if="'carrier_id' in doc._fields and doc.carrier_id">
<span t-field="doc.carrier_id"/>
<td><span t-esc="ship_via"/></td>
<td>
<t t-if="doc.scheduled_date">
<span t-field="doc.scheduled_date" t-options="{'widget': 'date'}"/>
</t>
<t t-else="">-</t>
</td>
<td class="text-center">
<t t-if="'carrier_tracking_ref' in doc._fields">
<span t-esc="doc.carrier_tracking_ref or '-'"/>
</t>
<t t-else="">-</t>
<t t-else=""></t>
</td>
<td><span t-esc="tracking_text"/></td>
</tr>
</tbody>
</table>
<!-- Products -->
<table class="bordered">
<thead>
<tr>
<th class="text-start" style="width: 18%;">PART NUMBER</th>
<th class="text-start" style="width: 26%;">DESCRIPTION</th>
<th style="width: 10%;">ORDERED</th>
<th style="width: 10%;">DONE</th>
<th style="width: 8%;">UOM</th>
<th style="width: 14%;">LOT / SERIAL</th>
<th style="width: 14%;">NOTES</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.move_ids_without_package" t-as="move">
<tr>
<t t-set="line" t-value="move.sale_line_id or move"/>
<td>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
</td>
<td>
<t t-call="fusion_plating_reports.customer_line_description"/>
</td>
<td class="text-center">
<span t-esc="int(move.product_uom_qty) if move.product_uom_qty == int(move.product_uom_qty) else move.product_uom_qty"/>
</td>
<td class="text-center">
<span t-esc="int(move.quantity) if move.quantity == int(move.quantity) else move.quantity"/>
</td>
<td class="text-center"><span t-field="move.product_uom"/></td>
<td>
<t t-foreach="move.move_line_ids" t-as="ml">
<t t-if="ml.lot_id">
<span t-field="ml.lot_id.name"/><br/>
</t>
</t>
</td>
<td/>
</tr>
</t>
</tbody>
</table>
<!-- Items: landscape gets a touch more breathing room on
the description / part columns. -->
<t t-call="fusion_plating_reports.fp_packing_slip_items">
<t t-set="w_ordered" t-value="'width: 7%;'"/>
<t t-set="w_shipped" t-value="'width: 7%;'"/>
<t t-set="w_bo" t-value="'width: 7%;'"/>
<t t-set="w_part" t-value="'width: 16%;'"/>
<t t-set="w_po" t-value="'width: 10%;'"/>
<t t-set="w_wo" t-value="'width: 10%;'"/>
<t t-set="w_process" t-value="'width: 13%;'"/>
<t t-set="w_desc" t-value="'width: 30%;'"/>
</t>
<!-- Notes -->
<t t-if="doc.note">
@@ -264,21 +359,8 @@
</div>
</t>
<!-- Sign off -->
<div class="row" style="margin-top: 30px;">
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Shipper (Signature / Date)</div>
</div>
</div>
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Receiver (Signature / Date)</div>
</div>
</div>
</div>
<!-- Sign-off + QR -->
<t t-call="fusion_plating_reports.fp_packing_slip_signoff"/>
</div>
</div>

View File

@@ -11,28 +11,99 @@
<!-- ============================================================= -->
<!-- PORTRAIT -->
<!-- ============================================================= -->
<!-- Shared bilingual-label snippet. CSS class `.fp-bl` does the
two-line render: English on top, French underneath in a lighter
italic. Stored next to the report's own scss-style block so it
doesn't drift when the same idiom propagates to other reports.
Title sizing: the previous attempt at "compact" (negative
margin-top) pushed the title up INTO the wkhtmltopdf header zone
(the company logo band) and clipped the top of the H1 glyphs.
External_layout already places the page body at the bottom of
the reserved margin-top — don't fight that. Use a small positive
gap and shrink the title text instead. -->
<template id="fp_sale_bilingual_styles">
<style>
/* Inline bilingual: English bold, then a faint slash, then
French italic-grey. Sits on one line where room allows
and wraps to two naturally if the cell is narrow. Apply
this everywhere except super-narrow cells (QTY, UOM)
where the cell is physically too tight even for the
shortest French word — those use the stacked variant
below. */
.fp-bl-en { font-weight: bold; }
.fp-bl-sep { color: #999; margin: 0 3px; font-weight: normal; }
.fp-bl-fr { font-weight: normal; font-style: italic; color: #555; }
/* Stacked variant for narrow cells — EN on top line, FR
below in italic-grey. */
.fp-bl-en-stk { display: block; font-weight: bold; }
.fp-bl-fr-stk { display: block; font-weight: normal; font-style: italic; color: #555; font-size: 80%; margin-top: 1px; }
/* Kill the extra top padding Odoo's `.page` class adds
(1cm by default). The paperformat already reserves
header room — `.page` padding compounds on top of it
and was the source of the giant gap. Keep left/right/
bottom at 1cm so the content isn't flush to the edges. */
.fp-report.fp-sale .page { padding-top: 0 !important; }
/* Title bar uses float-based div layout, NOT an HTML table —
the global ".fp-report table" rule was applying borders
to every nested table even with "border: 0 !important",
so the only reliable fix is to avoid the table element. */
.fp-sale-titlebar { margin: 0 0 8px 0; padding: 0; overflow: hidden; }
.fp-sale-title { font-size: 14pt; line-height: 1.2; color: #2e2e2e; font-weight: bold; }
.fp-sale-title .fp-bl-fr { font-size: 10pt; }
.fp-sale-barcode { float: right; text-align: right; margin-left: 12px; }
.fp-sale-barcode img { height: 34px; max-width: 220px; }
.fp-sale-barcode .fp-bc-label { font-size: 8pt; color: #555; margin-top: 2px; }
</style>
</template>
<template id="report_fp_sale_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
<!-- Compute helpers -->
<t t-set="is_quote" t-value="doc.state in ('draft', 'sent')"/>
<t t-set="title_en" t-value="'Quotation' if is_quote else 'Order Confirmation'"/>
<t t-set="title_fr" t-value="'Devis' if is_quote else 'Confirmation de commande'"/>
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name else False"/>
<t t-set="spec_label" t-value="(doc.x_fc_customer_spec_id.display_name or doc.x_fc_customer_spec_id.name) if doc.x_fc_customer_spec_id else ''"/>
<t t-set="delivery_method_label" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '') if 'x_fc_delivery_method' in doc._fields and doc.x_fc_delivery_method else ''"/>
<div class="fp-report fp-sale">
<div class="page">
<!-- Title -->
<h4>
<span t-if="doc.state in ['draft','sent']">Quotation # </span>
<span t-else="">Sales Order # </span>
<span t-field="doc.name"/>
</h4>
<!-- Title bar: bilingual title on the left,
Code128 barcode floated right. NO <table>
— see CLAUDE.md "wkhtmltopdf header
overlap" §2 for why a table here leaks
borders even with `border:0 !important`. -->
<div class="fp-sale-titlebar">
<t t-if="barcode_uri">
<div class="fp-sale-barcode">
<img t-att-src="barcode_uri" alt="Order Barcode"/>
<div class="fp-bc-label"><span t-field="doc.name"/></div>
</div>
</t>
<div class="fp-sale-title">
<span class="fp-bl-en"><t t-esc="title_en"/></span><span class="fp-bl-sep">/</span><span class="fp-bl-fr"><t t-esc="title_fr"/></span>
<span> # </span><span t-field="doc.name"/>
</div>
</div>
<!-- Billing / Shipping -->
<!-- Billing / Shipping (wide cells — inline) -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">BILLING ADDRESS</th>
<th style="width: 50%;">SHIPPING ADDRESS</th>
<th style="width: 50%;">
<span class="fp-bl-en">Billing Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de facturation</span>
</th>
<th style="width: 50%;">
<span class="fp-bl-en">Shipping Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse d'expédition</span>
</th>
</tr>
</thead>
<tbody>
@@ -49,115 +120,144 @@
</tbody>
</table>
<!-- Order info -->
<!-- Row 1: 5 narrow cells (20% each) — stacked
so the French label doesn't overflow into
the next column. -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 20%;">ORDER DATE</th>
<th class="info-header" style="width: 20%;">EXPIRATION</th>
<th class="info-header" style="width: 20%;">SALESPERSON</th>
<th class="info-header" style="width: 20%;">CUSTOMER PO #</th>
<th class="info-header" style="width: 20%;">RUSH</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Order Date</span>
<span class="fp-bl-fr-stk">Date de commande</span>
</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Delivery Date</span>
<span class="fp-bl-fr-stk">Date de livraison</span>
</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Salesperson</span>
<span class="fp-bl-fr-stk">Vendeur</span>
</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Customer PO #</span>
<span class="fp-bl-fr-stk">N° de B/C client</span>
</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Lead Time</span>
<span class="fp-bl-fr-stk">Délai</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.date_order" t-options="{'widget': 'date'}"/></td>
<td class="text-center"><span t-field="doc.validity_date"/></td>
<td class="text-center"><span t-field="doc.user_id"/></td>
<td class="text-center"><span t-esc="doc.x_fc_po_number or '-'"/></td>
<td class="text-center">
<span t-if="doc.x_fc_rush_order" class="status-warning">RUSH</span>
<span t-else="">Standard</span>
<t t-if="doc.commitment_date">
<span t-field="doc.commitment_date" t-options="{'widget': 'date'}"/>
</t>
<t t-else=""></t>
</td>
<td class="text-center"><span t-field="doc.user_id"/></td>
<td class="text-center"><span t-esc="doc.x_fc_po_number or '—'"/></td>
<td class="text-center">
<t t-if="doc.x_fc_rush_order">
<span class="status-warning">Rush / Urgent</span>
</t>
<t t-else="">Standard</t>
</td>
</tr>
</tbody>
</table>
<!-- Plating info -->
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_customer_spec_id or doc.x_fc_delivery_method">
<!-- Row 2: 3 wider cells (33% each) — inline. -->
<t t-if="doc.x_fc_customer_job_number or spec_label or delivery_method_label">
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 34%;">PART</th>
<th class="info-header" style="width: 33%;">SPECIFICATION</th>
<th class="info-header" style="width: 33%;">DELIVERY METHOD</th>
<th class="info-header" style="width: 34%;">
<span class="fp-bl-en">Customer Job #</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de travail client</span>
</th>
<th class="info-header" style="width: 33%;">
<span class="fp-bl-en">Specification</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Spécification</span>
</th>
<th class="info-header" style="width: 33%;">
<span class="fp-bl-en">Delivery Method</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Méthode de livraison</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td>
<td class="text-center"><span t-field="doc.x_fc_customer_spec_id"/></td>
<td class="text-center">
<t t-set="dm" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '-')"/>
<span t-esc="dm"/>
</td>
<td class="text-center"><span t-esc="doc.x_fc_customer_job_number or '—'"/></td>
<td class="text-center"><span t-esc="spec_label or '—'"/></td>
<td class="text-center"><span t-esc="delivery_method_label or '—'"/></td>
</tr>
</tbody>
</table>
</t>
<!-- Scheduling + customer job reference -->
<t t-if="doc.x_fc_customer_job_number or doc.x_fc_planned_start_date or doc.commitment_date or doc.x_fc_ship_via">
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 25%;">CUSTOMER JOB #</th>
<th class="info-header" style="width: 25%;">PLANNED START</th>
<th class="info-header" style="width: 25%;">CUSTOMER DEADLINE</th>
<th class="info-header" style="width: 25%;">SHIP VIA</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-esc="doc.x_fc_customer_job_number or '-'"/></td>
<td class="text-center"><span t-field="doc.x_fc_planned_start_date"/></td>
<td class="text-center"><span t-field="doc.commitment_date"/></td>
<td class="text-center"><span t-esc="doc.x_fc_ship_via or '-'"/></td>
</tr>
</tbody>
</table>
</t>
<!-- Blanket / block-partial callout (confirmed-order shipping flags) -->
<!-- Blanket / block-partial callout -->
<t t-if="doc.x_fc_is_blanket_order or doc.x_fc_block_partial_shipments">
<div class="highlight-box">
<t t-if="doc.x_fc_is_blanket_order">
<strong>Blanket Order.</strong>
<strong>Blanket Order / Commande ouverte.</strong>
Parts will be released in quantities over time.
</t>
<t t-if="doc.x_fc_block_partial_shipments">
<strong>Partial shipments blocked.</strong>
<strong>Partial shipments blocked / Expéditions partielles bloquées.</strong>
The order ships as one complete batch.
</t>
</div>
</t>
<!-- Order lines -->
<!-- Order lines. Taxes column dropped — taxes
summarized in the totals block below; per-line
tax labels were noise on a single-tax-region
plating order. The part-number cell appends
the catalog `name` (Part Name) after the
revision so customers see PN + Rev + Name. -->
<table class="bordered">
<thead>
<tr>
<th class="text-start" style="width: 20%;">PART NUMBER</th>
<th class="text-start" style="width: 30%;">DESCRIPTION</th>
<th style="width: 8%;">QTY</th>
<th style="width: 8%;">UOM</th>
<th style="width: 12%;">UNIT PRICE</th>
<th style="width: 10%;">TAXES</th>
<th style="width: 12%;">AMOUNT</th>
<th class="text-start" style="width: 24%;">
<span class="fp-bl-en">Part Number</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de pièce</span>
</th>
<th class="text-start" style="width: 38%;">
<span class="fp-bl-en">Description</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Description</span>
</th>
<th style="width: 8%;">
<span class="fp-bl-en-stk">Qty</span>
<span class="fp-bl-fr-stk">Qté</span>
</th>
<th style="width: 8%;">
<span class="fp-bl-en-stk">UOM</span>
<span class="fp-bl-fr-stk">UDM</span>
</th>
<th style="width: 11%;">
<span class="fp-bl-en-stk">Unit Price</span>
<span class="fp-bl-fr-stk">Prix unitaire</span>
</th>
<th style="width: 11%;">
<span class="fp-bl-en-stk">Amount</span>
<span class="fp-bl-fr-stk">Montant</span>
</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.order_line" t-as="line">
<t t-if="line.display_type == 'line_section'">
<tr class="section-row"><td colspan="7"><strong t-field="line.name"/></td></tr>
<tr class="section-row"><td colspan="6"><strong t-field="line.name"/></td></tr>
</t>
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr>
<tr class="note-row"><td colspan="6"><span t-field="line.name"/></td></tr>
</t>
<t t-elif="not line.display_type or line.display_type == 'product'">
<tr>
<td>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
<span> - </span>
<span t-esc="line.x_fc_part_catalog_id.name"/>
</t>
</td>
<td>
<t t-call="fusion_plating_reports.customer_line_description"/>
@@ -169,9 +269,6 @@
<td class="text-end">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
<td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
@@ -184,37 +281,38 @@
<!-- Terms + Totals -->
<div class="row" style="margin-top: 15px;">
<div class="col-6">
<t t-if="doc.payment_term_id.note">
<strong>Payment Terms:</strong><br/>
<span t-field="doc.payment_term_id.note"/>
</t>
<t t-if="doc.x_fc_invoice_strategy">
<div style="margin-top: 10px;">
<strong>Invoice Strategy: </strong>
<t t-set="inv_strat" t-value="dict(doc._fields['x_fc_invoice_strategy'].selection).get(doc.x_fc_invoice_strategy, '-')"/>
<span t-esc="inv_strat"/>
<t t-if="doc.x_fc_invoice_strategy == 'deposit' and doc.x_fc_deposit_percent">
(<span t-esc="doc.x_fc_deposit_percent"/>%)
</t>
</div>
<t t-if="doc.payment_term_id">
<strong>Payment Terms / Modalités de paiement:</strong><br/>
<t t-if="doc.payment_term_id.note">
<span t-field="doc.payment_term_id.note"/>
</t>
<t t-else="">
<span t-field="doc.payment_term_id.name"/>
</t>
</t>
</div>
<div class="col-6" style="text-align: right;">
<table class="totals-table" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 150px;">Subtotal</td>
<td style="min-width: 150px;">
<span class="fp-bl-en">Subtotal</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Sous-total</span>
</td>
<td class="text-end" style="min-width: 110px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Taxes</td>
<td>
<span class="fp-bl-en">Taxes</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Taxes</span>
</td>
<td class="text-end">
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr style="background-color: #c1c1c1;">
<td><strong>Grand Total</strong></td>
<td>
<span class="fp-bl-en">Grand Total</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Total général</span>
</td>
<td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
@@ -226,7 +324,7 @@
<!-- External (customer-visible) notes -->
<t t-if="doc.x_fc_external_note">
<div style="margin-top: 15px;">
<strong>Notes:</strong>
<strong>Notes / Remarques:</strong>
<div t-field="doc.x_fc_external_note"/>
</div>
</t>
@@ -234,7 +332,7 @@
<!-- Terms and Conditions -->
<t t-if="doc.note">
<div style="margin-top: 15px;">
<strong>Terms and Conditions:</strong>
<strong>Terms and Conditions / Conditions générales:</strong>
<div t-field="doc.note"/>
</div>
</t>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Schedule',
'version': '19.0.2.0.0',
'version': '19.0.2.1.0',
'category': 'Services/Appointment',
'summary': 'Multi-calendar sync, portal booking, and shareable scheduling links',
'description': """

View File

@@ -399,12 +399,17 @@ class FusionCalendarAccount(models.Model):
}
def _find_existing_event(self, CalendarEvent, vals):
"""Find an existing calendar event matching name+start+stop to avoid duplicates."""
"""Find an existing calendar event matching name+start+stop to avoid duplicates.
Includes archived records so prior wrongly-archived duplicates get
reused (the caller is expected to reactivate them) instead of new
copies piling up on every sync iteration.
"""
start_val = vals.get('start') or vals.get('start_date')
stop_val = vals.get('stop') or vals.get('stop_date')
if not (start_val and stop_val and vals.get('name')):
return None
domain = [('name', '=', vals['name']), ('active', '=', True)]
domain = [('name', '=', vals['name']), ('active', 'in', [True, False])]
if vals.get('allday'):
domain += [('start_date', '=', start_val), ('stop_date', '=', stop_val)]
else:
@@ -485,6 +490,8 @@ class FusionCalendarAccount(models.Model):
reuse_event = self._find_existing_event(CalendarEvent, vals)
if reuse_event:
if not reuse_event.active:
reuse_event.with_context(**ctx).write({'active': True})
self._upsert_event_link(EventLink, reuse_event.id, external_id, ical_uid)
return 'updated'
@@ -677,11 +684,18 @@ class FusionCalendarAccount(models.Model):
('x_fc_external_id', '=', external_id),
], limit=1)
if event_data.get('@removed') or event_data.get('isCancelled'):
removed = event_data.get('@removed')
removed_reason = removed.get('reason') if isinstance(removed, dict) else None
if event_data.get('isCancelled') or removed_reason == 'deleted':
if link and link.x_fc_event_id:
link.x_fc_event_id.with_context(**ctx).write({'active': False})
link.unlink()
return 'deleted'
if removed:
# @removed with reason != 'deleted' (typically 'changed') means the
# event drifted outside the original delta query window — it still
# exists upstream. Leave the local copy alone so it stays visible.
return 'skipped'
vals = self._microsoft_event_to_odoo_vals(event_data)
if not vals:
@@ -690,8 +704,11 @@ class FusionCalendarAccount(models.Model):
ical_uid = event_data.get('iCalUId', '')
if link:
if link.x_fc_event_id and link.x_fc_event_id.active:
link.x_fc_event_id.with_context(**ctx).write(vals)
if link.x_fc_event_id:
update_vals = dict(vals)
if not link.x_fc_event_id.active:
update_vals['active'] = True
link.x_fc_event_id.with_context(**ctx).write(update_vals)
link.write({'x_fc_last_synced': fields.Datetime.now()})
return 'updated'
@@ -701,11 +718,15 @@ class FusionCalendarAccount(models.Model):
], limit=1) if ical_uid else None
if existing_link and existing_link.x_fc_event_id:
if not existing_link.x_fc_event_id.active:
existing_link.x_fc_event_id.with_context(**ctx).write({'active': True})
self._upsert_event_link(EventLink, existing_link.x_fc_event_id.id, external_id, ical_uid)
return 'updated'
reuse_event = self._find_existing_event(CalendarEvent, vals)
if reuse_event:
if not reuse_event.active:
reuse_event.with_context(**ctx).write({'active': True})
self._upsert_event_link(EventLink, reuse_event.id, external_id, ical_uid)
return 'updated'

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
{
'name': 'Fusion Tasks',
'version': '19.0.1.0.0',
'version': '19.0.1.1.0',
'category': 'Services/Field Service',
'summary': 'Technician scheduling, route planning, GPS tracking, and cross-instance sync.',
'author': 'Nexa Systems Inc.',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
from odoo import models, fields, api
class ResUsers(models.Model):
@@ -24,3 +24,26 @@ class ResUsers(models.Model):
'Must be the same value on all instances for the same person.',
copy=False,
)
@api.onchange('x_fc_tech_sync_id')
def _onchange_x_fc_tech_sync_id_dup_warning(self):
if not self.x_fc_tech_sync_id:
return
dup = self.env['res.users'].sudo().search([
('id', '!=', self._origin.id or self.id),
('x_fc_tech_sync_id', '=', self.x_fc_tech_sync_id),
('x_fc_is_field_staff', '=', True),
('active', '=', True),
], limit=1)
if dup:
return {
'warning': {
'title': "Duplicate Tech Sync ID",
'message': (
f"Tech Sync ID {self.x_fc_tech_sync_id!r} is already used "
f"by {dup.login} ({dup.partner_id.name}). Cross-instance "
f"task sync only routes to ONE user per sync ID — "
f"pick a unique value or only one tech's tasks will sync."
),
}
}

View File

@@ -135,11 +135,18 @@ class FusionTaskSyncConfig(models.Model):
], {'fields': ['id', 'x_fc_tech_sync_id']})
if not remote_users:
return {}
return {
ru['x_fc_tech_sync_id']: ru['id']
for ru in remote_users
if ru.get('x_fc_tech_sync_id')
}
by_sync_id = {}
for ru in remote_users:
sid = ru.get('x_fc_tech_sync_id')
if not sid:
continue
if sid in by_sync_id:
_logger.warning(
"Task sync: duplicate x_fc_tech_sync_id %r on remote %s "
"(uids %d and %d) — only the last seen will be reachable",
sid, self.name, by_sync_id[sid], ru['id'])
by_sync_id[sid] = ru['id']
return by_sync_id
def _get_local_syncid_to_uid(self):
"""Build {x_fc_tech_sync_id: local_user_id} for local field staff."""
@@ -148,7 +155,16 @@ class FusionTaskSyncConfig(models.Model):
('x_fc_tech_sync_id', '!=', False),
('active', '=', True),
])
return {u.x_fc_tech_sync_id: u.id for u in techs}
by_sync_id = {}
for u in techs:
sid = u.x_fc_tech_sync_id
if sid in by_sync_id:
_logger.warning(
"Task sync: duplicate x_fc_tech_sync_id %r locally "
"(uids %d and %d) — only the last seen will be reachable",
sid, by_sync_id[sid], u.id)
by_sync_id[sid] = u.id
return by_sync_id
# ------------------------------------------------------------------
# Connection test
@@ -219,9 +235,15 @@ class FusionTaskSyncConfig(models.Model):
for task in tasks:
sync_id = local_map.get(task.technician_id.id)
if not sync_id:
_logger.info(
"Task sync: skipping task %s — technician %s has no x_fc_tech_sync_id",
task.name, task.technician_id.login or task.technician_id.id)
continue
remote_tech_uid = remote_map.get(sync_id)
if not remote_tech_uid:
_logger.info(
"Task sync: skipping task %s — sync_id %r has no matching tech on %s",
task.name, sync_id, self.name)
continue
# Map additional technicians to remote user IDs